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