erster Commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,432 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
26EF66312F9112E700824F91 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26EF66252F9112E700824F91 /* Assets.xcassets */; };
|
||||
26EF66322F9112E700824F91 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; };
|
||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662F2F9112E700824F91 /* TodayView.swift */; };
|
||||
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662A2F9112E700824F91 /* PersonDetailView.swift */; };
|
||||
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662E2F9112E700824F91 /* ThemeSystem.swift */; };
|
||||
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66292F9112E700824F91 /* PeopleListView.swift */; };
|
||||
26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66242F9112E700824F91 /* AddPersonView.swift */; };
|
||||
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662C2F9112E700824F91 /* SettingsView.swift */; };
|
||||
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66232F9112E700824F91 /* AddMomentView.swift */; };
|
||||
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662B2F9112E700824F91 /* NahbarApp.swift */; };
|
||||
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66272F9112E700824F91 /* ContentView.swift */; };
|
||||
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66262F9112E700824F91 /* ContactPickerView.swift */; };
|
||||
26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662D2F9112E700824F91 /* SharedComponents.swift */; };
|
||||
26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF663E2F9129D700824F91 /* CallWindowManager.swift */; };
|
||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66402F9129F000824F91 /* CallWindowSetupView.swift */; };
|
||||
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66422F912A0000824F91 /* CallSuggestionView.swift */; };
|
||||
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66442F91350200824F91 /* AppLockManager.swift */; };
|
||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66462F91351800824F91 /* AppLockView.swift */; };
|
||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66482F91352D00824F91 /* AppLockSetupView.swift */; };
|
||||
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664A2F913C8600824F91 /* LogbuchView.swift */; };
|
||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664D2F91514B00824F91 /* ThemePickerView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
26EF66232F9112E700824F91 /* AddMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMomentView.swift; sourceTree = "<group>"; };
|
||||
26EF66242F9112E700824F91 /* AddPersonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPersonView.swift; sourceTree = "<group>"; };
|
||||
26EF66252F9112E700824F91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
26EF66262F9112E700824F91 /* ContactPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPickerView.swift; sourceTree = "<group>"; };
|
||||
26EF66272F9112E700824F91 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
26EF66282F9112E700824F91 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
26EF66292F9112E700824F91 /* PeopleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleListView.swift; sourceTree = "<group>"; };
|
||||
26EF662A2F9112E700824F91 /* PersonDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailView.swift; sourceTree = "<group>"; };
|
||||
26EF662B2F9112E700824F91 /* NahbarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarApp.swift; sourceTree = "<group>"; };
|
||||
26EF662C2F9112E700824F91 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
26EF662D2F9112E700824F91 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
||||
26EF662E2F9112E700824F91 /* ThemeSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSystem.swift; sourceTree = "<group>"; };
|
||||
26EF662F2F9112E700824F91 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||
26EF663E2F9129D700824F91 /* CallWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallWindowManager.swift; sourceTree = "<group>"; };
|
||||
26EF66402F9129F000824F91 /* CallWindowSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallWindowSetupView.swift; sourceTree = "<group>"; };
|
||||
26EF66422F912A0000824F91 /* CallSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSuggestionView.swift; sourceTree = "<group>"; };
|
||||
26EF66442F91350200824F91 /* AppLockManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockManager.swift; sourceTree = "<group>"; };
|
||||
26EF66462F91351800824F91 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = "<group>"; };
|
||||
26EF66482F91352D00824F91 /* AppLockSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupView.swift; sourceTree = "<group>"; };
|
||||
26EF664A2F913C8600824F91 /* LogbuchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbuchView.swift; sourceTree = "<group>"; };
|
||||
26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
265F921D2F9109B500CE0A5C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
265F92172F9109B500CE0A5C = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
26EF66302F9112E700824F91 /* nahbar */,
|
||||
265F92212F9109B500CE0A5C /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
265F92212F9109B500CE0A5C /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
265F92202F9109B500CE0A5C /* nahbar.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
26EF66302F9112E700824F91 /* nahbar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
26EF66232F9112E700824F91 /* AddMomentView.swift */,
|
||||
26EF66242F9112E700824F91 /* AddPersonView.swift */,
|
||||
26EF66252F9112E700824F91 /* Assets.xcassets */,
|
||||
26EF66262F9112E700824F91 /* ContactPickerView.swift */,
|
||||
26EF66272F9112E700824F91 /* ContentView.swift */,
|
||||
26EF66282F9112E700824F91 /* Models.swift */,
|
||||
26EF66292F9112E700824F91 /* PeopleListView.swift */,
|
||||
26EF662A2F9112E700824F91 /* PersonDetailView.swift */,
|
||||
26EF662B2F9112E700824F91 /* NahbarApp.swift */,
|
||||
26EF662C2F9112E700824F91 /* SettingsView.swift */,
|
||||
26EF662D2F9112E700824F91 /* SharedComponents.swift */,
|
||||
26EF662E2F9112E700824F91 /* ThemeSystem.swift */,
|
||||
26EF662F2F9112E700824F91 /* TodayView.swift */,
|
||||
26EF663E2F9129D700824F91 /* CallWindowManager.swift */,
|
||||
26EF66402F9129F000824F91 /* CallWindowSetupView.swift */,
|
||||
26EF66422F912A0000824F91 /* CallSuggestionView.swift */,
|
||||
26EF66442F91350200824F91 /* AppLockManager.swift */,
|
||||
26EF66462F91351800824F91 /* AppLockView.swift */,
|
||||
26EF66482F91352D00824F91 /* AppLockSetupView.swift */,
|
||||
26EF664A2F913C8600824F91 /* LogbuchView.swift */,
|
||||
26EF664D2F91514B00824F91 /* ThemePickerView.swift */,
|
||||
);
|
||||
path = nahbar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
265F921F2F9109B500CE0A5C /* nahbar */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 265F922B2F9109B600CE0A5C /* Build configuration list for PBXNativeTarget "nahbar" */;
|
||||
buildPhases = (
|
||||
265F921C2F9109B500CE0A5C /* Sources */,
|
||||
265F921D2F9109B500CE0A5C /* Frameworks */,
|
||||
265F921E2F9109B500CE0A5C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = nahbar;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = relationz;
|
||||
productReference = 265F92202F9109B500CE0A5C /* nahbar.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
265F92182F9109B500CE0A5C /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2640;
|
||||
LastUpgradeCheck = 2640;
|
||||
TargetAttributes = {
|
||||
265F921F2F9109B500CE0A5C = {
|
||||
CreatedOnToolsVersion = 26.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 265F921B2F9109B500CE0A5C /* Build configuration list for PBXProject "nahbar" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 265F92172F9109B500CE0A5C;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 265F92212F9109B500CE0A5C /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
265F921F2F9109B500CE0A5C /* nahbar */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
265F921E2F9109B500CE0A5C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
26EF66312F9112E700824F91 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
265F921C2F9109B500CE0A5C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
|
||||
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
|
||||
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
|
||||
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
|
||||
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
|
||||
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
|
||||
26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */,
|
||||
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
|
||||
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
|
||||
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
|
||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
||||
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */,
|
||||
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
|
||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
|
||||
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */,
|
||||
26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */,
|
||||
26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
265F92292F9109B600CE0A5C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
265F922A2F9109B600CE0A5C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
265F922C2F9109B600CE0A5C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
||||
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.nahbar;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
265F922D2F9109B600CE0A5C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
||||
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.nahbar;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
265F921B2F9109B500CE0A5C /* Build configuration list for PBXProject "nahbar" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
265F92292F9109B600CE0A5C /* Debug */,
|
||||
265F922A2F9109B600CE0A5C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
265F922B2F9109B600CE0A5C /* Build configuration list for PBXNativeTarget "nahbar" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
265F922C2F9109B600CE0A5C /* Debug */,
|
||||
265F922D2F9109B600CE0A5C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 265F92182F9109B500CE0A5C /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
BIN
Binary file not shown.
+14
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>relationz.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Contacts
|
||||
import PhotosUI
|
||||
|
||||
struct AddPersonView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@Environment(\.modelContext) var modelContext
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var existingPerson: Person? = nil
|
||||
|
||||
@State private var name = ""
|
||||
@State private var selectedTag: PersonTag = .other
|
||||
@State private var occupation = ""
|
||||
@State private var location = ""
|
||||
@State private var interests = ""
|
||||
@State private var generalNotes = ""
|
||||
@State private var hasBirthday = false
|
||||
@State private var birthday = Date()
|
||||
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
||||
|
||||
@State private var showingContactPicker = false
|
||||
@State private var importedName: String? = nil // tracks whether fields were pre-filled
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
@State private var selectedPhoto: UIImage? = nil
|
||||
@State private var photoPickerItem: PhotosPickerItem? = nil
|
||||
|
||||
private var isEditing: Bool { existingPerson != nil }
|
||||
private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
|
||||
// Photo picker
|
||||
photoSection
|
||||
|
||||
// Contact import button (only when adding, not editing)
|
||||
if !isEditing {
|
||||
importButton
|
||||
}
|
||||
|
||||
// Name
|
||||
formSection("Name") {
|
||||
TextField("Wie heißt diese Person?", text: $name)
|
||||
.font(.system(size: 22, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.tint(theme.accent)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
|
||||
// Tag
|
||||
formSection("Kontext") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(PersonTag.allCases, id: \.self) { tag in
|
||||
Button {
|
||||
selectedTag = tag
|
||||
} label: {
|
||||
Text(tag.rawValue)
|
||||
.font(.system(size: 13, weight: selectedTag == tag ? .medium : .regular))
|
||||
.foregroundStyle(selectedTag == tag ? theme.accent : theme.contentSecondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(selectedTag == tag ? theme.accent.opacity(0.12) : theme.surfaceCard)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
formSection("Optional") {
|
||||
VStack(spacing: 0) {
|
||||
inlineField("Beruf", text: $occupation)
|
||||
RowDivider()
|
||||
inlineField("Wohnort", text: $location)
|
||||
RowDivider()
|
||||
inlineField("Interessen", text: $interests)
|
||||
RowDivider()
|
||||
inlineField("Notizen", text: $generalNotes)
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
|
||||
// Birthday
|
||||
formSection("Geburtstag") {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Geburtstag bekannt")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Spacer()
|
||||
Toggle("", isOn: $hasBirthday.animation())
|
||||
.labelsHidden()
|
||||
.tint(theme.accent)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if hasBirthday {
|
||||
RowDivider()
|
||||
DatePicker(
|
||||
"Datum",
|
||||
selection: $birthday,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.compact)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.tint(theme.accent)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
|
||||
// Nudge frequency
|
||||
formSection("Wie oft erinnern?") {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(NudgeFrequency.allCases, id: \.self) { freq in
|
||||
Button {
|
||||
nudgeFrequency = freq
|
||||
} label: {
|
||||
HStack {
|
||||
Text(freq.rawValue)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Spacer()
|
||||
if nudgeFrequency == freq {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
if freq != NudgeFrequency.allCases.last {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
|
||||
// Delete link — only in edit mode
|
||||
if isEditing {
|
||||
Button {
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Text("Löschen")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||
.navigationTitle(isEditing ? (existingPerson?.name ?? "") : "Jemanden hinzufügen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.themedNavBar()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(isEditing ? "Fertig" : "Hinzufügen") { save() }
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(isValid ? theme.accent : theme.contentTertiary)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingContactPicker) {
|
||||
ContactPickerView { contact in
|
||||
applyContact(contact)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Diese Person wirklich löschen?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Löschen", role: .destructive) { deletePerson() }
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht.")
|
||||
}
|
||||
.onAppear { loadExisting() }
|
||||
}
|
||||
|
||||
// MARK: - Photo Section
|
||||
|
||||
private var photoSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
PhotosPicker(selection: $photoPickerItem, matching: .images) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if let photo = selectedPhoto {
|
||||
Image(uiImage: photo)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 88, height: 88)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Circle()
|
||||
.fill(theme.accent.opacity(0.10))
|
||||
.frame(width: 88, height: 88)
|
||||
.overlay(
|
||||
Text(previewInitials)
|
||||
.font(.system(size: 32, weight: .medium, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "camera.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(theme.accent)
|
||||
.background(theme.backgroundPrimary, in: Circle())
|
||||
}
|
||||
}
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
Task {
|
||||
if let data = try? await item?.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data),
|
||||
let resized = image.resizedForAvatar() {
|
||||
selectedPhoto = resized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedPhoto != nil {
|
||||
Button {
|
||||
selectedPhoto = nil
|
||||
photoPickerItem = nil
|
||||
} label: {
|
||||
Text("Foto entfernen")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// Initials preview using current name input
|
||||
private var previewInitials: String {
|
||||
let parts = name.split(separator: " ")
|
||||
if parts.count >= 2 {
|
||||
return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased()
|
||||
}
|
||||
return String(name.prefix(2)).uppercased().isEmpty ? "?" : String(name.prefix(2)).uppercased()
|
||||
}
|
||||
|
||||
// MARK: - Import Button
|
||||
|
||||
private var importButton: some View {
|
||||
Button {
|
||||
showingContactPicker = true
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(theme.accent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(importedName == nil ? "Aus Kontakten auswählen" : "Anderen Kontakt wählen")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(theme.accent)
|
||||
if let imported = importedName {
|
||||
Text("Felder aus \"\(imported)\" übernommen")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
} else {
|
||||
Text("Felder werden automatisch ausgefüllt")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background(theme.accent.opacity(0.07))
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||
.stroke(theme.accent.opacity(0.20), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact Mapping
|
||||
|
||||
private func applyContact(_ contact: CNContact) {
|
||||
let imported = ContactImport.from(contact)
|
||||
|
||||
if !imported.name.isEmpty {
|
||||
name = imported.name
|
||||
importedName = imported.name
|
||||
}
|
||||
if !imported.occupation.isEmpty {
|
||||
occupation = imported.occupation
|
||||
}
|
||||
if !imported.location.isEmpty {
|
||||
location = imported.location
|
||||
}
|
||||
if let bday = imported.birthday {
|
||||
birthday = bday
|
||||
hasBirthday = true
|
||||
}
|
||||
if let data = imported.photoData {
|
||||
selectedPhoto = UIImage(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@ViewBuilder
|
||||
private func formSection<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inlineField(_ label: String, text: Binding<String>) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(label)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
TextField(label, text: text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.tint(theme.accent)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private func loadExisting() {
|
||||
guard let p = existingPerson else { return }
|
||||
name = p.name
|
||||
selectedTag = p.tag
|
||||
occupation = p.occupation ?? ""
|
||||
location = p.location ?? ""
|
||||
interests = p.interests ?? ""
|
||||
generalNotes = p.generalNotes ?? ""
|
||||
hasBirthday = p.birthday != nil
|
||||
birthday = p.birthday ?? Date()
|
||||
nudgeFrequency = p.nudgeFrequency
|
||||
if let data = p.photoData {
|
||||
selectedPhoto = UIImage(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let photoData = selectedPhoto?.jpegData(compressionQuality: 0.8)
|
||||
|
||||
if let p = existingPerson {
|
||||
p.name = trimmed
|
||||
p.tag = selectedTag
|
||||
p.occupation = occupation.isEmpty ? nil : occupation
|
||||
p.location = location.isEmpty ? nil : location
|
||||
p.interests = interests.isEmpty ? nil : interests
|
||||
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
||||
p.birthday = hasBirthday ? birthday : nil
|
||||
p.nudgeFrequency = nudgeFrequency
|
||||
p.photoData = photoData
|
||||
} else {
|
||||
let person = Person(
|
||||
name: trimmed,
|
||||
tag: selectedTag,
|
||||
birthday: hasBirthday ? birthday : nil,
|
||||
occupation: occupation.isEmpty ? nil : occupation,
|
||||
location: location.isEmpty ? nil : location,
|
||||
interests: interests.isEmpty ? nil : interests,
|
||||
generalNotes: generalNotes.isEmpty ? nil : generalNotes,
|
||||
nudgeFrequency: nudgeFrequency
|
||||
)
|
||||
person.photoData = photoData
|
||||
modelContext.insert(person)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func deletePerson() {
|
||||
guard let p = existingPerson else { return }
|
||||
modelContext.delete(p)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppLockSetupView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var lockManager: AppLockManager
|
||||
|
||||
/// true = user wants to disable or change PIN (must verify old PIN first if changing)
|
||||
let isDisabling: Bool
|
||||
|
||||
@State private var step: Step = .first
|
||||
@State private var firstPIN = ""
|
||||
@State private var entered = ""
|
||||
@State private var errorMessage = ""
|
||||
@State private var showError = false
|
||||
@State private var errorOffset: CGFloat = 0
|
||||
|
||||
private let pinLength = 6
|
||||
|
||||
enum Step { case first, confirm }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Close
|
||||
HStack {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 15, weight: .light))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 44)
|
||||
|
||||
// PIN dots
|
||||
HStack(spacing: 20) {
|
||||
ForEach(0..<pinLength, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i < entered.count ? theme.accent : Color.clear)
|
||||
.frame(width: 14, height: 14)
|
||||
.overlay(
|
||||
Circle().stroke(
|
||||
i < entered.count ? theme.accent : theme.contentTertiary,
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.offset(x: errorOffset)
|
||||
|
||||
Spacer().frame(height: 14)
|
||||
|
||||
Text(showError ? errorMessage : " ")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.red.opacity(0.8))
|
||||
.animation(.easeInOut(duration: 0.2), value: showError)
|
||||
|
||||
Spacer()
|
||||
|
||||
PINPadView { key in handleKey(key) }
|
||||
.padding(.horizontal, 44)
|
||||
|
||||
Spacer().frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Labels
|
||||
|
||||
private var title: String {
|
||||
if isDisabling { return "Code eingeben" }
|
||||
return step == .first ? "Code festlegen" : "Code bestätigen"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if isDisabling { return "Gib deinen Code ein, um den Schutz zu deaktivieren" }
|
||||
return step == .first
|
||||
? "Wähle einen 6-stelligen Code"
|
||||
: "Gib den Code zur Bestätigung nochmal ein"
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private func handleKey(_ key: PINKey) {
|
||||
switch key {
|
||||
case .digit(let d):
|
||||
guard entered.count < pinLength else { return }
|
||||
entered.append(d)
|
||||
if entered.count == pinLength { advance() }
|
||||
case .delete:
|
||||
if !entered.isEmpty { entered.removeLast() }
|
||||
}
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
if isDisabling {
|
||||
if lockManager.verifyPIN(entered) {
|
||||
lockManager.deletePIN()
|
||||
lockManager.isEnabled = false
|
||||
dismiss()
|
||||
} else {
|
||||
shake("Falscher Code")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch step {
|
||||
case .first:
|
||||
firstPIN = entered
|
||||
entered = ""
|
||||
withAnimation(.easeInOut(duration: 0.2)) { step = .confirm }
|
||||
case .confirm:
|
||||
if entered == firstPIN {
|
||||
lockManager.setPIN(entered)
|
||||
lockManager.isEnabled = true
|
||||
dismiss()
|
||||
} else {
|
||||
shake("Codes stimmen nicht überein")
|
||||
// Reset to step 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { step = .first }
|
||||
firstPIN = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shake feedback
|
||||
|
||||
private func shake(_ message: String) {
|
||||
errorMessage = message
|
||||
showError = true
|
||||
entered = ""
|
||||
let pattern: [(CGFloat, Double)] = [(10,0), (-10,0.07), (6,0.14), (-4,0.21), (0,0.28)]
|
||||
for (offset, delay) in pattern {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
withAnimation(.easeInOut(duration: 0.07)) { errorOffset = offset }
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation { showError = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
// MARK: - Lock Screen
|
||||
|
||||
struct AppLockView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@EnvironmentObject var lockManager: AppLockManager
|
||||
|
||||
@State private var entered = ""
|
||||
@State private var showError = false
|
||||
@State private var errorOffset: CGFloat = 0
|
||||
|
||||
private let pinLength = 6
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Title
|
||||
VStack(spacing: 6) {
|
||||
Text("nahbar")
|
||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text("Code eingeben")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 44)
|
||||
|
||||
// PIN dots
|
||||
HStack(spacing: 20) {
|
||||
ForEach(0..<pinLength, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i < entered.count ? theme.accent : Color.clear)
|
||||
.frame(width: 14, height: 14)
|
||||
.overlay(
|
||||
Circle().stroke(
|
||||
i < entered.count ? theme.accent : theme.contentTertiary,
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.offset(x: errorOffset)
|
||||
|
||||
Spacer().frame(height: 14)
|
||||
|
||||
// Error message — fixed height to avoid layout shift
|
||||
Text(showError ? "Falscher Code" : " ")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.red.opacity(0.8))
|
||||
.animation(.easeInOut(duration: 0.2), value: showError)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Number pad
|
||||
PINPadView { key in handleKey(key) }
|
||||
.padding(.horizontal, 44)
|
||||
|
||||
Spacer().frame(height: 24)
|
||||
|
||||
// Biometric button
|
||||
if lockManager.biometricType != .none {
|
||||
Button { tryBiometric() } label: {
|
||||
Image(systemName: lockManager.biometricType == .faceID ? "faceid" : "touchid")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
.padding(.bottom, 44)
|
||||
} else {
|
||||
Spacer().frame(height: 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { tryBiometric() }
|
||||
}
|
||||
|
||||
private func handleKey(_ key: PINKey) {
|
||||
switch key {
|
||||
case .digit(let d):
|
||||
guard entered.count < pinLength else { return }
|
||||
entered.append(d)
|
||||
if entered.count == pinLength { verify() }
|
||||
case .delete:
|
||||
if !entered.isEmpty { entered.removeLast() }
|
||||
}
|
||||
}
|
||||
|
||||
private func verify() {
|
||||
if lockManager.verifyPIN(entered) {
|
||||
lockManager.unlock()
|
||||
} else {
|
||||
entered = ""
|
||||
animateShake()
|
||||
showError = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation { showError = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tryBiometric() {
|
||||
lockManager.authenticateWithBiometrics { success in
|
||||
if success { lockManager.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
private func animateShake() {
|
||||
let pattern: [(CGFloat, Double)] = [(10,0), (-10,0.07), (6,0.14), (-4,0.21), (0,0.28)]
|
||||
for (offset, delay) in pattern {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
withAnimation(.easeInOut(duration: 0.07)) { errorOffset = offset }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PIN Key Model
|
||||
|
||||
enum PINKey {
|
||||
case digit(Character)
|
||||
case delete
|
||||
}
|
||||
|
||||
// MARK: - PIN Pad
|
||||
|
||||
struct PINPadView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
let onKey: (PINKey) -> Void
|
||||
|
||||
private let rows: [[String]] = [
|
||||
["1", "2", "3"],
|
||||
["4", "5", "6"],
|
||||
["7", "8", "9"],
|
||||
["", "0", "⌫"]
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(rows, id: \.self) { row in
|
||||
HStack(spacing: 18) {
|
||||
ForEach(row, id: \.self) { key in
|
||||
pinButton(for: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func pinButton(for key: String) -> some View {
|
||||
if key.isEmpty {
|
||||
Color.clear.frame(width: 80, height: 80)
|
||||
} else if key == "⌫" {
|
||||
Button { onKey(.delete) } label: {
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .light))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.frame(width: 80, height: 80)
|
||||
}
|
||||
} else {
|
||||
Button { onKey(.digit(key.first!)) } label: {
|
||||
Text(key)
|
||||
.font(.system(size: 28, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.frame(width: 80, height: 80)
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(theme.borderSubtle, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import UserNotifications
|
||||
|
||||
class CallWindowManager: ObservableObject {
|
||||
static let shared = CallWindowManager()
|
||||
|
||||
@Published var isEnabled: Bool { didSet { ud.set(isEnabled, forKey: K.enabled) } }
|
||||
@Published var startHour: Int { didSet { ud.set(startHour, forKey: K.startHour) } }
|
||||
@Published var startMinute: Int { didSet { ud.set(startMinute, forKey: K.startMinute) } }
|
||||
@Published var endHour: Int { didSet { ud.set(endHour, forKey: K.endHour) } }
|
||||
@Published var endMinute: Int { didSet { ud.set(endMinute, forKey: K.endMinute) } }
|
||||
@Published var selectedWeekdays: Set<Int> {
|
||||
didSet {
|
||||
let data = (try? JSONEncoder().encode(Array(selectedWeekdays))) ?? Data()
|
||||
ud.set(data, forKey: K.weekdays)
|
||||
}
|
||||
}
|
||||
|
||||
private let ud = UserDefaults.standard
|
||||
|
||||
private enum K {
|
||||
static let enabled = "cwEnabled"
|
||||
static let startHour = "cwStartHour"
|
||||
static let startMinute = "cwStartMinute"
|
||||
static let endHour = "cwEndHour"
|
||||
static let endMinute = "cwEndMinute"
|
||||
static let weekdays = "cwWeekdays"
|
||||
}
|
||||
|
||||
// Calendar weekdays in German display order (Calendar: 1=So, 2=Mo, ..., 7=Sa)
|
||||
static let weekdayOrder: [(Int, String)] = [
|
||||
(2, "Mo"), (3, "Di"), (4, "Mi"), (5, "Do"), (6, "Fr"), (7, "Sa"), (1, "So")
|
||||
]
|
||||
|
||||
private init() {
|
||||
isEnabled = ud.bool(forKey: K.enabled)
|
||||
startHour = ud.object(forKey: K.startHour) as? Int ?? 17
|
||||
startMinute = ud.object(forKey: K.startMinute) as? Int ?? 0
|
||||
endHour = ud.object(forKey: K.endHour) as? Int ?? 18
|
||||
endMinute = ud.object(forKey: K.endMinute) as? Int ?? 0
|
||||
|
||||
if let data = ud.data(forKey: K.weekdays),
|
||||
let days = try? JSONDecoder().decode([Int].self, from: data) {
|
||||
selectedWeekdays = Set(days)
|
||||
} else {
|
||||
selectedWeekdays = [2, 3, 4, 5, 6] // 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Enums
|
||||
|
||||
enum PersonTag: String, CaseIterable, Codable {
|
||||
case family = "Familie"
|
||||
case friends = "Freunde"
|
||||
case work = "Arbeit"
|
||||
case community = "Community"
|
||||
case other = "Andere"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .family: return "house"
|
||||
case .friends: return "person.2"
|
||||
case .work: return "briefcase"
|
||||
case .community: return "person.3"
|
||||
case .other: return "tag"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NudgeFrequency: String, CaseIterable, Codable {
|
||||
case never = "Nie"
|
||||
case weekly = "Wöchentlich"
|
||||
case biweekly = "2 Wochen"
|
||||
case monthly = "Monatlich"
|
||||
case quarterly = "Quartalsweise"
|
||||
|
||||
var days: Int? {
|
||||
switch self {
|
||||
case .never: return nil
|
||||
case .weekly: return 7
|
||||
case .biweekly: return 14
|
||||
case .monthly: return 30
|
||||
case .quarterly: return 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MomentType: String, CaseIterable, Codable {
|
||||
case conversation = "Gespräch"
|
||||
case meeting = "Treffen"
|
||||
case thought = "Gedanke"
|
||||
case intention = "Vorhaben"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .conversation: return "bubble.left"
|
||||
case .meeting: return "person.2"
|
||||
case .thought: return "lightbulb"
|
||||
case .intention: return "arrow.right.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Person
|
||||
|
||||
@Model
|
||||
class Person {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var tagRaw: String
|
||||
var birthday: Date?
|
||||
var occupation: String?
|
||||
var location: String?
|
||||
var interests: String?
|
||||
var generalNotes: String?
|
||||
var nudgeFrequencyRaw: String
|
||||
var photoData: Data?
|
||||
var nextStep: String?
|
||||
var nextStepCompleted: Bool
|
||||
var nextStepReminderDate: Date?
|
||||
var lastSuggestedForCall: Date?
|
||||
var createdAt: Date
|
||||
@Relationship(deleteRule: .cascade) var moments: [Moment]
|
||||
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]
|
||||
|
||||
init(
|
||||
name: String,
|
||||
tag: PersonTag = .other,
|
||||
birthday: Date? = nil,
|
||||
occupation: String? = nil,
|
||||
location: String? = nil,
|
||||
interests: String? = nil,
|
||||
generalNotes: String? = nil,
|
||||
nudgeFrequency: NudgeFrequency = .monthly
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.name = name
|
||||
self.tagRaw = tag.rawValue
|
||||
self.birthday = birthday
|
||||
self.occupation = occupation
|
||||
self.location = location
|
||||
self.interests = interests
|
||||
self.generalNotes = generalNotes
|
||||
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
||||
self.photoData = nil
|
||||
self.nextStep = nil
|
||||
self.nextStepCompleted = false
|
||||
self.nextStepReminderDate = nil
|
||||
self.lastSuggestedForCall = nil
|
||||
self.createdAt = Date()
|
||||
self.moments = []
|
||||
self.logEntries = []
|
||||
}
|
||||
|
||||
var tag: PersonTag {
|
||||
get { PersonTag(rawValue: tagRaw) ?? .other }
|
||||
set { tagRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var nudgeFrequency: NudgeFrequency {
|
||||
get { NudgeFrequency(rawValue: nudgeFrequencyRaw) ?? .monthly }
|
||||
set { nudgeFrequencyRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var lastMomentDate: Date? {
|
||||
moments.sorted { $0.createdAt > $1.createdAt }.first?.createdAt
|
||||
}
|
||||
|
||||
var needsAttention: Bool {
|
||||
guard let days = nudgeFrequency.days else { return false }
|
||||
if let last = lastMomentDate {
|
||||
return Date().timeIntervalSince(last) > Double(days * 86400)
|
||||
}
|
||||
// Never had a moment – show if added more than `days` ago
|
||||
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
|
||||
}
|
||||
|
||||
func hasBirthdayWithin(days: Int) -> Bool {
|
||||
guard let birthday else { return false }
|
||||
let cal = Calendar.current
|
||||
let bdc = cal.dateComponents([.month, .day], from: birthday)
|
||||
guard let bMonth = bdc.month, let bDay = bdc.day else { return false }
|
||||
for offset in 0..<days {
|
||||
if let date = cal.date(byAdding: .day, value: offset, to: Date()) {
|
||||
let dc = cal.dateComponents([.month, .day], from: date)
|
||||
if dc.month == bMonth && dc.day == bDay { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var initials: String {
|
||||
let parts = name.split(separator: " ")
|
||||
if parts.count >= 2 {
|
||||
return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased()
|
||||
}
|
||||
return String(name.prefix(2)).uppercased()
|
||||
}
|
||||
|
||||
var firstName: String {
|
||||
String(name.split(separator: " ").first ?? Substring(name))
|
||||
}
|
||||
|
||||
var sortedMoments: [Moment] {
|
||||
moments.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var sortedLogEntries: [LogEntry] {
|
||||
logEntries.sorted { $0.loggedAt > $1.loggedAt }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LogEntryType
|
||||
|
||||
enum LogEntryType: String, Codable {
|
||||
case nextStep = "Schritt abgeschlossen"
|
||||
case calendarEvent = "Termin geplant"
|
||||
case call = "Anruf"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .nextStep: return "checkmark.circle.fill"
|
||||
case .calendarEvent: return "calendar.badge.checkmark"
|
||||
case .call: return "phone.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String { // used for tinting in the view
|
||||
switch self {
|
||||
case .nextStep: return "green"
|
||||
case .calendarEvent: return "blue"
|
||||
case .call: return "accent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LogEntry
|
||||
|
||||
@Model
|
||||
class LogEntry {
|
||||
var id: UUID
|
||||
var typeRaw: String
|
||||
var title: String
|
||||
var loggedAt: Date
|
||||
var person: Person?
|
||||
|
||||
init(type: LogEntryType, title: String, person: Person? = nil) {
|
||||
self.id = UUID()
|
||||
self.typeRaw = type.rawValue
|
||||
self.title = title
|
||||
self.loggedAt = Date()
|
||||
self.person = person
|
||||
}
|
||||
|
||||
var type: LogEntryType {
|
||||
get { LogEntryType(rawValue: typeRaw) ?? .nextStep }
|
||||
set { typeRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Moment
|
||||
|
||||
@Model
|
||||
class Moment {
|
||||
var id: UUID
|
||||
var text: String
|
||||
var typeRaw: String
|
||||
var createdAt: Date
|
||||
var person: Person?
|
||||
|
||||
init(text: String, type: MomentType = .conversation, person: Person? = nil) {
|
||||
self.id = UUID()
|
||||
self.text = text
|
||||
self.typeRaw = type.rawValue
|
||||
self.createdAt = Date()
|
||||
self.person = person
|
||||
}
|
||||
|
||||
var type: MomentType {
|
||||
get { MomentType(rawValue: typeRaw) ?? .conversation }
|
||||
set { typeRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TodayView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@Query private var people: [Person]
|
||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||
|
||||
private var needsAttention: [Person] {
|
||||
people
|
||||
.filter { $0.needsAttention }
|
||||
.sorted { ($0.lastMomentDate ?? $0.createdAt) < ($1.lastMomentDate ?? $1.createdAt) }
|
||||
}
|
||||
|
||||
private var birthdayPeople: [Person] {
|
||||
people.filter { $0.hasBirthdayWithin(days: daysAhead) }
|
||||
}
|
||||
|
||||
// People with a scheduled reminder within the look-ahead window
|
||||
private var upcomingReminders: [Person] {
|
||||
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: Date()) ?? Date()
|
||||
return people
|
||||
.filter { p in
|
||||
guard let reminder = p.nextStepReminderDate, !p.nextStepCompleted else { return false }
|
||||
return reminder >= Date() && reminder <= horizon
|
||||
}
|
||||
.sorted { ($0.nextStepReminderDate ?? Date()) < ($1.nextStepReminderDate ?? Date()) }
|
||||
}
|
||||
|
||||
// Open next steps NOT already shown under upcoming reminders
|
||||
private var openNextSteps: [Person] {
|
||||
let scheduledIDs = Set(upcomingReminders.map { $0.id })
|
||||
return people.filter { p in
|
||||
p.nextStep != nil && !p.nextStepCompleted && !scheduledIDs.contains(p.id)
|
||||
}
|
||||
}
|
||||
|
||||
private var isEmpty: Bool {
|
||||
needsAttention.isEmpty && birthdayPeople.isEmpty && openNextSteps.isEmpty && upcomingReminders.isEmpty
|
||||
}
|
||||
|
||||
private var birthdaySectionTitle: String {
|
||||
switch daysAhead {
|
||||
case 3: return "In 3 Tagen"
|
||||
case 7: return "Diese Woche"
|
||||
case 14: return "Nächste 2 Wochen"
|
||||
case 30: return "Nächster Monat"
|
||||
default: return "Nächste \(daysAhead) Tage"
|
||||
}
|
||||
}
|
||||
|
||||
private var greeting: String {
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
if hour < 12 { return "Guten Morgen." }
|
||||
if hour < 18 { return "Guten Tag." }
|
||||
return "Guten Abend."
|
||||
}
|
||||
|
||||
private var formattedToday: String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "de_DE")
|
||||
fmt.dateFormat = "EEEE, d. MMMM"
|
||||
return fmt.string(from: Date())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 32) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(greeting)
|
||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text(formattedToday)
|
||||
.font(.system(size: 15, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
if isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
VStack(spacing: 24) {
|
||||
if !birthdayPeople.isEmpty {
|
||||
TodaySection(title: birthdaySectionTitle, icon: "gift") {
|
||||
ForEach(birthdayPeople) { person in
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: birthdayHint(for: person))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if person.id != birthdayPeople.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !upcomingReminders.isEmpty {
|
||||
TodaySection(title: "Anstehende Termine", icon: "calendar") {
|
||||
ForEach(upcomingReminders) { person in
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: reminderHint(for: person))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if person.id != upcomingReminders.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !openNextSteps.isEmpty {
|
||||
TodaySection(title: "Offene Schritte", icon: "arrow.right.circle") {
|
||||
ForEach(openNextSteps) { person in
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: person.nextStep ?? "")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if person.id != openNextSteps.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !needsAttention.isEmpty {
|
||||
TodaySection(title: "Schon eine Weile her", icon: "clock") {
|
||||
ForEach(needsAttention.prefix(5)) { person in
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: lastSeenHint(for: person))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if person.id != Array(needsAttention.prefix(5)).last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 10) {
|
||||
Spacer().frame(height: 48)
|
||||
Text("Ein ruhiger Tag.")
|
||||
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text("Oder einer, der es noch wird.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Hints
|
||||
|
||||
private func birthdayHint(for person: Person) -> String {
|
||||
guard let birthday = person.birthday else { return "" }
|
||||
let cal = Calendar.current
|
||||
let bdc = cal.dateComponents([.month, .day], from: birthday)
|
||||
for offset in 0..<7 {
|
||||
if let date = cal.date(byAdding: .day, value: offset, to: Date()) {
|
||||
let dc = cal.dateComponents([.month, .day], from: date)
|
||||
if dc.month == bdc.month && dc.day == bdc.day {
|
||||
if offset == 0 { return "Heute Geburtstag 🎂" }
|
||||
if offset == 1 { return "Morgen Geburtstag" }
|
||||
return "In \(offset) Tagen Geburtstag"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func reminderHint(for person: Person) -> String {
|
||||
guard let reminder = person.nextStepReminderDate else { return person.nextStep ?? "" }
|
||||
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE")))
|
||||
return "\(person.nextStep ?? "") · \(dateStr)"
|
||||
}
|
||||
|
||||
private func lastSeenHint(for person: Person) -> String {
|
||||
guard let last = person.lastMomentDate else { return "Noch keine Momente festgehalten" }
|
||||
let days = Int(Date().timeIntervalSince(last) / 86400)
|
||||
if days == 0 { return "Heute" }
|
||||
if days == 1 { return "Gestern" }
|
||||
if days < 7 { return "Vor \(days) Tagen" }
|
||||
if days < 30 { return "Vor \(days / 7) Woche\(days / 7 == 1 ? "" : "n")" }
|
||||
let months = days / 30
|
||||
return "Vor \(months) Monat\(months == 1 ? "" : "en")"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today Section
|
||||
|
||||
struct TodaySection<Content: View>: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(title: title, icon: icon)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today Row
|
||||
|
||||
struct TodayRow: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
let person: Person
|
||||
let hint: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
PersonAvatar(person: person, size: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(person.name)
|
||||
.font(.system(size: 15, weight: .medium, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
if !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user