commit 3e04fc3296851d8e92fe57eab9d6107d0fc46815 Author: Sven Date: Thu Apr 16 19:37:28 2026 +0200 erster Commit diff --git a/nahbar/.DS_Store b/nahbar/.DS_Store new file mode 100644 index 0000000..bb89eeb Binary files /dev/null and b/nahbar/.DS_Store differ diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4160739 --- /dev/null +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -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 = ""; }; + 26EF66242F9112E700824F91 /* AddPersonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPersonView.swift; sourceTree = ""; }; + 26EF66252F9112E700824F91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 26EF66262F9112E700824F91 /* ContactPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPickerView.swift; sourceTree = ""; }; + 26EF66272F9112E700824F91 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 26EF66282F9112E700824F91 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 26EF66292F9112E700824F91 /* PeopleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleListView.swift; sourceTree = ""; }; + 26EF662A2F9112E700824F91 /* PersonDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailView.swift; sourceTree = ""; }; + 26EF662B2F9112E700824F91 /* NahbarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarApp.swift; sourceTree = ""; }; + 26EF662C2F9112E700824F91 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 26EF662D2F9112E700824F91 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; }; + 26EF662E2F9112E700824F91 /* ThemeSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSystem.swift; sourceTree = ""; }; + 26EF662F2F9112E700824F91 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; + 26EF663E2F9129D700824F91 /* CallWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallWindowManager.swift; sourceTree = ""; }; + 26EF66402F9129F000824F91 /* CallWindowSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallWindowSetupView.swift; sourceTree = ""; }; + 26EF66422F912A0000824F91 /* CallSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSuggestionView.swift; sourceTree = ""; }; + 26EF66442F91350200824F91 /* AppLockManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockManager.swift; sourceTree = ""; }; + 26EF66462F91351800824F91 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; }; + 26EF66482F91352D00824F91 /* AppLockSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupView.swift; sourceTree = ""; }; + 26EF664A2F913C8600824F91 /* LogbuchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbuchView.swift; sourceTree = ""; }; + 26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = ""; }; +/* 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 = ""; + }; + 265F92212F9109B500CE0A5C /* Products */ = { + isa = PBXGroup; + children = ( + 265F92202F9109B500CE0A5C /* nahbar.app */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 */; +} diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/nahbar/nahbar.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/nahbar/nahbar.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..187f036 Binary files /dev/null and b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nahbar/nahbar.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/nahbar/nahbar.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..3d09db2 --- /dev/null +++ b/nahbar/nahbar.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + relationz.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift new file mode 100644 index 0000000..e12e64d --- /dev/null +++ b/nahbar/nahbar/AddMomentView.swift @@ -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) + } + } +} diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift new file mode 100644 index 0000000..ef5cf26 --- /dev/null +++ b/nahbar/nahbar/AddPersonView.swift @@ -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(_ 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) -> 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() + } +} diff --git a/nahbar/nahbar/AppLockManager.swift b/nahbar/nahbar/AppLockManager.swift new file mode 100644 index 0000000..48000bc --- /dev/null +++ b/nahbar/nahbar/AppLockManager.swift @@ -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) + } +} diff --git a/nahbar/nahbar/AppLockSetupView.swift b/nahbar/nahbar/AppLockSetupView.swift new file mode 100644 index 0000000..b886e99 --- /dev/null +++ b/nahbar/nahbar/AppLockSetupView.swift @@ -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.. 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)) + } + } + } +} diff --git a/nahbar/nahbar/Assets.xcassets/AccentColor.colorset/Contents.json b/nahbar/nahbar/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/nahbar/nahbar/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/nahbar/nahbar/Assets.xcassets/AppIcon.appiconset/Contents.json b/nahbar/nahbar/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/nahbar/nahbar/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/nahbar/nahbar/Assets.xcassets/Contents.json b/nahbar/nahbar/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/nahbar/nahbar/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/nahbar/nahbar/CallSuggestionView.swift b/nahbar/nahbar/CallSuggestionView.swift new file mode 100644 index 0000000..2b1e3aa --- /dev/null +++ b/nahbar/nahbar/CallSuggestionView.swift @@ -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 + } +} diff --git a/nahbar/nahbar/CallWindowManager.swift b/nahbar/nahbar/CallWindowManager.swift new file mode 100644 index 0000000..30bf21a --- /dev/null +++ b/nahbar/nahbar/CallWindowManager.swift @@ -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 { + 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] // Mo–Fr + } + } + + // 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) + } +} diff --git a/nahbar/nahbar/CallWindowSetupView.swift b/nahbar/nahbar/CallWindowSetupView.swift new file mode 100644 index 0000000..34946e1 --- /dev/null +++ b/nahbar/nahbar/CallWindowSetupView.swift @@ -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() + } +} diff --git a/nahbar/nahbar/ContactPickerView.swift b/nahbar/nahbar/ContactPickerView.swift new file mode 100644 index 0000000..faf7175 --- /dev/null +++ b/nahbar/nahbar/ContactPickerView.swift @@ -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)) + } + } +} diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift new file mode 100644 index 0000000..2887551 --- /dev/null +++ b/nahbar/nahbar/ContentView.swift @@ -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) +} diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift new file mode 100644 index 0000000..656d4ae --- /dev/null +++ b/nahbar/nahbar/LogbuchView.swift @@ -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 + } +} diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift new file mode 100644 index 0000000..9c0a765 --- /dev/null +++ b/nahbar/nahbar/Models.swift @@ -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..= 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 } + } +} diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift new file mode 100644 index 0000000..b15121d --- /dev/null +++ b/nahbar/nahbar/NahbarApp.swift @@ -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 + } +} diff --git a/nahbar/nahbar/PeopleListView.swift b/nahbar/nahbar/PeopleListView.swift new file mode 100644 index 0000000..a8aedf4 --- /dev/null +++ b/nahbar/nahbar/PeopleListView.swift @@ -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) + } +} diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift new file mode 100644 index 0000000..638eab4 --- /dev/null +++ b/nahbar/nahbar/PersonDetailView.swift @@ -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) + } +} diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift new file mode 100644 index 0000000..103111e --- /dev/null +++ b/nahbar/nahbar/SettingsView.swift @@ -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) + } +} diff --git a/nahbar/nahbar/SharedComponents.swift b/nahbar/nahbar/SharedComponents.swift new file mode 100644 index 0000000..66f05b5 --- /dev/null +++ b/nahbar/nahbar/SharedComponents.swift @@ -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()) + } +} diff --git a/nahbar/nahbar/ThemePickerView.swift b/nahbar/nahbar/ThemePickerView.swift new file mode 100644 index 0000000..3fafd77 --- /dev/null +++ b/nahbar/nahbar/ThemePickerView.swift @@ -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) + } +} diff --git a/nahbar/nahbar/ThemeSystem.swift b/nahbar/nahbar/ThemeSystem.swift new file mode 100644 index 0000000..6806755 --- /dev/null +++ b/nahbar/nahbar/ThemeSystem.swift @@ -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 } + } +} diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift new file mode 100644 index 0000000..bafd9e4 --- /dev/null +++ b/nahbar/nahbar/TodayView.swift @@ -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: 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) + } +}