erster Commit

This commit is contained in:
2026-04-16 19:37:28 +02:00
commit 3e04fc3296
28 changed files with 4630 additions and 0 deletions
BIN
View File
Binary file not shown.
+432
View File
@@ -0,0 +1,432 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
26EF66312F9112E700824F91 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26EF66252F9112E700824F91 /* Assets.xcassets */; };
26EF66322F9112E700824F91 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; };
26EF66332F9112E700824F91 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662F2F9112E700824F91 /* TodayView.swift */; };
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662A2F9112E700824F91 /* PersonDetailView.swift */; };
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662E2F9112E700824F91 /* ThemeSystem.swift */; };
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66292F9112E700824F91 /* PeopleListView.swift */; };
26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66242F9112E700824F91 /* AddPersonView.swift */; };
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662C2F9112E700824F91 /* SettingsView.swift */; };
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66232F9112E700824F91 /* AddMomentView.swift */; };
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662B2F9112E700824F91 /* NahbarApp.swift */; };
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66272F9112E700824F91 /* ContentView.swift */; };
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66262F9112E700824F91 /* ContactPickerView.swift */; };
26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662D2F9112E700824F91 /* SharedComponents.swift */; };
26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF663E2F9129D700824F91 /* CallWindowManager.swift */; };
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66402F9129F000824F91 /* CallWindowSetupView.swift */; };
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66422F912A0000824F91 /* CallSuggestionView.swift */; };
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66442F91350200824F91 /* AppLockManager.swift */; };
26EF66472F91351800824F91 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66462F91351800824F91 /* AppLockView.swift */; };
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66482F91352D00824F91 /* AppLockSetupView.swift */; };
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664A2F913C8600824F91 /* LogbuchView.swift */; };
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664D2F91514B00824F91 /* ThemePickerView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
26EF66232F9112E700824F91 /* AddMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMomentView.swift; sourceTree = "<group>"; };
26EF66242F9112E700824F91 /* AddPersonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPersonView.swift; sourceTree = "<group>"; };
26EF66252F9112E700824F91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
26EF66262F9112E700824F91 /* ContactPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPickerView.swift; sourceTree = "<group>"; };
26EF66272F9112E700824F91 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
26EF66282F9112E700824F91 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
26EF66292F9112E700824F91 /* PeopleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleListView.swift; sourceTree = "<group>"; };
26EF662A2F9112E700824F91 /* PersonDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailView.swift; sourceTree = "<group>"; };
26EF662B2F9112E700824F91 /* NahbarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarApp.swift; sourceTree = "<group>"; };
26EF662C2F9112E700824F91 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
26EF662D2F9112E700824F91 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
26EF662E2F9112E700824F91 /* ThemeSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSystem.swift; sourceTree = "<group>"; };
26EF662F2F9112E700824F91 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
26EF663E2F9129D700824F91 /* CallWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallWindowManager.swift; sourceTree = "<group>"; };
26EF66402F9129F000824F91 /* CallWindowSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallWindowSetupView.swift; sourceTree = "<group>"; };
26EF66422F912A0000824F91 /* CallSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSuggestionView.swift; sourceTree = "<group>"; };
26EF66442F91350200824F91 /* AppLockManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockManager.swift; sourceTree = "<group>"; };
26EF66462F91351800824F91 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = "<group>"; };
26EF66482F91352D00824F91 /* AppLockSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupView.swift; sourceTree = "<group>"; };
26EF664A2F913C8600824F91 /* LogbuchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbuchView.swift; sourceTree = "<group>"; };
26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
265F921D2F9109B500CE0A5C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
265F92172F9109B500CE0A5C = {
isa = PBXGroup;
children = (
26EF66302F9112E700824F91 /* nahbar */,
265F92212F9109B500CE0A5C /* Products */,
);
sourceTree = "<group>";
};
265F92212F9109B500CE0A5C /* Products */ = {
isa = PBXGroup;
children = (
265F92202F9109B500CE0A5C /* nahbar.app */,
);
name = Products;
sourceTree = "<group>";
};
26EF66302F9112E700824F91 /* nahbar */ = {
isa = PBXGroup;
children = (
26EF66232F9112E700824F91 /* AddMomentView.swift */,
26EF66242F9112E700824F91 /* AddPersonView.swift */,
26EF66252F9112E700824F91 /* Assets.xcassets */,
26EF66262F9112E700824F91 /* ContactPickerView.swift */,
26EF66272F9112E700824F91 /* ContentView.swift */,
26EF66282F9112E700824F91 /* Models.swift */,
26EF66292F9112E700824F91 /* PeopleListView.swift */,
26EF662A2F9112E700824F91 /* PersonDetailView.swift */,
26EF662B2F9112E700824F91 /* NahbarApp.swift */,
26EF662C2F9112E700824F91 /* SettingsView.swift */,
26EF662D2F9112E700824F91 /* SharedComponents.swift */,
26EF662E2F9112E700824F91 /* ThemeSystem.swift */,
26EF662F2F9112E700824F91 /* TodayView.swift */,
26EF663E2F9129D700824F91 /* CallWindowManager.swift */,
26EF66402F9129F000824F91 /* CallWindowSetupView.swift */,
26EF66422F912A0000824F91 /* CallSuggestionView.swift */,
26EF66442F91350200824F91 /* AppLockManager.swift */,
26EF66462F91351800824F91 /* AppLockView.swift */,
26EF66482F91352D00824F91 /* AppLockSetupView.swift */,
26EF664A2F913C8600824F91 /* LogbuchView.swift */,
26EF664D2F91514B00824F91 /* ThemePickerView.swift */,
);
path = nahbar;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
265F921F2F9109B500CE0A5C /* nahbar */ = {
isa = PBXNativeTarget;
buildConfigurationList = 265F922B2F9109B600CE0A5C /* Build configuration list for PBXNativeTarget "nahbar" */;
buildPhases = (
265F921C2F9109B500CE0A5C /* Sources */,
265F921D2F9109B500CE0A5C /* Frameworks */,
265F921E2F9109B500CE0A5C /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = nahbar;
packageProductDependencies = (
);
productName = relationz;
productReference = 265F92202F9109B500CE0A5C /* nahbar.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
265F92182F9109B500CE0A5C /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640;
TargetAttributes = {
265F921F2F9109B500CE0A5C = {
CreatedOnToolsVersion = 26.4;
};
};
};
buildConfigurationList = 265F921B2F9109B500CE0A5C /* Build configuration list for PBXProject "nahbar" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 265F92172F9109B500CE0A5C;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 265F92212F9109B500CE0A5C /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
265F921F2F9109B500CE0A5C /* nahbar */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
265F921E2F9109B500CE0A5C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
26EF66312F9112E700824F91 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
265F921C2F9109B500CE0A5C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
26EF66322F9112E700824F91 /* Models.swift in Sources */,
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */,
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */,
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */,
26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */,
26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
265F92292F9109B600CE0A5C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = EKFHUHT63T;
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 = 26.4;
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;
};
265F922A2F9109B600CE0A5C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = EKFHUHT63T;
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 = 26.4;
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;
};
265F922C2F9109B600CE0A5C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen";
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";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Team.nahbar;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
265F922D2F9109B600CE0A5C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen";
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";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Team.nahbar;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
265F921B2F9109B500CE0A5C /* Build configuration list for PBXProject "nahbar" */ = {
isa = XCConfigurationList;
buildConfigurations = (
265F92292F9109B600CE0A5C /* Debug */,
265F922A2F9109B600CE0A5C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
265F922B2F9109B600CE0A5C /* Build configuration list for PBXNativeTarget "nahbar" */ = {
isa = XCConfigurationList;
buildConfigurations = (
265F922C2F9109B600CE0A5C /* Debug */,
265F922D2F9109B600CE0A5C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 265F92182F9109B500CE0A5C /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?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>SchemeUserState</key>
<dict>
<key>relationz.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
+246
View File
@@ -0,0 +1,246 @@
import SwiftUI
import SwiftData
import EventKit
struct AddMomentView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
let person: Person
@State private var text = ""
@State private var selectedType: MomentType = .conversation
@FocusState private var isFocused: Bool
// Calendar
@State private var addToCalendar = false
@State private var eventDate: Date = {
let cal = Calendar.current
let hour = cal.component(.hour, from: Date())
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
}()
@State private var eventDuration: Double = 3600 // seconds; -1 = all-day
private var isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty }
private var showsCalendarSection: Bool { selectedType == .meeting || selectedType == .intention }
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
// Person context chip
HStack(spacing: 10) {
PersonAvatar(person: person, size: 36)
Text(person.name)
.font(.system(size: 16, weight: .medium, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Spacer()
Text(Date(), format: .dateTime.day().month(.abbreviated).locale(Locale(identifier: "de_DE")))
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 20)
.padding(.top, 8)
// Type selector
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(MomentType.allCases, id: \.self) { type in
Button {
selectedType = type
} label: {
HStack(spacing: 5) {
Image(systemName: type.icon)
.font(.system(size: 12))
Text(type.rawValue)
.font(.system(size: 13, weight: selectedType == type ? .medium : .regular))
}
.foregroundStyle(selectedType == type ? theme.accent : theme.contentSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(selectedType == type ? theme.accent.opacity(0.10) : theme.backgroundSecondary)
.clipShape(Capsule())
}
}
}
.padding(.horizontal, 20)
}
// Text input
ZStack(alignment: .topLeading) {
if text.isEmpty {
Text("Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?")
.font(.system(size: 16))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.allowsHitTesting(false)
}
TextEditor(text: $text)
.font(.system(size: 16, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
.scrollContentBackground(.hidden)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.focused($isFocused)
}
.frame(minHeight: 180)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
// Calendar section shown for Treffen and Vorhaben
if showsCalendarSection {
calendarSection
.transition(.opacity.combined(with: .move(edge: .top)))
}
Spacer()
}
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
.animation(.easeInOut(duration: 0.2), value: addToCalendar)
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Moment festhalten")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
.foregroundStyle(theme.contentSecondary)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Speichern") { save() }
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(isValid ? theme.accent : theme.contentTertiary)
.disabled(!isValid)
}
}
}
.onAppear { isFocused = true }
}
// MARK: - Calendar Section
private var calendarSection: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "calendar")
.font(.system(size: 14))
.foregroundStyle(addToCalendar ? theme.accent : theme.contentTertiary)
Text("Termin anlegen")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Toggle("", isOn: $addToCalendar)
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if addToCalendar {
RowDivider()
DatePicker(
"Wann?",
selection: $eventDate,
in: Date()...,
displayedComponents: [.date, .hourAndMinute]
)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 10)
RowDivider()
HStack {
Text("Dauer")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $eventDuration) {
Text("30 Min").tag(1800.0)
Text("1 Std").tag(3600.0)
Text("2 Std").tag(7200.0)
Text("Halbtag").tag(14400.0)
Text("Ganztag").tag(-1.0)
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// MARK: - Save
private func save() {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
let moment = Moment(text: trimmed, type: selectedType, person: person)
modelContext.insert(moment)
person.moments.append(moment)
guard addToCalendar else {
dismiss()
return
}
let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE")))
let calEntry = LogEntry(
type: .calendarEvent,
title: "Treffen mit \(person.firstName)\(dateStr)",
person: person
)
modelContext.insert(calEntry)
person.logEntries.append(calEntry)
// Kein async/await Callback-API vermeidet "unsafeForcedSync"
createCalendarEvent(notes: trimmed)
}
// MARK: - EventKit (callback-basiert, kein Swift Concurrency)
private func createCalendarEvent(notes: String) {
let store = EKEventStore()
let completion: (Bool, Error?) -> Void = { [store] granted, _ in
guard granted, let calendar = store.defaultCalendarForNewEvents else {
DispatchQueue.main.async { self.dismiss() }
return
}
let event = EKEvent(eventStore: store)
event.title = "Treffen mit \(self.person.firstName)"
event.notes = notes.isEmpty ? nil : notes
event.calendar = calendar
if self.eventDuration < 0 {
event.isAllDay = true
let dayStart = Calendar.current.startOfDay(for: self.eventDate)
event.startDate = dayStart
event.endDate = Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
} else {
event.startDate = self.eventDate
event.endDate = self.eventDate.addingTimeInterval(self.eventDuration)
}
try? store.save(event, span: .thisEvent)
DispatchQueue.main.async { self.dismiss() }
}
if #available(iOS 17.0, *) {
store.requestWriteOnlyAccessToEvents(completion: completion)
} else {
store.requestAccess(to: .event, completion: completion)
}
}
}
+426
View File
@@ -0,0 +1,426 @@
import SwiftUI
import SwiftData
import Contacts
import PhotosUI
struct AddPersonView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
var existingPerson: Person? = nil
@State private var name = ""
@State private var selectedTag: PersonTag = .other
@State private var occupation = ""
@State private var location = ""
@State private var interests = ""
@State private var generalNotes = ""
@State private var hasBirthday = false
@State private var birthday = Date()
@State private var nudgeFrequency: NudgeFrequency = .monthly
@State private var showingContactPicker = false
@State private var importedName: String? = nil // tracks whether fields were pre-filled
@State private var showingDeleteConfirmation = false
@State private var selectedPhoto: UIImage? = nil
@State private var photoPickerItem: PhotosPickerItem? = nil
private var isEditing: Bool { existingPerson != nil }
private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty }
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Photo picker
photoSection
// Contact import button (only when adding, not editing)
if !isEditing {
importButton
}
// Name
formSection("Name") {
TextField("Wie heißt diese Person?", text: $name)
.font(.system(size: 22, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Tag
formSection("Kontext") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(PersonTag.allCases, id: \.self) { tag in
Button {
selectedTag = tag
} label: {
Text(tag.rawValue)
.font(.system(size: 13, weight: selectedTag == tag ? .medium : .regular))
.foregroundStyle(selectedTag == tag ? theme.accent : theme.contentSecondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(selectedTag == tag ? theme.accent.opacity(0.12) : theme.surfaceCard)
.clipShape(Capsule())
}
}
}
}
}
// Optional fields
formSection("Optional") {
VStack(spacing: 0) {
inlineField("Beruf", text: $occupation)
RowDivider()
inlineField("Wohnort", text: $location)
RowDivider()
inlineField("Interessen", text: $interests)
RowDivider()
inlineField("Notizen", text: $generalNotes)
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Birthday
formSection("Geburtstag") {
VStack(spacing: 0) {
HStack {
Text("Geburtstag bekannt")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Toggle("", isOn: $hasBirthday.animation())
.labelsHidden()
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if hasBirthday {
RowDivider()
DatePicker(
"Datum",
selection: $birthday,
displayedComponents: .date
)
.datePickerStyle(.compact)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Nudge frequency
formSection("Wie oft erinnern?") {
VStack(spacing: 0) {
ForEach(NudgeFrequency.allCases, id: \.self) { freq in
Button {
nudgeFrequency = freq
} label: {
HStack {
Text(freq.rawValue)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
if nudgeFrequency == freq {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.accent)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
if freq != NudgeFrequency.allCases.last {
RowDivider()
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Delete link only in edit mode
if isEditing {
Button {
showingDeleteConfirmation = true
} label: {
Text("Löschen")
.font(.system(size: 15))
.foregroundStyle(.red)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle(isEditing ? (existingPerson?.name ?? "") : "Jemanden hinzufügen")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
.foregroundStyle(theme.contentSecondary)
}
ToolbarItem(placement: .topBarTrailing) {
Button(isEditing ? "Fertig" : "Hinzufügen") { save() }
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(isValid ? theme.accent : theme.contentTertiary)
.disabled(!isValid)
}
}
}
.sheet(isPresented: $showingContactPicker) {
ContactPickerView { contact in
applyContact(contact)
}
.ignoresSafeArea()
}
.confirmationDialog(
"Diese Person wirklich löschen?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) { deletePerson() }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht.")
}
.onAppear { loadExisting() }
}
// MARK: - Photo Section
private var photoSection: some View {
VStack(spacing: 10) {
PhotosPicker(selection: $photoPickerItem, matching: .images) {
ZStack(alignment: .bottomTrailing) {
Group {
if let photo = selectedPhoto {
Image(uiImage: photo)
.resizable()
.scaledToFill()
.frame(width: 88, height: 88)
.clipShape(Circle())
} else {
Circle()
.fill(theme.accent.opacity(0.10))
.frame(width: 88, height: 88)
.overlay(
Text(previewInitials)
.font(.system(size: 32, weight: .medium, design: theme.displayDesign))
.foregroundStyle(theme.accent)
)
}
}
Image(systemName: "camera.circle.fill")
.font(.system(size: 26))
.foregroundStyle(theme.accent)
.background(theme.backgroundPrimary, in: Circle())
}
}
.onChange(of: photoPickerItem) { _, item in
Task {
if let data = try? await item?.loadTransferable(type: Data.self),
let image = UIImage(data: data),
let resized = image.resizedForAvatar() {
selectedPhoto = resized
}
}
}
if selectedPhoto != nil {
Button {
selectedPhoto = nil
photoPickerItem = nil
} label: {
Text("Foto entfernen")
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
// Initials preview using current name input
private var previewInitials: String {
let parts = name.split(separator: " ")
if parts.count >= 2 {
return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased()
}
return String(name.prefix(2)).uppercased().isEmpty ? "?" : String(name.prefix(2)).uppercased()
}
// MARK: - Import Button
private var importButton: some View {
Button {
showingContactPicker = true
} label: {
HStack(spacing: 10) {
Image(systemName: "person.crop.circle")
.font(.system(size: 16))
.foregroundStyle(theme.accent)
VStack(alignment: .leading, spacing: 1) {
Text(importedName == nil ? "Aus Kontakten auswählen" : "Anderen Kontakt wählen")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.accent)
if let imported = importedName {
Text("Felder aus \"\(imported)\" übernommen")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
} else {
Text("Felder werden automatisch ausgefüllt")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
.background(theme.accent.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.accent.opacity(0.20), lineWidth: 1)
)
}
}
// MARK: - Contact Mapping
private func applyContact(_ contact: CNContact) {
let imported = ContactImport.from(contact)
if !imported.name.isEmpty {
name = imported.name
importedName = imported.name
}
if !imported.occupation.isEmpty {
occupation = imported.occupation
}
if !imported.location.isEmpty {
location = imported.location
}
if let bday = imported.birthday {
birthday = bday
hasBirthday = true
}
if let data = imported.photoData {
selectedPhoto = UIImage(data: data)
}
}
// MARK: - Helpers
@ViewBuilder
private func formSection<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(label.uppercased())
.font(.system(size: 11, weight: .semibold))
.tracking(0.8)
.foregroundStyle(theme.contentTertiary)
content()
}
}
@ViewBuilder
private func inlineField(_ label: String, text: Binding<String>) -> some View {
HStack(spacing: 12) {
Text(label)
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
.frame(width: 80, alignment: .leading)
TextField(label, text: text)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func loadExisting() {
guard let p = existingPerson else { return }
name = p.name
selectedTag = p.tag
occupation = p.occupation ?? ""
location = p.location ?? ""
interests = p.interests ?? ""
generalNotes = p.generalNotes ?? ""
hasBirthday = p.birthday != nil
birthday = p.birthday ?? Date()
nudgeFrequency = p.nudgeFrequency
if let data = p.photoData {
selectedPhoto = UIImage(data: data)
}
}
private func save() {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
let photoData = selectedPhoto?.jpegData(compressionQuality: 0.8)
if let p = existingPerson {
p.name = trimmed
p.tag = selectedTag
p.occupation = occupation.isEmpty ? nil : occupation
p.location = location.isEmpty ? nil : location
p.interests = interests.isEmpty ? nil : interests
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
p.birthday = hasBirthday ? birthday : nil
p.nudgeFrequency = nudgeFrequency
p.photoData = photoData
} else {
let person = Person(
name: trimmed,
tag: selectedTag,
birthday: hasBirthday ? birthday : nil,
occupation: occupation.isEmpty ? nil : occupation,
location: location.isEmpty ? nil : location,
interests: interests.isEmpty ? nil : interests,
generalNotes: generalNotes.isEmpty ? nil : generalNotes,
nudgeFrequency: nudgeFrequency
)
person.photoData = photoData
modelContext.insert(person)
}
dismiss()
}
private func deletePerson() {
guard let p = existingPerson else { return }
modelContext.delete(p)
dismiss()
}
}
+91
View File
@@ -0,0 +1,91 @@
import Foundation
import Combine
import LocalAuthentication
import Security
class AppLockManager: ObservableObject {
static let shared = AppLockManager()
@Published var isEnabled: Bool {
didSet { UserDefaults.standard.set(isEnabled, forKey: "appLockEnabled") }
}
@Published var isLocked = false
@Published var biometricType: LABiometryType = .none
var hasPIN: Bool { loadPIN() != nil }
private let keychainAccount = "nahbar.applock.pin"
private init() {
isEnabled = UserDefaults.standard.bool(forKey: "appLockEnabled")
refreshBiometricType()
}
func refreshBiometricType() {
let ctx = LAContext()
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
biometricType = ctx.biometryType
}
// MARK: - Lock control
func lockIfEnabled() {
guard isEnabled, hasPIN else { return }
isLocked = true
}
func unlock() {
isLocked = false
}
// MARK: - PIN verification
func verifyPIN(_ pin: String) -> Bool {
loadPIN() == pin
}
func authenticateWithBiometrics(completion: @escaping (Bool) -> Void) {
let ctx = LAContext()
var error: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
completion(false)
return
}
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "nahbar entsperren") { success, _ in
DispatchQueue.main.async { completion(success) }
}
}
// MARK: - Keychain
func setPIN(_ pin: String) {
deletePIN()
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainAccount,
kSecValueData as String: Data(pin.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemAdd(query as CFDictionary, nil)
}
func deletePIN() {
SecItemDelete([
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainAccount
] as CFDictionary)
}
private func loadPIN() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
+166
View File
@@ -0,0 +1,166 @@
import SwiftUI
struct AppLockSetupView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
@EnvironmentObject var lockManager: AppLockManager
/// true = user wants to disable or change PIN (must verify old PIN first if changing)
let isDisabling: Bool
@State private var step: Step = .first
@State private var firstPIN = ""
@State private var entered = ""
@State private var errorMessage = ""
@State private var showError = false
@State private var errorOffset: CGFloat = 0
private let pinLength = 6
enum Step { case first, confirm }
var body: some View {
ZStack {
theme.backgroundPrimary.ignoresSafeArea()
VStack(spacing: 0) {
// Close
HStack {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .light))
.foregroundStyle(theme.contentTertiary)
.frame(width: 36, height: 36)
.background(theme.surfaceCard)
.clipShape(Circle())
}
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 20)
Spacer()
VStack(spacing: 8) {
Text(title)
.font(.system(size: 24, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text(subtitle)
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Spacer().frame(height: 44)
// PIN dots
HStack(spacing: 20) {
ForEach(0..<pinLength, id: \.self) { i in
Circle()
.fill(i < entered.count ? theme.accent : Color.clear)
.frame(width: 14, height: 14)
.overlay(
Circle().stroke(
i < entered.count ? theme.accent : theme.contentTertiary,
lineWidth: 1.5
)
)
}
}
.offset(x: errorOffset)
Spacer().frame(height: 14)
Text(showError ? errorMessage : " ")
.font(.system(size: 13))
.foregroundStyle(.red.opacity(0.8))
.animation(.easeInOut(duration: 0.2), value: showError)
Spacer()
PINPadView { key in handleKey(key) }
.padding(.horizontal, 44)
Spacer().frame(height: 60)
}
}
}
// MARK: - Labels
private var title: String {
if isDisabling { return "Code eingeben" }
return step == .first ? "Code festlegen" : "Code bestätigen"
}
private var subtitle: String {
if isDisabling { return "Gib deinen Code ein, um den Schutz zu deaktivieren" }
return step == .first
? "Wähle einen 6-stelligen Code"
: "Gib den Code zur Bestätigung nochmal ein"
}
// MARK: - Input
private func handleKey(_ key: PINKey) {
switch key {
case .digit(let d):
guard entered.count < pinLength else { return }
entered.append(d)
if entered.count == pinLength { advance() }
case .delete:
if !entered.isEmpty { entered.removeLast() }
}
}
private func advance() {
if isDisabling {
if lockManager.verifyPIN(entered) {
lockManager.deletePIN()
lockManager.isEnabled = false
dismiss()
} else {
shake("Falscher Code")
}
return
}
switch step {
case .first:
firstPIN = entered
entered = ""
withAnimation(.easeInOut(duration: 0.2)) { step = .confirm }
case .confirm:
if entered == firstPIN {
lockManager.setPIN(entered)
lockManager.isEnabled = true
dismiss()
} else {
shake("Codes stimmen nicht überein")
// Reset to step 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
withAnimation(.easeInOut(duration: 0.2)) { step = .first }
firstPIN = ""
}
}
}
}
// MARK: - Shake feedback
private func shake(_ message: String) {
errorMessage = message
showError = true
entered = ""
let pattern: [(CGFloat, Double)] = [(10,0), (-10,0.07), (6,0.14), (-4,0.21), (0,0.28)]
for (offset, delay) in pattern {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
withAnimation(.easeInOut(duration: 0.07)) { errorOffset = offset }
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation { showError = false }
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import SwiftUI
import LocalAuthentication
// MARK: - Lock Screen
struct AppLockView: View {
@Environment(\.nahbarTheme) var theme
@EnvironmentObject var lockManager: AppLockManager
@State private var entered = ""
@State private var showError = false
@State private var errorOffset: CGFloat = 0
private let pinLength = 6
var body: some View {
ZStack {
theme.backgroundPrimary.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
// Title
VStack(spacing: 6) {
Text("nahbar")
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text("Code eingeben")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
}
Spacer().frame(height: 44)
// PIN dots
HStack(spacing: 20) {
ForEach(0..<pinLength, id: \.self) { i in
Circle()
.fill(i < entered.count ? theme.accent : Color.clear)
.frame(width: 14, height: 14)
.overlay(
Circle().stroke(
i < entered.count ? theme.accent : theme.contentTertiary,
lineWidth: 1.5
)
)
}
}
.offset(x: errorOffset)
Spacer().frame(height: 14)
// Error message fixed height to avoid layout shift
Text(showError ? "Falscher Code" : " ")
.font(.system(size: 13))
.foregroundStyle(.red.opacity(0.8))
.animation(.easeInOut(duration: 0.2), value: showError)
Spacer()
// Number pad
PINPadView { key in handleKey(key) }
.padding(.horizontal, 44)
Spacer().frame(height: 24)
// Biometric button
if lockManager.biometricType != .none {
Button { tryBiometric() } label: {
Image(systemName: lockManager.biometricType == .faceID ? "faceid" : "touchid")
.font(.system(size: 34, weight: .light))
.foregroundStyle(theme.accent)
}
.padding(.bottom, 44)
} else {
Spacer().frame(height: 44)
}
}
}
.onAppear { tryBiometric() }
}
private func handleKey(_ key: PINKey) {
switch key {
case .digit(let d):
guard entered.count < pinLength else { return }
entered.append(d)
if entered.count == pinLength { verify() }
case .delete:
if !entered.isEmpty { entered.removeLast() }
}
}
private func verify() {
if lockManager.verifyPIN(entered) {
lockManager.unlock()
} else {
entered = ""
animateShake()
showError = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { showError = false }
}
}
}
private func tryBiometric() {
lockManager.authenticateWithBiometrics { success in
if success { lockManager.unlock() }
}
}
private func animateShake() {
let pattern: [(CGFloat, Double)] = [(10,0), (-10,0.07), (6,0.14), (-4,0.21), (0,0.28)]
for (offset, delay) in pattern {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
withAnimation(.easeInOut(duration: 0.07)) { errorOffset = offset }
}
}
}
}
// MARK: - PIN Key Model
enum PINKey {
case digit(Character)
case delete
}
// MARK: - PIN Pad
struct PINPadView: View {
@Environment(\.nahbarTheme) var theme
let onKey: (PINKey) -> Void
private let rows: [[String]] = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
["", "0", ""]
]
var body: some View {
VStack(spacing: 14) {
ForEach(rows, id: \.self) { row in
HStack(spacing: 18) {
ForEach(row, id: \.self) { key in
pinButton(for: key)
}
}
}
}
}
@ViewBuilder
private func pinButton(for key: String) -> some View {
if key.isEmpty {
Color.clear.frame(width: 80, height: 80)
} else if key == "" {
Button { onKey(.delete) } label: {
Image(systemName: "delete.left")
.font(.system(size: 20, weight: .light))
.foregroundStyle(theme.contentPrimary)
.frame(width: 80, height: 80)
}
} else {
Button { onKey(.digit(key.first!)) } label: {
Text(key)
.font(.system(size: 28, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.frame(width: 80, height: 80)
.background(theme.surfaceCard)
.clipShape(Circle())
.overlay(Circle().stroke(theme.borderSubtle, lineWidth: 1))
}
}
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+104
View File
@@ -0,0 +1,104 @@
import SwiftUI
struct CallSuggestionView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
@Bindable var person: Person
let onConfirm: () -> Void
var body: some View {
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.3))
.frame(width: 36, height: 4)
.padding(.top, 12)
VStack(spacing: 24) {
// Person
VStack(spacing: 10) {
PersonAvatar(person: person, size: 72)
VStack(spacing: 4) {
Text("Wie geht es \(person.firstName)?")
.font(.system(size: 22, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
TagBadge(text: person.tag.rawValue)
}
}
// Gesprächseinstieg
if let context = callContext {
VStack(alignment: .leading, spacing: 6) {
Text("Gesprächseinstieg")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(theme.contentTertiary)
.textCase(.uppercase)
.tracking(0.5)
Text(context)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Offener nächster Schritt
if let step = person.nextStep, !person.nextStepCompleted {
HStack(spacing: 8) {
Image(systemName: "arrow.right.circle")
.font(.system(size: 13))
Text(step)
.font(.system(size: 14))
}
.foregroundStyle(theme.accent.opacity(0.85))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, -8)
}
// Buttons
VStack(spacing: 10) {
Button {
onConfirm()
dismiss()
} label: {
Text("Los geht's")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusTag))
}
Button { dismiss() } label: {
Text("Nicht jetzt")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
}
}
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary)
.presentationDetents([.medium])
.presentationDragIndicator(.hidden)
}
// Letzten Moment-Text oder Interessen als Einstieg
private var callContext: String? {
if let last = person.sortedMoments.first {
return last.text
}
if let interests = person.interests, !interests.isEmpty {
return interests
}
return nil
}
}
+137
View File
@@ -0,0 +1,137 @@
import Foundation
import Combine
import UserNotifications
class CallWindowManager: ObservableObject {
static let shared = CallWindowManager()
@Published var isEnabled: Bool { didSet { ud.set(isEnabled, forKey: K.enabled) } }
@Published var startHour: Int { didSet { ud.set(startHour, forKey: K.startHour) } }
@Published var startMinute: Int { didSet { ud.set(startMinute, forKey: K.startMinute) } }
@Published var endHour: Int { didSet { ud.set(endHour, forKey: K.endHour) } }
@Published var endMinute: Int { didSet { ud.set(endMinute, forKey: K.endMinute) } }
@Published var selectedWeekdays: Set<Int> {
didSet {
let data = (try? JSONEncoder().encode(Array(selectedWeekdays))) ?? Data()
ud.set(data, forKey: K.weekdays)
}
}
private let ud = UserDefaults.standard
private enum K {
static let enabled = "cwEnabled"
static let startHour = "cwStartHour"
static let startMinute = "cwStartMinute"
static let endHour = "cwEndHour"
static let endMinute = "cwEndMinute"
static let weekdays = "cwWeekdays"
}
// Calendar weekdays in German display order (Calendar: 1=So, 2=Mo, ..., 7=Sa)
static let weekdayOrder: [(Int, String)] = [
(2, "Mo"), (3, "Di"), (4, "Mi"), (5, "Do"), (6, "Fr"), (7, "Sa"), (1, "So")
]
private init() {
isEnabled = ud.bool(forKey: K.enabled)
startHour = ud.object(forKey: K.startHour) as? Int ?? 17
startMinute = ud.object(forKey: K.startMinute) as? Int ?? 0
endHour = ud.object(forKey: K.endHour) as? Int ?? 18
endMinute = ud.object(forKey: K.endMinute) as? Int ?? 0
if let data = ud.data(forKey: K.weekdays),
let days = try? JSONDecoder().decode([Int].self, from: data) {
selectedWeekdays = Set(days)
} else {
selectedWeekdays = [2, 3, 4, 5, 6] // MoFr
}
}
// MARK: - Window check
var isCurrentlyInWindow: Bool {
guard isEnabled else { return false }
let cal = Calendar.current
let now = Date()
let weekday = cal.component(.weekday, from: now)
guard selectedWeekdays.contains(weekday) else { return false }
let nowMin = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
let startMin = startHour * 60 + startMinute
let endMin = endHour * 60 + endMinute
return nowMin >= startMin && nowMin < endMin
}
// MARK: - Person selection
func selectPerson(from persons: [Person]) -> Person? {
guard !persons.isEmpty else { return nil }
let cal = Calendar.current
let candidates = persons.filter { p in
// Nicht heute schon vorgeschlagen
if let last = p.lastSuggestedForCall, cal.isDateInToday(last) { return false }
// Mindestens 7 Tage Pause
if let last = p.lastSuggestedForCall {
let days = cal.dateComponents([.day], from: last, to: Date()).day ?? 0
if days < 7 { return false }
}
// Nicht heute schon kontaktiert
if let lastMoment = p.lastMomentDate, cal.isDateInToday(lastMoment) { return false }
return true
}
let pool = candidates.isEmpty ? persons : candidates
// Überfällige Kontakte bevorzugen
let prioritized = pool.filter { $0.needsAttention }
let ranked = (prioritized.isEmpty ? pool : prioritized).sorted {
($0.lastMomentDate ?? $0.createdAt) < ($1.lastMomentDate ?? $1.createdAt)
}
// Zufällig aus den Top 3
return Array(ranked.prefix(3)).randomElement()
}
// MARK: - Notifications
func scheduleNotifications() {
cancelNotifications()
guard isEnabled else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
for weekday in self.selectedWeekdays {
let content = UNMutableNotificationContent()
content.title = "Gesprächszeit"
content.body = "Wer freut sich heute von dir zu hören?"
content.sound = .default
content.categoryIdentifier = "CALL_WINDOW"
var dc = DateComponents()
dc.weekday = weekday
dc.hour = self.startHour
dc.minute = self.startMinute
let request = UNNotificationRequest(
identifier: "callwindow-\(weekday)",
content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)
)
UNUserNotificationCenter.current().add(request)
}
}
}
func cancelNotifications() {
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: (1...7).map { "callwindow-\($0)" }
)
}
// MARK: - Display
var windowDescription: String {
String(format: "%02d:%02d %02d:%02d Uhr", startHour, startMinute, endHour, endMinute)
}
}
+162
View File
@@ -0,0 +1,162 @@
import SwiftUI
struct CallWindowSetupView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
@ObservedObject var manager: CallWindowManager
let isOnboarding: Bool
let onDone: () -> Void
@State private var startTime: Date = Date()
@State private var endTime: Date = Date()
var body: some View {
VStack(spacing: 0) {
if isOnboarding {
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.3))
.frame(width: 36, height: 4)
.padding(.top, 12)
}
ScrollView {
VStack(alignment: .leading, spacing: 28) {
// Header
VStack(alignment: .leading, spacing: 8) {
Image(systemName: "phone.arrow.up.right")
.font(.system(size: 28, weight: .light))
.foregroundStyle(theme.accent)
Text("Gesprächszeit")
.font(.system(size: 26, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
.fixedSize(horizontal: false, vertical: true)
}
// Time window
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Zeitfenster", icon: "clock")
VStack(spacing: 0) {
HStack {
Text("Von")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
DatePicker("", selection: $startTime, displayedComponents: .hourAndMinute)
.labelsHidden()
.tint(theme.accent)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
RowDivider()
HStack {
Text("Bis")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
DatePicker("", selection: $endTime, displayedComponents: .hourAndMinute)
.labelsHidden()
.tint(theme.accent)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Weekday selector
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Wochentage", icon: "calendar")
HStack(spacing: 6) {
ForEach(CallWindowManager.weekdayOrder, id: \.0) { weekday, label in
let isSelected = manager.selectedWeekdays.contains(weekday)
Button {
if isSelected {
// Mindestens einen Tag behalten
if manager.selectedWeekdays.count > 1 {
manager.selectedWeekdays.remove(weekday)
}
} else {
manager.selectedWeekdays.insert(weekday)
}
} label: {
Text(label)
.font(.system(size: 12, weight: isSelected ? .semibold : .regular))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(isSelected ? theme.accent : theme.surfaceCard)
.foregroundStyle(isSelected ? .white : theme.contentSecondary)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusTag))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusTag)
.stroke(theme.borderSubtle, lineWidth: isSelected ? 0 : 1)
)
}
}
}
}
// Actions
VStack(spacing: 10) {
Button {
saveAndSchedule()
onDone()
} label: {
Text(isOnboarding ? "Einrichten" : "Speichern")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusTag))
}
if isOnboarding {
Button { onDone() } label: {
Text("Vielleicht später")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
.frame(maxWidth: .infinity)
}
}
}
}
.padding(.horizontal, 24)
.padding(.top, isOnboarding ? 20 : 16)
.padding(.bottom, 40)
}
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle(isOnboarding ? "" : "Gesprächszeit")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.onAppear {
startTime = timeDate(hour: manager.startHour, minute: manager.startMinute)
endTime = timeDate(hour: manager.endHour, minute: manager.endMinute)
}
}
private func timeDate(hour: Int, minute: Int) -> Date {
Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date()
}
private func saveAndSchedule() {
let cal = Calendar.current
manager.startHour = cal.component(.hour, from: startTime)
manager.startMinute = cal.component(.minute, from: startTime)
manager.endHour = cal.component(.hour, from: endTime)
manager.endMinute = cal.component(.minute, from: endTime)
manager.isEnabled = true
manager.scheduleNotifications()
}
}
+112
View File
@@ -0,0 +1,112 @@
import SwiftUI
import ContactsUI
import Contacts
// MARK: - UIViewControllerRepresentable wrapper
/// Wraps CNContactPickerViewController no NSContactsUsageDescription needed,
/// the system picker runs in its own process and manages its own access.
struct ContactPickerView: UIViewControllerRepresentable {
let onSelect: (CNContact) -> Void
func makeUIViewController(context: Context) -> CNContactPickerViewController {
let picker = CNContactPickerViewController()
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(onSelect: onSelect) }
final class Coordinator: NSObject, CNContactPickerDelegate {
let onSelect: (CNContact) -> Void
init(onSelect: @escaping (CNContact) -> Void) { self.onSelect = onSelect }
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
onSelect(contact)
}
}
}
// MARK: - Mapping helper
struct ContactImport {
let name: String
let occupation: String
let location: String
let birthday: Date?
let photoData: Data?
/// Maps a CNContact to the fields used by AddPersonView.
/// All fields are best-effort; missing data yields empty strings / nil.
static func from(_ contact: CNContact) -> ContactImport {
// Full name
let parts = [contact.givenName, contact.familyName].filter { !$0.isEmpty }
let name = parts.joined(separator: " ")
// Occupation: prefer job title, fall back to org name
let occupation: String
if !contact.jobTitle.isEmpty {
occupation = contact.jobTitle
} else if !contact.organizationName.isEmpty {
occupation = contact.organizationName
} else {
occupation = ""
}
// Location: city (+ country if different from obvious)
let location: String
if let postal = contact.postalAddresses.first?.value {
let city = postal.city
let country = postal.country
location = [city, country].filter { !$0.isEmpty }.joined(separator: ", ")
} else {
location = ""
}
// Birthday
var birthdayDate: Date? = nil
if let components = contact.birthday {
// Some contacts store only month+day (year == nil or year == 1)
var resolved = components
if resolved.year == nil || resolved.year == 1 {
resolved.year = Calendar.current.component(.year, from: Date())
}
birthdayDate = Calendar.current.date(from: resolved)
}
// Photo: prefer thumbnail (smaller), fall back to full image resized
let photoData: Data?
if let thumbnail = contact.thumbnailImageData {
photoData = thumbnail
} else if let fullData = contact.imageData,
let resized = UIImage(data: fullData)?.resizedForAvatar() {
photoData = resized.jpegData(compressionQuality: 0.8)
} else {
photoData = nil
}
return ContactImport(
name: name,
occupation: occupation,
location: location,
birthday: birthdayDate,
photoData: photoData
)
}
}
// MARK: - UIImage helper
extension UIImage {
/// Downscales the image so the longer side is at most `maxSide` points.
func resizedForAvatar(maxSide: CGFloat = 400) -> UIImage? {
let scale = min(maxSide / size.width, maxSide / size.height)
guard scale < 1 else { return self }
let newSize = CGSize(width: (size.width * scale).rounded(), height: (size.height * scale).rounded())
return UIGraphicsImageRenderer(size: newSize).image { _ in
draw(in: CGRect(origin: .zero, size: newSize))
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
import SwiftData
struct ContentView: View {
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false
@AppStorage("callSuggestionDate") private var suggestionDateStr = ""
@EnvironmentObject private var callWindowManager: CallWindowManager
@EnvironmentObject private var appLockManager: AppLockManager
@Environment(\.scenePhase) private var scenePhase
@Environment(\.modelContext) private var modelContext
@Environment(\.nahbarTheme) private var theme
@Query private var persons: [Person]
@State private var showingOnboarding = false
@State private var suggestedPerson: Person? = nil
@State private var showingSuggestion = false
var body: some View {
TabView {
TodayView()
.tabItem { Label("Heute", systemImage: "sun.max") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
PeopleListView()
.tabItem { Label("Menschen", systemImage: "person.2") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
SettingsView()
.tabItem { Label("Einstellungen", systemImage: "gearshape") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
}
.sheet(isPresented: $showingOnboarding) {
CallWindowSetupView(
manager: callWindowManager,
isOnboarding: true,
onDone: {
showingOnboarding = false
onboardingDone = true
}
)
.presentationDetents([.large])
}
.sheet(isPresented: $showingSuggestion) {
if let person = suggestedPerson {
CallSuggestionView(person: person) {
person.lastSuggestedForCall = Date()
suggestionDateStr = ISO8601DateFormatter().string(from: Date())
let entry = LogEntry(type: .call, title: "Anruf mit \(person.firstName)", person: person)
modelContext.insert(entry)
person.logEntries.append(entry)
}
}
}
.onAppear {
if !onboardingDone {
showingOnboarding = true
} else {
checkCallWindow()
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { checkCallWindow() }
}
}
private func checkCallWindow() {
guard callWindowManager.isEnabled,
callWindowManager.isCurrentlyInWindow,
!suggestionShownToday,
!showingSuggestion,
!appLockManager.isLocked else { return }
if let person = callWindowManager.selectPerson(from: persons) {
suggestedPerson = person
showingSuggestion = true
}
}
private var suggestionShownToday: Bool {
guard !suggestionDateStr.isEmpty,
let date = ISO8601DateFormatter().date(from: suggestionDateStr) else { return false }
return Calendar.current.isDateInToday(date)
}
}
#Preview {
ContentView()
.modelContainer(for: [Person.self, Moment.self], inMemory: true)
.environmentObject(CallWindowManager.shared)
.environmentObject(AppLockManager.shared)
}
+244
View File
@@ -0,0 +1,244 @@
import SwiftUI
// MARK: - Timeline Item
private enum LogbuchItem: Identifiable {
case moment(Moment)
case logEntry(LogEntry)
var id: String {
switch self {
case .moment(let m): return "m-\(m.id)"
case .logEntry(let e): return "e-\(e.id)"
}
}
var date: Date {
switch self {
case .moment(let m): return m.createdAt
case .logEntry(let e): return e.loggedAt
}
}
var icon: String {
switch self {
case .moment(let m): return m.type.icon
case .logEntry(let e): return e.type.icon
}
}
var label: String {
switch self {
case .moment(let m): return m.type.rawValue
case .logEntry(let e): return e.type.rawValue
}
}
var title: String {
switch self {
case .moment(let m): return m.text
case .logEntry(let e): return e.title
}
}
var isLogEntry: Bool {
if case .logEntry = self { return true }
return false
}
}
// MARK: - Logbuch View
struct LogbuchView: View {
@Environment(\.nahbarTheme) var theme
let person: Person
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
// Timeline
if mergedItems.isEmpty {
emptyState
} else {
ForEach(groupedItems, id: \.0) { month, items in
monthSection(month: month, items: items)
}
}
// PRO: KI-Analyse
aiAnalysisCard
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 48)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Logbuch")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
}
// MARK: - Month Section
private func monthSection(month: String, items: [LogbuchItem]) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(month.uppercased())
.font(.system(size: 11, weight: .semibold))
.tracking(0.8)
.foregroundStyle(theme.contentTertiary)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
logbuchRow(item: item)
if index < items.count - 1 {
RowDivider()
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
// MARK: - Row
private func logbuchRow(item: LogbuchItem) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: item.icon)
.font(.system(size: 14, weight: .light))
.foregroundStyle(item.isLogEntry ? theme.accent : theme.contentTertiary)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 3) {
Text(item.title)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
Text(item.label)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text("·")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text(item.date.formatted(.dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "book.closed")
.font(.system(size: 32, weight: .light))
.foregroundStyle(theme.contentTertiary)
Text("Noch keine Einträge")
.font(.system(size: 16, weight: .light))
.foregroundStyle(theme.contentSecondary)
Text("Momente und abgeschlossene Schritte erscheinen hier.")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
// MARK: - PRO: KI-Analyse
private var aiAnalysisCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
Text("PRO")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(theme.accent.opacity(0.10))
.clipShape(Capsule())
}
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Muster & Themen")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.contentPrimary)
Text("Welche Themen kehren in Gesprächen immer wieder? Was bewegt \(person.firstName)?")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) {
Text("Beziehungsqualität")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.contentPrimary)
Text("Wie hat sich eure Beziehung über die Zeit entwickelt? Wann wart ihr zuletzt in Kontakt?")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Label("Bald verfügbar", systemImage: "lock")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.contentTertiary)
Spacer()
}
.padding(.top, 4)
}
.padding(16)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.borderSubtle, lineWidth: 1)
)
.opacity(0.7)
}
}
// MARK: - Data
private var mergedItems: [LogbuchItem] {
let moments = person.sortedMoments.map { LogbuchItem.moment($0) }
let entries = person.sortedLogEntries.map { LogbuchItem.logEntry($0) }
return (moments + entries).sorted { $0.date > $1.date }
}
private var groupedItems: [(String, [LogbuchItem])] {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
formatter.locale = Locale(identifier: "de_DE")
var result: [(String, [LogbuchItem])] = []
var currentKey = ""
var currentGroup: [LogbuchItem] = []
for item in mergedItems {
let key = formatter.string(from: item.date)
if key != currentKey {
if !currentGroup.isEmpty { result.append((currentKey, currentGroup)) }
currentKey = key
currentGroup = [item]
} else {
currentGroup.append(item)
}
}
if !currentGroup.isEmpty { result.append((currentKey, currentGroup)) }
return result
}
}
+237
View File
@@ -0,0 +1,237 @@
import SwiftUI
import SwiftData
// MARK: - Enums
enum PersonTag: String, CaseIterable, Codable {
case family = "Familie"
case friends = "Freunde"
case work = "Arbeit"
case community = "Community"
case other = "Andere"
var icon: String {
switch self {
case .family: return "house"
case .friends: return "person.2"
case .work: return "briefcase"
case .community: return "person.3"
case .other: return "tag"
}
}
}
enum NudgeFrequency: String, CaseIterable, Codable {
case never = "Nie"
case weekly = "Wöchentlich"
case biweekly = "2 Wochen"
case monthly = "Monatlich"
case quarterly = "Quartalsweise"
var days: Int? {
switch self {
case .never: return nil
case .weekly: return 7
case .biweekly: return 14
case .monthly: return 30
case .quarterly: return 90
}
}
}
enum MomentType: String, CaseIterable, Codable {
case conversation = "Gespräch"
case meeting = "Treffen"
case thought = "Gedanke"
case intention = "Vorhaben"
var icon: String {
switch self {
case .conversation: return "bubble.left"
case .meeting: return "person.2"
case .thought: return "lightbulb"
case .intention: return "arrow.right.circle"
}
}
}
// MARK: - Person
@Model
class Person {
var id: UUID
var name: String
var tagRaw: String
var birthday: Date?
var occupation: String?
var location: String?
var interests: String?
var generalNotes: String?
var nudgeFrequencyRaw: String
var photoData: Data?
var nextStep: String?
var nextStepCompleted: Bool
var nextStepReminderDate: Date?
var lastSuggestedForCall: Date?
var createdAt: Date
@Relationship(deleteRule: .cascade) var moments: [Moment]
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]
init(
name: String,
tag: PersonTag = .other,
birthday: Date? = nil,
occupation: String? = nil,
location: String? = nil,
interests: String? = nil,
generalNotes: String? = nil,
nudgeFrequency: NudgeFrequency = .monthly
) {
self.id = UUID()
self.name = name
self.tagRaw = tag.rawValue
self.birthday = birthday
self.occupation = occupation
self.location = location
self.interests = interests
self.generalNotes = generalNotes
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
self.photoData = nil
self.nextStep = nil
self.nextStepCompleted = false
self.nextStepReminderDate = nil
self.lastSuggestedForCall = nil
self.createdAt = Date()
self.moments = []
self.logEntries = []
}
var tag: PersonTag {
get { PersonTag(rawValue: tagRaw) ?? .other }
set { tagRaw = newValue.rawValue }
}
var nudgeFrequency: NudgeFrequency {
get { NudgeFrequency(rawValue: nudgeFrequencyRaw) ?? .monthly }
set { nudgeFrequencyRaw = newValue.rawValue }
}
var lastMomentDate: Date? {
moments.sorted { $0.createdAt > $1.createdAt }.first?.createdAt
}
var needsAttention: Bool {
guard let days = nudgeFrequency.days else { return false }
if let last = lastMomentDate {
return Date().timeIntervalSince(last) > Double(days * 86400)
}
// Never had a moment show if added more than `days` ago
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
}
func hasBirthdayWithin(days: Int) -> Bool {
guard let birthday else { return false }
let cal = Calendar.current
let bdc = cal.dateComponents([.month, .day], from: birthday)
guard let bMonth = bdc.month, let bDay = bdc.day else { return false }
for offset in 0..<days {
if let date = cal.date(byAdding: .day, value: offset, to: Date()) {
let dc = cal.dateComponents([.month, .day], from: date)
if dc.month == bMonth && dc.day == bDay { return true }
}
}
return false
}
var initials: String {
let parts = name.split(separator: " ")
if parts.count >= 2 {
return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased()
}
return String(name.prefix(2)).uppercased()
}
var firstName: String {
String(name.split(separator: " ").first ?? Substring(name))
}
var sortedMoments: [Moment] {
moments.sorted { $0.createdAt > $1.createdAt }
}
var sortedLogEntries: [LogEntry] {
logEntries.sorted { $0.loggedAt > $1.loggedAt }
}
}
// MARK: - LogEntryType
enum LogEntryType: String, Codable {
case nextStep = "Schritt abgeschlossen"
case calendarEvent = "Termin geplant"
case call = "Anruf"
var icon: String {
switch self {
case .nextStep: return "checkmark.circle.fill"
case .calendarEvent: return "calendar.badge.checkmark"
case .call: return "phone.circle.fill"
}
}
var color: String { // used for tinting in the view
switch self {
case .nextStep: return "green"
case .calendarEvent: return "blue"
case .call: return "accent"
}
}
}
// MARK: - LogEntry
@Model
class LogEntry {
var id: UUID
var typeRaw: String
var title: String
var loggedAt: Date
var person: Person?
init(type: LogEntryType, title: String, person: Person? = nil) {
self.id = UUID()
self.typeRaw = type.rawValue
self.title = title
self.loggedAt = Date()
self.person = person
}
var type: LogEntryType {
get { LogEntryType(rawValue: typeRaw) ?? .nextStep }
set { typeRaw = newValue.rawValue }
}
}
// MARK: - Moment
@Model
class Moment {
var id: UUID
var text: String
var typeRaw: String
var createdAt: Date
var person: Person?
init(text: String, type: MomentType = .conversation, person: Person? = nil) {
self.id = UUID()
self.text = text
self.typeRaw = type.rawValue
self.createdAt = Date()
self.person = person
}
var type: MomentType {
get { MomentType(rawValue: typeRaw) ?? .conversation }
set { typeRaw = newValue.rawValue }
}
}
+83
View File
@@ -0,0 +1,83 @@
import SwiftUI
import SwiftData
@main
struct NahbarApp: App {
@StateObject private var callWindowManager = CallWindowManager.shared
@StateObject private var appLockManager = AppLockManager.shared
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
@Environment(\.scenePhase) private var scenePhase
private var activeTheme: NahbarTheme {
NahbarTheme.theme(for: ThemeID(rawValue: activeThemeIDRaw) ?? .linen)
}
var body: some Scene {
WindowGroup {
ZStack {
ContentView()
.environmentObject(callWindowManager)
.environmentObject(appLockManager)
if appLockManager.isLocked {
AppLockView()
.environmentObject(appLockManager)
.transition(.opacity)
.zIndex(1)
}
}
.animation(.easeInOut(duration: 0.25), value: appLockManager.isLocked)
.environment(\.nahbarTheme, activeTheme)
.tint(activeTheme.accent)
.onAppear { applyTabBarAppearance(activeTheme) }
.onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) }
}
.modelContainer(for: [Person.self, Moment.self, LogEntry.self])
.onChange(of: scenePhase) { _, phase in
if phase == .background {
appLockManager.lockIfEnabled()
}
}
}
private func applyTabBarAppearance(_ theme: NahbarTheme) {
let bg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.88)
let normal = UIColor(theme.contentTertiary)
let selected = UIColor(theme.accent)
let border = UIColor(theme.borderSubtle).withAlphaComponent(0.6)
// Tab bar
let item = UITabBarItemAppearance()
item.normal.iconColor = normal
item.normal.titleTextAttributes = [.foregroundColor: normal]
item.selected.iconColor = selected
item.selected.titleTextAttributes = [.foregroundColor: selected]
let tabAppearance = UITabBarAppearance()
tabAppearance.configureWithTransparentBackground()
tabAppearance.backgroundColor = bg
tabAppearance.shadowColor = border
tabAppearance.stackedLayoutAppearance = item
tabAppearance.inlineLayoutAppearance = item
tabAppearance.compactInlineLayoutAppearance = item
UITabBar.appearance().standardAppearance = tabAppearance
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
// Navigation bar
let navBg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.92)
let titleColor = UIColor(theme.contentPrimary)
let navAppearance = UINavigationBarAppearance()
navAppearance.configureWithTransparentBackground()
navAppearance.backgroundColor = navBg
navAppearance.shadowColor = border
navAppearance.titleTextAttributes = [.foregroundColor: titleColor]
navAppearance.largeTitleTextAttributes = [.foregroundColor: titleColor]
UINavigationBar.appearance().standardAppearance = navAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
UINavigationBar.appearance().compactAppearance = navAppearance
UINavigationBar.appearance().tintColor = selected
}
}
+204
View File
@@ -0,0 +1,204 @@
import SwiftUI
import SwiftData
struct PeopleListView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext
@Query(sort: \Person.name) private var people: [Person]
@State private var searchText = ""
@State private var selectedTag: PersonTag? = nil
@State private var showingAddPerson = false
private var filteredPeople: [Person] {
var result = people
if let tag = selectedTag {
result = result.filter { $0.tag == tag }
}
if !searchText.isEmpty {
result = result.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
return result
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Custom header
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
Text("Menschen")
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Spacer()
Button {
showingAddPerson = true
} label: {
Image(systemName: "plus")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(theme.accent)
.frame(width: 36, height: 36)
.background(theme.accent.opacity(0.10))
.clipShape(Circle())
}
.accessibilityLabel("Person hinzufügen")
}
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
TextField("Suchen…", text: $searchText)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(theme.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
// Tag filter chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "Alle", isSelected: selectedTag == nil) {
selectedTag = nil
}
ForEach(PersonTag.allCases, id: \.self) { tag in
FilterChip(label: tag.rawValue, isSelected: selectedTag == tag) {
selectedTag = (selectedTag == tag) ? nil : tag
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 14)
.background(theme.backgroundPrimary)
// Content
if filteredPeople.isEmpty {
Spacer()
emptyState
Spacer()
} else {
ScrollView {
VStack(spacing: 0) {
ForEach(Array(filteredPeople.enumerated()), id: \.element.id) { index, person in
NavigationLink(destination: PersonDetailView(person: person)) {
PersonRowView(person: person)
}
.buttonStyle(.plain)
if index < filteredPeople.count - 1 {
RowDivider()
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.padding(.top, 4)
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary)
}
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationBarHidden(true)
}
.sheet(isPresented: $showingAddPerson) {
AddPersonView()
}
}
private var emptyState: some View {
VStack(spacing: 10) {
Text(searchText.isEmpty ? "Noch keine Menschen hier." : "Keine Treffer.")
.font(.system(size: 18, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
if searchText.isEmpty {
Text("Tippe auf + um jemanden hinzuzufügen.")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
}
}
}
}
// MARK: - Filter Chip
struct FilterChip: View {
@Environment(\.nahbarTheme) var theme
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? theme.accent : theme.contentSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? theme.accent.opacity(0.12) : theme.backgroundSecondary)
.clipShape(Capsule())
}
}
}
// MARK: - Person Row
struct PersonRowView: View {
@Environment(\.nahbarTheme) var theme
let person: Person
var body: some View {
HStack(spacing: 12) {
PersonAvatar(person: person, size: 44)
VStack(alignment: .leading, spacing: 3) {
Text(person.name)
.font(.system(size: 16, weight: .medium, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
HStack(spacing: 5) {
Text(person.tag.rawValue)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
if let last = person.lastMomentDate {
Text("·")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text(last, style: .relative)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
}
Spacer()
if person.needsAttention {
Circle()
.fill(theme.accent.opacity(0.55))
.frame(width: 7, height: 7)
}
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
+463
View File
@@ -0,0 +1,463 @@
import SwiftUI
import SwiftData
import UserNotifications
struct PersonDetailView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext
@Bindable var person: Person
@State private var showingAddMoment = false
@State private var showingEditPerson = false
@State private var nextStepText = ""
@State private var isEditingNextStep = false
@State private var showingReminderSheet = false
@State private var reminderDate: Date = {
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
return Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
}()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
personHeader
nextStepSection
momentsSection
if hasInfoContent { infoSection }
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 48)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Bearbeiten") { showingEditPerson = true }
.font(.system(size: 15))
.foregroundStyle(theme.accent)
}
}
.sheet(isPresented: $showingAddMoment) {
AddMomentView(person: person)
}
.sheet(isPresented: $showingEditPerson) {
AddPersonView(existingPerson: person)
}
.sheet(isPresented: $showingReminderSheet) {
NextStepReminderSheet(person: person, reminderDate: $reminderDate)
}
.onAppear {
nextStepText = person.nextStep ?? ""
}
}
// MARK: - Header
private var personHeader: some View {
HStack(spacing: 16) {
PersonAvatar(person: person, size: 64)
VStack(alignment: .leading, spacing: 5) {
Text(person.name)
.font(.system(size: 26, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
TagBadge(text: person.tag.rawValue)
if let occ = person.occupation, !occ.isEmpty {
Text(occ)
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
}
}
Spacer()
}
}
// MARK: - Next Step
private var nextStepSection: some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: "Nächster Schritt", icon: "arrow.right.circle")
if isEditingNextStep {
nextStepEditor
} else if let step = person.nextStep, !person.nextStepCompleted {
nextStepDisplay(step: step)
} else {
addNextStepButton
}
}
}
private var addNextStepButton: some View {
Button {
nextStepText = ""
person.nextStep = nil
person.nextStepCompleted = false
isEditingNextStep = true
} label: {
HStack(spacing: 8) {
Image(systemName: "plus")
.font(.system(size: 13))
Text("Schritt definieren")
.font(.system(size: 15))
}
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.borderSubtle, lineWidth: 1)
)
}
}
private var nextStepEditor: some View {
HStack(alignment: .top, spacing: 10) {
TextField("Was als Nächstes?", text: $nextStepText, axis: .vertical)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
.lineLimit(1...4)
Button {
let trimmed = nextStepText.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
person.nextStep = trimmed
person.nextStepCompleted = false
cancelReminder(for: person)
person.nextStepReminderDate = nil
isEditingNextStep = false
// Erinnerungsdatum auf morgen 9 Uhr zurücksetzen
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
reminderDate = Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
showingReminderSheet = true
} else {
isEditingNextStep = false
}
} label: {
Text("Ok")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(theme.accent)
}
.padding(.top, 2)
}
.padding(14)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.accent.opacity(0.25), lineWidth: 1)
)
}
private func nextStepDisplay(step: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
if let step = person.nextStep {
let entry = LogEntry(type: .nextStep, title: step, person: person)
modelContext.insert(entry)
person.logEntries.append(entry)
}
person.nextStepCompleted = true
cancelReminder(for: person)
person.nextStepReminderDate = nil
}
} label: {
Image(systemName: "circle")
.font(.system(size: 22))
.foregroundStyle(theme.contentTertiary)
}
VStack(alignment: .leading, spacing: 4) {
Text(step)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
if let reminder = person.nextStepReminderDate {
Label(reminder.formatted(.dateTime.day().month().hour().minute().locale(Locale(identifier: "de_DE"))), systemImage: "bell")
.font(.system(size: 12))
.foregroundStyle(theme.accent.opacity(0.8))
}
}
Spacer()
Button {
nextStepText = step
isEditingNextStep = true
} label: {
Image(systemName: "pencil")
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
private func cancelReminder(for person: Person) {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"])
}
// MARK: - Moments
private var momentsSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
SectionHeader(title: "Momente", icon: "clock")
Spacer()
NavigationLink {
LogbuchView(person: person)
} label: {
Image(systemName: "book.closed")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 8)
.padding(.vertical, 5)
}
Button {
showingAddMoment = true
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 11, weight: .medium))
Text("Moment")
.font(.system(size: 13, weight: .medium))
}
.foregroundStyle(theme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(theme.accent.opacity(0.10))
.clipShape(Capsule())
}
}
if person.sortedMoments.isEmpty {
Text("Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen.")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
.padding(.vertical, 4)
} else {
VStack(spacing: 0) {
ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in
MomentRowView(moment: moment)
if index < person.sortedMoments.count - 1 {
RowDivider()
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
}
// MARK: - Info
private var hasInfoContent: Bool {
person.birthday != nil
|| !(person.location ?? "").isEmpty
|| !(person.interests ?? "").isEmpty
|| !(person.generalNotes ?? "").isEmpty
}
private var infoSection: some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: "Über \(person.firstName)", icon: "person")
VStack(spacing: 0) {
if let birthday = person.birthday {
InfoRowView(
label: "Geburtstag",
value: birthday.formatted(.dateTime.day().month(.wide).year().locale(Locale(identifier: "de_DE")))
)
RowDivider()
}
if let loc = person.location, !loc.isEmpty {
InfoRowView(label: "Wohnort", value: loc)
RowDivider()
}
if let interests = person.interests, !interests.isEmpty {
InfoRowView(label: "Interessen", value: interests)
RowDivider()
}
if let notes = person.generalNotes, !notes.isEmpty {
InfoRowView(label: "Notizen", value: notes)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
}
// MARK: - Reminder Sheet
struct NextStepReminderSheet: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
@Bindable var person: Person
@Binding var reminderDate: Date
var body: some View {
VStack(spacing: 0) {
// Handle
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.3))
.frame(width: 36, height: 4)
.padding(.top, 12)
VStack(spacing: 24) {
// Header
VStack(spacing: 6) {
Image(systemName: "bell")
.font(.system(size: 26, weight: .light))
.foregroundStyle(theme.accent)
Text("Erinnerung setzen?")
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
if let step = person.nextStep {
Text(step)
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
}
// Date picker
DatePicker("", selection: $reminderDate, in: Date()..., displayedComponents: [.date, .hourAndMinute])
.datePickerStyle(.compact)
.labelsHidden()
.tint(theme.accent)
// Buttons
VStack(spacing: 10) {
Button {
scheduleReminder()
dismiss()
} label: {
Text("Erinnern")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusTag))
}
Button {
dismiss()
} label: {
Text("Überspringen")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
}
}
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary)
.presentationDetents([.height(380)])
.presentationDragIndicator(.hidden)
}
private func scheduleReminder() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = person.firstName
content.body = person.nextStep ?? ""
content.sound = .default
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: reminderDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(
identifier: "nextstep-\(person.id)",
content: content,
trigger: trigger
)
center.add(request)
DispatchQueue.main.async {
person.nextStepReminderDate = reminderDate
}
}
}
}
// MARK: - Moment Row
struct MomentRowView: View {
@Environment(\.nahbarTheme) var theme
let moment: Moment
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: moment.type.icon)
.font(.system(size: 13, weight: .light))
.foregroundStyle(theme.contentTertiary)
.frame(width: 18)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(moment.text)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE")))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// MARK: - Info Row
struct InfoRowView: View {
@Environment(\.nahbarTheme) var theme
let label: String
let value: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
.frame(width: 88, alignment: .leading)
Text(value)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
+330
View File
@@ -0,0 +1,330 @@
import SwiftUI
import LocalAuthentication
struct SettingsView: View {
@Environment(\.nahbarTheme) var theme
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
@EnvironmentObject private var callWindowManager: CallWindowManager
@EnvironmentObject private var appLockManager: AppLockManager
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
@State private var showingPINSetup = false
@State private var showingPINDisable = false
private var biometricLabel: String {
switch appLockManager.biometricType {
case .faceID: return "Face ID aktiviert"
case .touchID: return "Touch ID aktiviert"
default: return "Aktiv"
}
}
private var activeThemeID: ThemeID {
ThemeID(rawValue: activeThemeIDRaw) ?? .linen
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 32) {
// Header
Text("Einstellungen")
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.padding(.horizontal, 20)
.padding(.top, 12)
// Theme picker
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Atmosphäre", icon: "paintpalette")
.padding(.horizontal, 20)
NavigationLink(destination: ThemePickerView()) {
HStack(spacing: 14) {
// Swatch
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: 8)
.fill(NahbarTheme.theme(for: activeThemeID).backgroundPrimary)
.frame(width: 40, height: 40)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(theme.borderSubtle, lineWidth: 1)
)
RoundedRectangle(cornerRadius: 3)
.fill(NahbarTheme.theme(for: activeThemeID).accent)
.frame(width: 13, height: 13)
.padding(2)
}
VStack(alignment: .leading, spacing: 2) {
Text(activeThemeID.displayName)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.contentPrimary)
Text(activeThemeID.tagline)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
}
// App-Schutz
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "App-Schutz", icon: "lock")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Code-Schutz")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
if appLockManager.isEnabled {
Text(biometricLabel)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
Spacer()
Toggle("", isOn: Binding(
get: { appLockManager.isEnabled },
set: { newValue in
if newValue { showingPINSetup = true }
else { showingPINDisable = true }
}
))
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if appLockManager.isEnabled {
RowDivider()
Button { showingPINSetup = true } label: {
HStack {
Text("Code ändern")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
.sheet(isPresented: $showingPINSetup, onDismiss: { appLockManager.refreshBiometricType() }) {
AppLockSetupView(isDisabling: false)
.environmentObject(appLockManager)
}
.sheet(isPresented: $showingPINDisable) {
AppLockSetupView(isDisabling: true)
.environmentObject(appLockManager)
}
// Gesprächszeit
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Gesprächszeit", icon: "phone.arrow.up.right")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
Text("Aktiv")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Toggle("", isOn: $callWindowManager.isEnabled)
.tint(theme.accent)
.onChange(of: callWindowManager.isEnabled) { _, enabled in
if enabled {
callWindowManager.scheduleNotifications()
} else {
callWindowManager.cancelNotifications()
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if callWindowManager.isEnabled {
RowDivider()
NavigationLink {
CallWindowSetupView(
manager: callWindowManager,
isOnboarding: false,
onDone: {}
)
} label: {
HStack {
Text("Zeitfenster")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Text(callWindowManager.windowDescription)
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// Vorausschau
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Vorausschau", icon: "calendar")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
Text("Geburtstage & Termine")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $daysAhead) {
Text("3 Tage").tag(3)
Text("1 Woche").tag(7)
Text("2 Wochen").tag(14)
Text("1 Monat").tag(30)
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// About
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Über nahbar", icon: "info.circle")
.padding(.horizontal, 20)
VStack(spacing: 0) {
SettingsInfoRow(label: "Version", value: "1.0 Draft")
RowDivider()
SettingsInfoRow(label: "Daten", value: "Lokal + iCloud")
RowDivider()
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
}
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationBarHidden(true)
}
}
}
// MARK: - Theme Option Row
struct ThemeOptionRow: View {
@Environment(\.nahbarTheme) var current
let themeID: ThemeID
let isActive: Bool
let onSelect: () -> Void
private var preview: NahbarTheme { NahbarTheme.theme(for: themeID) }
var body: some View {
Button(action: onSelect) {
HStack(spacing: 14) {
// Color swatch
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 5)
.fill(preview.backgroundPrimary)
.frame(width: 32, height: 32)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(current.borderSubtle, lineWidth: 1)
)
RoundedRectangle(cornerRadius: 3)
.fill(preview.accent)
.frame(width: 10, height: 32)
}
VStack(alignment: .leading, spacing: 2) {
Text(themeID.displayName)
.font(.system(size: 15, weight: isActive ? .semibold : .regular))
.foregroundStyle(current.contentPrimary)
Text(themeID.tagline)
.font(.system(size: 12))
.foregroundStyle(current.contentTertiary)
}
Spacer()
if themeID.isPremium {
Text("PRO")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(current.accent)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(current.accent.opacity(0.10))
.clipShape(Capsule())
}
if isActive {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(current.accent)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(current.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: current.radiusCard))
}
}
}
// MARK: - Settings Info Row
struct SettingsInfoRow: View {
@Environment(\.nahbarTheme) var theme
let label: String
let value: String
var body: some View {
HStack(alignment: .top) {
Text(label)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Text(value)
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
.multilineTextAlignment(.trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
// MARK: - Person Avatar
struct PersonAvatar: View {
@Environment(\.nahbarTheme) var theme
let person: Person
let size: CGFloat
var body: some View {
Group {
if let data = person.photoData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Text(person.initials)
.font(.system(size: size * 0.38, weight: .medium, design: theme.displayDesign))
.foregroundStyle(theme.accent)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.accent.opacity(0.12))
}
}
.frame(width: size, height: size)
.clipShape(Circle())
}
}
// MARK: - Tag Badge
struct TagBadge: View {
@Environment(\.nahbarTheme) var theme
let text: String
var body: some View {
Text(text)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
}
}
// MARK: - Section Header
struct SectionHeader: View {
@Environment(\.nahbarTheme) var theme
let title: String
let icon: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(theme.contentTertiary)
Text(title.uppercased())
.font(.system(size: 11, weight: .semibold))
.tracking(0.8)
.foregroundStyle(theme.contentTertiary)
}
}
}
// MARK: - Divider Row
struct RowDivider: View {
@Environment(\.nahbarTheme) var theme
var body: some View {
Rectangle()
.fill(theme.borderSubtle)
.frame(height: 0.5)
.padding(.leading, 16)
}
}
// MARK: - Themed Navigation Bar Modifier
private struct ThemedNavBar: ViewModifier {
@Environment(\.nahbarTheme) var theme
func body(content: Content) -> some View {
content
.toolbarBackground(theme.backgroundPrimary.opacity(0.92), for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .navigationBar)
}
}
extension View {
func themedNavBar() -> some View {
modifier(ThemedNavBar())
}
}
+227
View File
@@ -0,0 +1,227 @@
import SwiftUI
// MARK: - Theme Picker View
struct ThemePickerView: View {
@Environment(\.nahbarTheme) var theme
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
@State private var previewID: ThemeID = .linen
private var activeThemeID: ThemeID {
ThemeID(rawValue: activeThemeIDRaw) ?? .linen
}
private var lightThemes: [ThemeID] {
ThemeID.allCases.filter { !$0.isDark }
}
private var darkThemes: [ThemeID] {
ThemeID.allCases.filter { $0.isDark && !$0.isNeurodiverseFocused }
}
private var ndThemes: [ThemeID] {
ThemeID.allCases.filter { $0.isNeurodiverseFocused }
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
// Live Preview
ThemePreviewCard(previewTheme: NahbarTheme.theme(for: previewID))
.padding(.horizontal, 20)
.padding(.top, 4)
.animation(.easeInOut(duration: 0.25), value: previewID)
// Hell
themeGroup(title: "Hell", icon: "sun.max", themes: lightThemes)
// Dunkel
themeGroup(title: "Dunkel", icon: "moon", themes: darkThemes)
// Neurodivers
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
SectionHeader(title: "Neurodivers", icon: "sparkles")
.padding(.horizontal, 20)
Text("Dunkel · Reizarm · Reduzierte Bewegung")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 20)
}
VStack(spacing: 8) {
ForEach(ndThemes, id: \.self) { id in
themeRow(id: id)
}
}
.padding(.horizontal, 20)
}
}
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Atmosphäre")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.onAppear {
previewID = activeThemeID
}
}
// MARK: - Group
private func themeGroup(title: String, icon: String, themes: [ThemeID]) -> some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: title, icon: icon)
.padding(.horizontal, 20)
VStack(spacing: 8) {
ForEach(themes, id: \.self) { id in
themeRow(id: id)
}
}
.padding(.horizontal, 20)
}
}
// MARK: - Row
private func themeRow(id: ThemeID) -> some View {
let preview = NahbarTheme.theme(for: id)
let isActive = id == activeThemeID
let isPreviewing = id == previewID
return Button {
previewID = id
activeThemeIDRaw = id.rawValue
} label: {
HStack(spacing: 14) {
// Color swatch
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: 8)
.fill(preview.backgroundPrimary)
.frame(width: 44, height: 44)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isPreviewing ? theme.accent : theme.borderSubtle, lineWidth: isPreviewing ? 2 : 1)
)
RoundedRectangle(cornerRadius: 3)
.fill(preview.accent)
.frame(width: 14, height: 14)
.padding(3)
}
VStack(alignment: .leading, spacing: 2) {
Text(id.displayName)
.font(.system(size: 15, weight: isActive ? .semibold : .regular))
.foregroundStyle(theme.contentPrimary)
Text(id.tagline)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
if id.isPremium && !isActive {
Text("PRO")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(theme.accent.opacity(0.10))
.clipShape(Capsule())
}
if isActive {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.accent)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
}
// MARK: - Live Preview Card
struct ThemePreviewCard: View {
let previewTheme: NahbarTheme
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 20)
.fill(previewTheme.backgroundPrimary)
.frame(height: 200)
VStack(alignment: .leading, spacing: 0) {
// Header
VStack(alignment: .leading, spacing: 3) {
Text("Guten Morgen.")
.font(.system(size: 22, weight: .light, design: previewTheme.displayDesign))
.foregroundStyle(previewTheme.contentPrimary)
Text("Mittwoch, 16. April")
.font(.system(size: 12, design: previewTheme.displayDesign))
.foregroundStyle(previewTheme.contentTertiary)
}
.padding(.horizontal, 18)
.padding(.top, 18)
Spacer()
// Mock card
VStack(spacing: 0) {
mockRow(name: "Anna Müller", hint: "Heute Geburtstag 🎂", icon: "gift")
Rectangle()
.fill(previewTheme.borderSubtle)
.frame(height: 0.5)
.padding(.leading, 52)
mockRow(name: "Nächster Schritt", hint: "Meeting vorbereiten", icon: "arrow.right.circle")
}
.background(previewTheme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: previewTheme.radiusCard))
.padding(.horizontal, 14)
.padding(.bottom, 14)
}
// Accent bar
HStack {
Spacer()
Capsule()
.fill(previewTheme.accent)
.frame(width: 36, height: 4)
.padding(.top, 12)
.padding(.trailing, 18)
}
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: Color.black.opacity(0.08), radius: 12, x: 0, y: 4)
}
private func mockRow(name: String, hint: String, icon: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 12))
.foregroundStyle(previewTheme.accent)
.frame(width: 28, height: 28)
.background(previewTheme.accent.opacity(0.12))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 1) {
Text(name)
.font(.system(size: 12, weight: .medium, design: previewTheme.displayDesign))
.foregroundStyle(previewTheme.contentPrimary)
Text(hint)
.font(.system(size: 10, design: previewTheme.displayDesign))
.foregroundStyle(previewTheme.contentSecondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
+264
View File
@@ -0,0 +1,264 @@
import SwiftUI
// MARK: - Theme ID
enum ThemeID: String, CaseIterable, Codable {
case linen, slate, mist, grove, ink, copper
case abyss, dusk, basalt
var isPremium: Bool {
switch self {
case .linen, .slate, .mist: return false
default: return true
}
}
var isDark: Bool {
switch self {
case .copper, .abyss, .dusk, .basalt: return true
default: return false
}
}
var isNeurodiverseFocused: Bool {
switch self {
case .abyss, .dusk, .basalt: return true
default: return false
}
}
var displayName: String {
switch self {
case .linen: return "Linen"
case .slate: return "Slate"
case .mist: return "Mist"
case .grove: return "Grove"
case .ink: return "Ink"
case .copper: return "Copper"
case .abyss: return "Abyss"
case .dusk: return "Dusk"
case .basalt: return "Basalt"
}
}
var tagline: String {
switch self {
case .linen: return "Ruhig & warm"
case .slate: return "Klar & fokussiert"
case .mist: return "Sehr sanft"
case .grove: return "Natürlich & verbunden"
case .ink: return "Editorial & präzise"
case .copper: return "Warm & abendlich"
case .abyss: return "Tief & fokussiert · ND"
case .dusk: return "Warm & augenschonend · ND"
case .basalt: return "Neutral & reizarm · ND"
}
}
}
// MARK: - Theme Definition
struct NahbarTheme {
let id: ThemeID
// Surfaces
let backgroundPrimary: Color
let backgroundSecondary: Color
let surfaceCard: Color
let borderSubtle: Color
// Content
let contentPrimary: Color
let contentSecondary: Color
let contentTertiary: Color
let accent: Color
// Typography
let displayDesign: Font.Design
// Shape
let radiusCard: CGFloat
let radiusTag: CGFloat
// Motion
let reducedMotion: Bool
}
// MARK: - Theme Definitions
extension NahbarTheme {
static let linen = NahbarTheme(
id: .linen,
backgroundPrimary: Color(red: 0.961, green: 0.949, blue: 0.933),
backgroundSecondary: Color(red: 0.929, green: 0.914, blue: 0.894),
surfaceCard: Color(red: 0.980, green: 0.969, blue: 0.957),
borderSubtle: Color(red: 0.800, green: 0.773, blue: 0.745).opacity(0.45),
contentPrimary: Color(red: 0.180, green: 0.145, blue: 0.110),
contentSecondary: Color(red: 0.447, green: 0.384, blue: 0.318),
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
accent: Color(red: 0.710, green: 0.443, blue: 0.290),
displayDesign: .default,
radiusCard: 16,
radiusTag: 8,
reducedMotion: false
)
static let slate = NahbarTheme(
id: .slate,
backgroundPrimary: Color(red: 0.925, green: 0.933, blue: 0.941),
backgroundSecondary: Color(red: 0.894, green: 0.906, blue: 0.918),
surfaceCard: Color(red: 0.957, green: 0.961, blue: 0.969),
borderSubtle: Color(red: 0.729, green: 0.753, blue: 0.784).opacity(0.45),
contentPrimary: Color(red: 0.090, green: 0.110, blue: 0.137),
contentSecondary: Color(red: 0.353, green: 0.388, blue: 0.431),
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
accent: Color(red: 0.239, green: 0.353, blue: 0.945),
displayDesign: .default,
radiusCard: 12,
radiusTag: 6,
reducedMotion: false
)
static let mist = NahbarTheme(
id: .mist,
backgroundPrimary: Color(red: 0.973, green: 0.973, blue: 0.973),
backgroundSecondary: Color(red: 0.945, green: 0.945, blue: 0.949),
surfaceCard: Color(red: 0.988, green: 0.988, blue: 0.992),
borderSubtle: Color(red: 0.820, green: 0.820, blue: 0.835).opacity(0.35),
contentPrimary: Color(red: 0.200, green: 0.200, blue: 0.216),
contentSecondary: Color(red: 0.455, green: 0.455, blue: 0.475),
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
accent: Color(red: 0.569, green: 0.541, blue: 0.745),
displayDesign: .default,
radiusCard: 20,
radiusTag: 10,
reducedMotion: true
)
static let grove = NahbarTheme(
id: .grove,
backgroundPrimary: Color(red: 0.910, green: 0.929, blue: 0.894),
backgroundSecondary: Color(red: 0.875, green: 0.898, blue: 0.855),
surfaceCard: Color(red: 0.945, green: 0.957, blue: 0.933),
borderSubtle: Color(red: 0.659, green: 0.722, blue: 0.627).opacity(0.45),
contentPrimary: Color(red: 0.110, green: 0.188, blue: 0.118),
contentSecondary: Color(red: 0.298, green: 0.408, blue: 0.298),
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
accent: Color(red: 0.220, green: 0.412, blue: 0.227),
displayDesign: .serif,
radiusCard: 16,
radiusTag: 8,
reducedMotion: false
)
static let ink = NahbarTheme(
id: .ink,
backgroundPrimary: Color(red: 0.980, green: 0.980, blue: 0.976),
backgroundSecondary: Color(red: 0.953, green: 0.953, blue: 0.949),
surfaceCard: Color(red: 1.000, green: 1.000, blue: 1.000),
borderSubtle: Color(red: 0.749, green: 0.749, blue: 0.745).opacity(0.45),
contentPrimary: Color(red: 0.063, green: 0.063, blue: 0.063),
contentSecondary: Color(red: 0.310, green: 0.310, blue: 0.310),
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
accent: Color(red: 0.749, green: 0.220, blue: 0.165),
displayDesign: .serif,
radiusCard: 8,
radiusTag: 4,
reducedMotion: true
)
static let copper = NahbarTheme(
id: .copper,
backgroundPrimary: Color(red: 0.110, green: 0.078, blue: 0.063),
backgroundSecondary: Color(red: 0.141, green: 0.102, blue: 0.082),
surfaceCard: Color(red: 0.173, green: 0.129, blue: 0.102),
borderSubtle: Color(red: 0.341, green: 0.251, blue: 0.188).opacity(0.55),
contentPrimary: Color(red: 0.941, green: 0.902, blue: 0.851),
contentSecondary: Color(red: 0.714, green: 0.659, blue: 0.588),
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
accent: Color(red: 0.784, green: 0.514, blue: 0.227),
displayDesign: .default,
radiusCard: 16,
radiusTag: 8,
reducedMotion: false
)
// MARK: - Abyss (Neurodivers: kühles Dunkel, Fokus)
static let abyss = NahbarTheme(
id: .abyss,
backgroundPrimary: Color(red: 0.071, green: 0.082, blue: 0.106),
backgroundSecondary: Color(red: 0.094, green: 0.110, blue: 0.141),
surfaceCard: Color(red: 0.118, green: 0.137, blue: 0.176),
borderSubtle: Color(red: 0.251, green: 0.314, blue: 0.431).opacity(0.40),
contentPrimary: Color(red: 0.882, green: 0.910, blue: 0.945),
contentSecondary: Color(red: 0.541, green: 0.612, blue: 0.710),
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
accent: Color(red: 0.357, green: 0.553, blue: 0.937),
displayDesign: .default,
radiusCard: 14,
radiusTag: 7,
reducedMotion: true
)
// MARK: - Dusk (Neurodivers: warmes Dunkel, augenschonend)
static let dusk = NahbarTheme(
id: .dusk,
backgroundPrimary: Color(red: 0.102, green: 0.082, blue: 0.063),
backgroundSecondary: Color(red: 0.133, green: 0.110, blue: 0.086),
surfaceCard: Color(red: 0.169, green: 0.141, blue: 0.110),
borderSubtle: Color(red: 0.353, green: 0.275, blue: 0.204).opacity(0.45),
contentPrimary: Color(red: 0.941, green: 0.906, blue: 0.851),
contentSecondary: Color(red: 0.651, green: 0.565, blue: 0.451),
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
accent: Color(red: 0.831, green: 0.573, blue: 0.271),
displayDesign: .default,
radiusCard: 18,
radiusTag: 9,
reducedMotion: true
)
// MARK: - Basalt (Neurodivers: neutral, reizarm, monospaced)
static let basalt = NahbarTheme(
id: .basalt,
backgroundPrimary: Color(red: 0.082, green: 0.082, blue: 0.082),
backgroundSecondary: Color(red: 0.110, green: 0.110, blue: 0.110),
surfaceCard: Color(red: 0.141, green: 0.141, blue: 0.141),
borderSubtle: Color(red: 0.314, green: 0.314, blue: 0.314).opacity(0.35),
contentPrimary: Color(red: 0.882, green: 0.882, blue: 0.882),
contentSecondary: Color(red: 0.561, green: 0.561, blue: 0.561),
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
accent: Color(red: 0.376, green: 0.725, blue: 0.545),
displayDesign: .monospaced,
radiusCard: 10,
radiusTag: 5,
reducedMotion: true
)
static func theme(for id: ThemeID) -> NahbarTheme {
switch id {
case .linen: return .linen
case .slate: return .slate
case .mist: return .mist
case .grove: return .grove
case .ink: return .ink
case .copper: return .copper
case .abyss: return .abyss
case .dusk: return .dusk
case .basalt: return .basalt
}
}
}
// MARK: - Environment
private struct ThemeKey: EnvironmentKey {
static let defaultValue: NahbarTheme = .linen
}
extension EnvironmentValues {
var nahbarTheme: NahbarTheme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
+257
View File
@@ -0,0 +1,257 @@
import SwiftUI
import SwiftData
struct TodayView: View {
@Environment(\.nahbarTheme) var theme
@Query private var people: [Person]
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
private var needsAttention: [Person] {
people
.filter { $0.needsAttention }
.sorted { ($0.lastMomentDate ?? $0.createdAt) < ($1.lastMomentDate ?? $1.createdAt) }
}
private var birthdayPeople: [Person] {
people.filter { $0.hasBirthdayWithin(days: daysAhead) }
}
// People with a scheduled reminder within the look-ahead window
private var upcomingReminders: [Person] {
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: Date()) ?? Date()
return people
.filter { p in
guard let reminder = p.nextStepReminderDate, !p.nextStepCompleted else { return false }
return reminder >= Date() && reminder <= horizon
}
.sorted { ($0.nextStepReminderDate ?? Date()) < ($1.nextStepReminderDate ?? Date()) }
}
// Open next steps NOT already shown under upcoming reminders
private var openNextSteps: [Person] {
let scheduledIDs = Set(upcomingReminders.map { $0.id })
return people.filter { p in
p.nextStep != nil && !p.nextStepCompleted && !scheduledIDs.contains(p.id)
}
}
private var isEmpty: Bool {
needsAttention.isEmpty && birthdayPeople.isEmpty && openNextSteps.isEmpty && upcomingReminders.isEmpty
}
private var birthdaySectionTitle: String {
switch daysAhead {
case 3: return "In 3 Tagen"
case 7: return "Diese Woche"
case 14: return "Nächste 2 Wochen"
case 30: return "Nächster Monat"
default: return "Nächste \(daysAhead) Tage"
}
}
private var greeting: String {
let hour = Calendar.current.component(.hour, from: Date())
if hour < 12 { return "Guten Morgen." }
if hour < 18 { return "Guten Tag." }
return "Guten Abend."
}
private var formattedToday: String {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "de_DE")
fmt.dateFormat = "EEEE, d. MMMM"
return fmt.string(from: Date())
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 32) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(greeting)
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text(formattedToday)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 20)
.padding(.top, 12)
if isEmpty {
emptyState
} else {
VStack(spacing: 24) {
if !birthdayPeople.isEmpty {
TodaySection(title: birthdaySectionTitle, icon: "gift") {
ForEach(birthdayPeople) { person in
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: birthdayHint(for: person))
}
.buttonStyle(.plain)
if person.id != birthdayPeople.last?.id {
RowDivider()
}
}
}
}
if !upcomingReminders.isEmpty {
TodaySection(title: "Anstehende Termine", icon: "calendar") {
ForEach(upcomingReminders) { person in
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: reminderHint(for: person))
}
.buttonStyle(.plain)
if person.id != upcomingReminders.last?.id {
RowDivider()
}
}
}
}
if !openNextSteps.isEmpty {
TodaySection(title: "Offene Schritte", icon: "arrow.right.circle") {
ForEach(openNextSteps) { person in
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: person.nextStep ?? "")
}
.buttonStyle(.plain)
if person.id != openNextSteps.last?.id {
RowDivider()
}
}
}
}
if !needsAttention.isEmpty {
TodaySection(title: "Schon eine Weile her", icon: "clock") {
ForEach(needsAttention.prefix(5)) { person in
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: lastSeenHint(for: person))
}
.buttonStyle(.plain)
if person.id != Array(needsAttention.prefix(5)).last?.id {
RowDivider()
}
}
}
}
}
}
}
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationBarHidden(true)
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 10) {
Spacer().frame(height: 48)
Text("Ein ruhiger Tag.")
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text("Oder einer, der es noch wird.")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
}
.frame(maxWidth: .infinity)
}
// MARK: - Hints
private func birthdayHint(for person: Person) -> String {
guard let birthday = person.birthday else { return "" }
let cal = Calendar.current
let bdc = cal.dateComponents([.month, .day], from: birthday)
for offset in 0..<7 {
if let date = cal.date(byAdding: .day, value: offset, to: Date()) {
let dc = cal.dateComponents([.month, .day], from: date)
if dc.month == bdc.month && dc.day == bdc.day {
if offset == 0 { return "Heute Geburtstag 🎂" }
if offset == 1 { return "Morgen Geburtstag" }
return "In \(offset) Tagen Geburtstag"
}
}
}
return ""
}
private func reminderHint(for person: Person) -> String {
guard let reminder = person.nextStepReminderDate else { return person.nextStep ?? "" }
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE")))
return "\(person.nextStep ?? "") · \(dateStr)"
}
private func lastSeenHint(for person: Person) -> String {
guard let last = person.lastMomentDate else { return "Noch keine Momente festgehalten" }
let days = Int(Date().timeIntervalSince(last) / 86400)
if days == 0 { return "Heute" }
if days == 1 { return "Gestern" }
if days < 7 { return "Vor \(days) Tagen" }
if days < 30 { return "Vor \(days / 7) Woche\(days / 7 == 1 ? "" : "n")" }
let months = days / 30
return "Vor \(months) Monat\(months == 1 ? "" : "en")"
}
}
// MARK: - Today Section
struct TodaySection<Content: View>: View {
@Environment(\.nahbarTheme) var theme
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: title, icon: icon)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
}
}
// MARK: - Today Row
struct TodayRow: View {
@Environment(\.nahbarTheme) var theme
let person: Person
let hint: String
var body: some View {
HStack(spacing: 12) {
PersonAvatar(person: person, size: 40)
VStack(alignment: .leading, spacing: 2) {
Text(person.name)
.font(.system(size: 15, weight: .medium, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
if !hint.isEmpty {
Text(hint)
.font(.system(size: 13))
.foregroundStyle(theme.contentSecondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}