AI, Geburtstag, Abo
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -7,6 +7,16 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
26BB85B92F9248BD00889312 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85B82F9248BD00889312 /* SplashView.swift */; };
|
||||
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; };
|
||||
26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; };
|
||||
26BB85BF2F924E3D00889312 /* profeatures.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 26BB85BE2F924E3D00889312 /* profeatures.storekit */; };
|
||||
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85C02F92525200889312 /* AIAnalysisService.swift */; };
|
||||
26BB85C32F92586600889312 /* AIConfiguration.json in Resources */ = {isa = PBXBuildFile; fileRef = 26BB85C22F92586600889312 /* AIConfiguration.json */; };
|
||||
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85C42F926A1C00889312 /* AppGroup.swift */; };
|
||||
26BB85D42F926A9700889312 /* nahbarShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
26BB85DE2F926CAB00889312 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; };
|
||||
26BB85DF2F926CC500889312 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85C42F926A1C00889312 /* AppGroup.swift */; };
|
||||
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 */; };
|
||||
@@ -30,8 +40,41 @@
|
||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664D2F91514B00824F91 /* ThemePickerView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
26BB85D22F926A9700889312 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 265F92182F9109B500CE0A5C /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 26BB85C92F926A9700889312;
|
||||
remoteInfo = nahbarShareExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
26BB85D92F926A9700889312 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
26BB85D42F926A9700889312 /* nahbarShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
|
||||
26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
|
||||
26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
|
||||
26BB85BE2F924E3D00889312 /* profeatures.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = profeatures.storekit; sourceTree = "<group>"; };
|
||||
26BB85C02F92525200889312 /* AIAnalysisService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAnalysisService.swift; sourceTree = "<group>"; };
|
||||
26BB85C22F92586600889312 /* AIConfiguration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = AIConfiguration.json; sourceTree = "<group>"; };
|
||||
26BB85C42F926A1C00889312 /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
|
||||
26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = nahbarShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
26BB85E02F926D8E00889312 /* nahbar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nahbar.entitlements; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -55,6 +98,27 @@
|
||||
26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
26BB85D52F926A9700889312 /* Exceptions for "nahbarShareExtension" folder in "nahbarShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 26BB85C92F926A9700889312 /* nahbarShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
26BB85CB2F926A9700889312 /* nahbarShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
26BB85D52F926A9700889312 /* Exceptions for "nahbarShareExtension" folder in "nahbarShareExtension" target */,
|
||||
);
|
||||
path = nahbarShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
265F921D2F9109B500CE0A5C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
@@ -63,13 +127,22 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
26BB85C72F926A9700889312 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
265F92172F9109B500CE0A5C = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
26BB85BE2F924E3D00889312 /* profeatures.storekit */,
|
||||
26EF66302F9112E700824F91 /* nahbar */,
|
||||
26BB85CB2F926A9700889312 /* nahbarShareExtension */,
|
||||
265F92212F9109B500CE0A5C /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -78,6 +151,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
265F92202F9109B500CE0A5C /* nahbar.app */,
|
||||
26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -85,6 +159,7 @@
|
||||
26EF66302F9112E700824F91 /* nahbar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
26BB85E02F926D8E00889312 /* nahbar.entitlements */,
|
||||
26EF66232F9112E700824F91 /* AddMomentView.swift */,
|
||||
26EF66242F9112E700824F91 /* AddPersonView.swift */,
|
||||
26EF66252F9112E700824F91 /* Assets.xcassets */,
|
||||
@@ -106,6 +181,12 @@
|
||||
26EF66482F91352D00824F91 /* AppLockSetupView.swift */,
|
||||
26EF664A2F913C8600824F91 /* LogbuchView.swift */,
|
||||
26EF664D2F91514B00824F91 /* ThemePickerView.swift */,
|
||||
26BB85B82F9248BD00889312 /* SplashView.swift */,
|
||||
26BB85BA2F924D9B00889312 /* StoreManager.swift */,
|
||||
26BB85BC2F924DB100889312 /* PaywallView.swift */,
|
||||
26BB85C02F92525200889312 /* AIAnalysisService.swift */,
|
||||
26BB85C22F92586600889312 /* AIConfiguration.json */,
|
||||
26BB85C42F926A1C00889312 /* AppGroup.swift */,
|
||||
);
|
||||
path = nahbar;
|
||||
sourceTree = "<group>";
|
||||
@@ -120,10 +201,12 @@
|
||||
265F921C2F9109B500CE0A5C /* Sources */,
|
||||
265F921D2F9109B500CE0A5C /* Frameworks */,
|
||||
265F921E2F9109B500CE0A5C /* Resources */,
|
||||
26BB85D92F926A9700889312 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
26BB85D32F926A9700889312 /* PBXTargetDependency */,
|
||||
);
|
||||
name = nahbar;
|
||||
packageProductDependencies = (
|
||||
@@ -132,6 +215,28 @@
|
||||
productReference = 265F92202F9109B500CE0A5C /* nahbar.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
26BB85C92F926A9700889312 /* nahbarShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 26BB85D62F926A9700889312 /* Build configuration list for PBXNativeTarget "nahbarShareExtension" */;
|
||||
buildPhases = (
|
||||
26BB85C62F926A9700889312 /* Sources */,
|
||||
26BB85C72F926A9700889312 /* Frameworks */,
|
||||
26BB85C82F926A9700889312 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
26BB85CB2F926A9700889312 /* nahbarShareExtension */,
|
||||
);
|
||||
name = nahbarShareExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = nahbarShareExtension;
|
||||
productReference = 26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -145,6 +250,9 @@
|
||||
265F921F2F9109B500CE0A5C = {
|
||||
CreatedOnToolsVersion = 26.4;
|
||||
};
|
||||
26BB85C92F926A9700889312 = {
|
||||
CreatedOnToolsVersion = 26.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 265F921B2F9109B500CE0A5C /* Build configuration list for PBXProject "nahbar" */;
|
||||
@@ -162,6 +270,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
265F921F2F9109B500CE0A5C /* nahbar */,
|
||||
26BB85C92F926A9700889312 /* nahbarShareExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -171,7 +280,16 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
26BB85C32F92586600889312 /* AIConfiguration.json in Resources */,
|
||||
26EF66312F9112E700824F91 /* Assets.xcassets in Resources */,
|
||||
26BB85BF2F924E3D00889312 /* profeatures.storekit in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
26BB85C82F926A9700889312 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -185,6 +303,7 @@
|
||||
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
||||
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
|
||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
|
||||
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
|
||||
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
|
||||
@@ -192,21 +311,42 @@
|
||||
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
|
||||
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
|
||||
26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */,
|
||||
26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */,
|
||||
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
|
||||
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
|
||||
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
|
||||
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
|
||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
||||
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */,
|
||||
26BB85C12F92525200889312 /* AIAnalysisService.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 */,
|
||||
26BB85B92F9248BD00889312 /* SplashView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
26BB85C62F926A9700889312 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
26BB85DE2F926CAB00889312 /* Models.swift in Sources */,
|
||||
26BB85DF2F926CC500889312 /* AppGroup.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
26BB85D32F926A9700889312 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 26BB85C92F926A9700889312 /* nahbarShareExtension */;
|
||||
targetProxy = 26BB85D22F926A9700889312 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
265F92292F9109B600CE0A5C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -334,6 +474,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = nahbar/nahbar.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
@@ -372,6 +513,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = nahbar/nahbar.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
@@ -405,6 +547,64 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
26BB85D72F926A9700889312 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = nahbarShareExtension/nahbarShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = nahbarShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = nahbarShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.nahbar.nahbarShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
26BB85D82F926A9700889312 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = nahbarShareExtension/nahbarShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = nahbarShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = nahbarShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.nahbar.nahbarShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -426,6 +626,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
26BB85D62F926A9700889312 /* Build configuration list for PBXNativeTarget "nahbarShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
26BB85D72F926A9700889312 /* Debug */,
|
||||
26BB85D82F926A9700889312 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 265F92182F9109B500CE0A5C /* Project object */;
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "265F921F2F9109B500CE0A5C"
|
||||
BuildableName = "nahbar.app"
|
||||
BlueprintName = "nahbar"
|
||||
ReferencedContainer = "container:nahbar.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "265F921F2F9109B500CE0A5C"
|
||||
BuildableName = "nahbar.app"
|
||||
BlueprintName = "nahbar"
|
||||
ReferencedContainer = "container:nahbar.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../profeatures.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "265F921F2F9109B500CE0A5C"
|
||||
BuildableName = "nahbar.app"
|
||||
BlueprintName = "nahbar"
|
||||
ReferencedContainer = "container:nahbar.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
+13
@@ -9,11 +9,24 @@
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>nahbarShareExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>relationz.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>265F921F2F9109B500CE0A5C</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
struct AIConfig: Decodable {
|
||||
let baseURL: String
|
||||
let completionsPath: String // z.B. "/v1/chat/completions" oder "/api/chat/completions"
|
||||
let apiKey: String
|
||||
let model: String
|
||||
let timeoutSeconds: Double
|
||||
let rateLimitPerHour: Int
|
||||
let systemPrompt: String
|
||||
let userPromptTemplate: String
|
||||
let giftPromptTemplate: String
|
||||
|
||||
var completionsURL: String { baseURL + completionsPath }
|
||||
|
||||
// Fallback-Werte falls die JSON-Datei nicht geladen werden kann
|
||||
static let fallback = AIConfig(
|
||||
baseURL: "https://api.mistral.ai",
|
||||
completionsPath: "/v1/chat/completions",
|
||||
apiKey: "zyXgjAmQl8WwVV0qoe8rSNajqrwK6QXQ",
|
||||
model: "mistral-small-latest",
|
||||
timeoutSeconds: 60,
|
||||
rateLimitPerHour: 3,
|
||||
systemPrompt: "Du bist ein einfühlsamer Assistent für persönliche Beziehungspflege. Antworte ausschließlich auf Deutsch. Sei prägnant, warm und direkt. Strukturiere deine Antwort exakt wie verlangt.",
|
||||
userPromptTemplate: "Person: {{personName}}\n{{birthday}}{{interests}}\n{{moments}}\n{{logEntries}}\nAnalysiere diese Beziehung und antworte exakt in diesem Format:\n\nMUSTER: [2-3 Sätze]\nBEZIEHUNG: [2-3 Sätze]\nEMPFEHLUNG: [1 konkreter Schritt]",
|
||||
giftPromptTemplate: "Person: {{personName}}\n{{birthday}}{{interests}}\n{{moments}}\nSchlage 3 Geschenkideen vor. Antworte exakt in diesem Format:\n\nIDEE 1: [Idee – Begründung]\nIDEE 2: [Idee – Begründung]\nIDEE 3: [Idee – Begründung]"
|
||||
)
|
||||
|
||||
static func load() -> AIConfig {
|
||||
// Laufzeit-Overrides aus UserDefaults haben höchste Priorität
|
||||
let ud = UserDefaults.standard
|
||||
let base = loadFromBundle()
|
||||
return AIConfig(
|
||||
baseURL: ud.string(forKey: "aiBaseURL") ?? base.baseURL,
|
||||
completionsPath: base.completionsPath,
|
||||
apiKey: ud.string(forKey: "aiAPIKey") ?? base.apiKey,
|
||||
model: ud.string(forKey: "aiModel") ?? base.model,
|
||||
timeoutSeconds: base.timeoutSeconds,
|
||||
rateLimitPerHour: base.rateLimitPerHour,
|
||||
systemPrompt: base.systemPrompt,
|
||||
userPromptTemplate: base.userPromptTemplate,
|
||||
giftPromptTemplate: base.giftPromptTemplate
|
||||
)
|
||||
}
|
||||
|
||||
private static func loadFromBundle() -> AIConfig {
|
||||
guard
|
||||
let url = Bundle.main.url(forResource: "AIConfiguration", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url)
|
||||
else { return .fallback }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
return (try? decoder.decode(AIConfig.self, from: data)) ?? .fallback
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Analysis Result
|
||||
|
||||
struct AIAnalysisResult {
|
||||
let patterns: String
|
||||
let relationship: String
|
||||
let recommendation: String
|
||||
}
|
||||
|
||||
// MARK: - Cached Analysis
|
||||
|
||||
struct CachedAnalysis: Codable {
|
||||
let patterns: String
|
||||
let relationship: String
|
||||
let recommendation: String
|
||||
let analyzedAt: Date
|
||||
|
||||
var asResult: AIAnalysisResult {
|
||||
AIAnalysisResult(patterns: patterns, relationship: relationship, recommendation: recommendation)
|
||||
}
|
||||
|
||||
init(result: AIAnalysisResult, date: Date = Date()) {
|
||||
self.patterns = result.patterns
|
||||
self.relationship = result.relationship
|
||||
self.recommendation = result.recommendation
|
||||
self.analyzedAt = date
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cached Gift Suggestion
|
||||
|
||||
struct CachedGiftSuggestion: Codable {
|
||||
let text: String
|
||||
let generatedAt: Date
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
class AIAnalysisService {
|
||||
static let shared = AIAnalysisService()
|
||||
private init() {}
|
||||
|
||||
// MARK: - Rate Limiting (max 3 pro Stunde)
|
||||
|
||||
private var maxRequestsPerHour: Int { AIConfig.load().rateLimitPerHour }
|
||||
private let rateLimitKey = "ai_rate_timestamps"
|
||||
|
||||
var remainingRequests: Int {
|
||||
let recent = recentTimestamps()
|
||||
return max(0, maxRequestsPerHour - recent.count)
|
||||
}
|
||||
|
||||
var isRateLimited: Bool { remainingRequests == 0 }
|
||||
|
||||
var rateLimitResetsAt: Date? {
|
||||
let timestamps = recentTimestamps()
|
||||
guard timestamps.count >= maxRequestsPerHour,
|
||||
let oldest = timestamps.min()
|
||||
else { return nil }
|
||||
return Date(timeIntervalSince1970: oldest + 3600)
|
||||
}
|
||||
|
||||
private func recentTimestamps() -> [Double] {
|
||||
let all = UserDefaults.standard.array(forKey: rateLimitKey) as? [Double] ?? []
|
||||
let cutoff = Date().timeIntervalSince1970 - 3600
|
||||
return all.filter { $0 > cutoff }
|
||||
}
|
||||
|
||||
private func recordRequest() {
|
||||
var recent = recentTimestamps()
|
||||
recent.append(Date().timeIntervalSince1970)
|
||||
UserDefaults.standard.set(recent, forKey: rateLimitKey)
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
private func cacheKey(for person: Person) -> String { "ai_cache_\(person.id.uuidString)" }
|
||||
|
||||
func loadCached(for person: Person) -> CachedAnalysis? {
|
||||
guard let data = UserDefaults.standard.data(forKey: cacheKey(for: person)),
|
||||
let cached = try? JSONDecoder().decode(CachedAnalysis.self, from: data)
|
||||
else { return nil }
|
||||
return cached
|
||||
}
|
||||
|
||||
private func saveCache(_ result: AIAnalysisResult, for person: Person) {
|
||||
let cached = CachedAnalysis(result: result)
|
||||
if let data = try? JSONEncoder().encode(cached) {
|
||||
UserDefaults.standard.set(data, forKey: cacheKey(for: person))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Analyze
|
||||
|
||||
func analyze(person: Person) async throws -> AIAnalysisResult {
|
||||
let config = AIConfig.load()
|
||||
|
||||
guard let url = URL(string: config.completionsURL) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
let prompt = buildPrompt(for: person, template: config.userPromptTemplate)
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": config.model,
|
||||
"stream": false,
|
||||
"messages": [
|
||||
["role": "system", "content": config.systemPrompt],
|
||||
["role": "user", "content": prompt]
|
||||
]
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url, timeoutInterval: config.timeoutSeconds)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
guard
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let choices = json["choices"] as? [[String: Any]],
|
||||
let first = choices.first,
|
||||
let message = first["message"] as? [String: Any],
|
||||
let content = message["content"] as? String
|
||||
else {
|
||||
throw URLError(.cannotParseResponse)
|
||||
}
|
||||
|
||||
let result = parseResult(content)
|
||||
recordRequest()
|
||||
saveCache(result, for: person)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Prompt Builder
|
||||
|
||||
private func buildPrompt(for person: Person, template: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd.MM.yyyy"
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
|
||||
let momentLines = person.sortedMoments.prefix(30).map {
|
||||
"- \(formatter.string(from: $0.createdAt)) [\($0.type.rawValue)]: \($0.text)"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let logLines = person.sortedLogEntries.prefix(20).map {
|
||||
"- \(formatter.string(from: $0.loggedAt)) [\($0.type.rawValue)]: \($0.title)"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let moments = momentLines.isEmpty ? "" : "Momente (\(person.sortedMoments.count)):\n\(momentLines)\n"
|
||||
let logEntries = logLines.isEmpty ? "" : "Log-Einträge (\(person.sortedLogEntries.count)):\n\(logLines)\n"
|
||||
|
||||
return template
|
||||
.replacingOccurrences(of: "{{personName}}", with: person.firstName)
|
||||
.replacingOccurrences(of: "{{birthday}}", with: birthYearContext(for: person))
|
||||
.replacingOccurrences(of: "{{interests}}", with: person.interests.map { "Interessen: \($0)\n" } ?? "")
|
||||
.replacingOccurrences(of: "{{moments}}", with: moments)
|
||||
.replacingOccurrences(of: "{{logEntries}}", with: logEntries)
|
||||
}
|
||||
|
||||
private func birthYearContext(for person: Person) -> String {
|
||||
guard let birthday = person.birthday else { return "" }
|
||||
let year = Calendar.current.component(.year, from: birthday)
|
||||
return "Geburtsjahr: \(year)\n"
|
||||
}
|
||||
|
||||
// MARK: - Gift Cache
|
||||
|
||||
private func giftCacheKey(for person: Person) -> String { "ai_gift_\(person.id.uuidString)" }
|
||||
|
||||
func loadCachedGift(for person: Person) -> CachedGiftSuggestion? {
|
||||
guard let data = UserDefaults.standard.data(forKey: giftCacheKey(for: person)),
|
||||
let cached = try? JSONDecoder().decode(CachedGiftSuggestion.self, from: data)
|
||||
else { return nil }
|
||||
return cached
|
||||
}
|
||||
|
||||
private func saveGiftCache(_ text: String, for person: Person) {
|
||||
let cached = CachedGiftSuggestion(text: text, generatedAt: Date())
|
||||
if let data = try? JSONEncoder().encode(cached) {
|
||||
UserDefaults.standard.set(data, forKey: giftCacheKey(for: person))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gift Suggestion
|
||||
|
||||
func suggestGift(person: Person) async throws -> String {
|
||||
let config = AIConfig.load()
|
||||
|
||||
guard let url = URL(string: config.completionsURL) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
let prompt = buildPrompt(for: person, template: config.giftPromptTemplate)
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": config.model,
|
||||
"stream": false,
|
||||
"messages": [
|
||||
["role": "system", "content": config.systemPrompt],
|
||||
["role": "user", "content": prompt]
|
||||
]
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url, timeoutInterval: config.timeoutSeconds)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
guard
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let choices = json["choices"] as? [[String: Any]],
|
||||
let first = choices.first,
|
||||
let message = first["message"] as? [String: Any],
|
||||
let content = message["content"] as? String
|
||||
else {
|
||||
throw URLError(.cannotParseResponse)
|
||||
}
|
||||
|
||||
// Normalize **IDEE X:** → IDEE X:
|
||||
var normalized = content
|
||||
for i in 1...3 {
|
||||
normalized = normalized
|
||||
.replacingOccurrences(of: "**IDEE \(i):**", with: "IDEE \(i):")
|
||||
.replacingOccurrences(of: "**IDEE \(i)**:", with: "IDEE \(i):")
|
||||
}
|
||||
|
||||
recordRequest()
|
||||
saveGiftCache(normalized, for: person)
|
||||
return normalized
|
||||
}
|
||||
|
||||
// MARK: - Result Parser
|
||||
|
||||
private func parseResult(_ text: String) -> AIAnalysisResult {
|
||||
// Labels können als "MUSTER:" oder "**MUSTER:**" erscheinen — beides normalisieren
|
||||
var normalized = text
|
||||
for label in ["MUSTER", "BEZIEHUNG", "EMPFEHLUNG"] {
|
||||
normalized = normalized
|
||||
.replacingOccurrences(of: "**\(label):**", with: "\(label):")
|
||||
.replacingOccurrences(of: "**\(label)**:", with: "\(label):")
|
||||
.replacingOccurrences(of: "**\(label)** :", with: "\(label):")
|
||||
}
|
||||
|
||||
func extract(_ label: String) -> String {
|
||||
// Lookahead nur auf bekannte Labels, nicht auf beliebige Großbuchstaben-Sequenzen
|
||||
let pattern = "\(label):\\s*(.+?)(?=\\n(?:MUSTER|BEZIEHUNG|EMPFEHLUNG):|\\z)"
|
||||
guard
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]),
|
||||
let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)),
|
||||
let range = Range(match.range(at: 1), in: normalized)
|
||||
else { return "" }
|
||||
return String(normalized[range]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
return AIAnalysisResult(
|
||||
patterns: extract("MUSTER"),
|
||||
relationship: extract("BEZIEHUNG"),
|
||||
recommendation: extract("EMPFEHLUNG")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"_info": "Diese Datei konfiguriert die KI-Analyse in nahbar. Änderungen werden beim nächsten App-Start übernommen.",
|
||||
|
||||
"baseURL": "https://api.mistral.ai",
|
||||
"completionsPath": "/v1/chat/completions",
|
||||
"apiKey": "zyXgjAmQl8WwVV0qoe8rSNajqrwK6QXQ",
|
||||
"model": "mistral-small-latest",
|
||||
"timeoutSeconds": 60,
|
||||
"rateLimitPerHour": 100,
|
||||
|
||||
"_openwebui_example": {
|
||||
"baseURL": "https://chatbot-app.hanold.online",
|
||||
"completionsPath": "/api/chat/completions",
|
||||
"model": "phi3:mini"
|
||||
},
|
||||
|
||||
"systemPrompt": "Du bist ein einfühlsamer Assistent für persönliche Beziehungspflege. Antworte ausschließlich auf Deutsch. Keine Emojis. Vermeide Wörter in GROSSBUCHSTABEN. Sei prägnant, warm und direkt. Strukturiere deine Antwort exakt wie verlangt. Nutze Markdown.",
|
||||
|
||||
"_placeholders": {
|
||||
"{{personName}}": "Vorname der Person",
|
||||
"{{birthday}}": "Geburtsjahr (leer wenn nicht angegeben)",
|
||||
"{{interests}}": "Interessen (leer wenn nicht angegeben)",
|
||||
"{{moments}}": "Liste aller Momente als formatierter Text",
|
||||
"{{logEntries}}": "Liste aller Log-Einträge als formatierter Text"
|
||||
},
|
||||
|
||||
"userPromptTemplate": "Person: {{personName}}\n{{birthday}}{{interests}}\n{{moments}}\n{{logEntries}}\nAnalysiere diese Beziehung. Berücksichtige die Lebensphase der Person anhand des Geburtsjahres, sofern bekannt. Nutze Markdown. Verwende **fett** für wichtige Begriffe. Antworte in exakt diesem Format:\n\nMUSTER: [2-3 Sätze über wiederkehrende Themen und Muster]\nBEZIEHUNG: [2-3 Sätze über die Entwicklung und Qualität der Beziehung]\nEMPFEHLUNG: [1 konkreter, sofort umsetzbarer nächster Schritt]",
|
||||
|
||||
"giftPromptTemplate": "Person: {{personName}}\n{{birthday}}{{interests}}\n{{moments}}\nDer Geburtstag dieser Person steht bevor. Schlage 2 konkrete, persönliche Geschenkideen in 2 Preisklassen vor. Berücksichtige Interessen und bisherige gemeinsame Momente. Sei kreativ aber realistisch. Kein Smalltalk, keine Erklärungen außerhalb der Ideen. Mach es eher kurz. Nenne erwartete Kosten. Antworte in exakt diesem Format:\n\nIDEE 1: [Geschenkidee – 1 Satz Begründung]\nIDEE 2: [Geschenkidee – 1 Satz Begründung]\nIDEE 3: [Geschenkidee – 1 Satz Begründung]"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Gemeinsame App-Group-Konfiguration für Hauptapp und Share Extension.
|
||||
/// Die App-Group-ID muss in beiden Targets als Capability eingetragen sein.
|
||||
enum AppGroup {
|
||||
static let identifier = "group.nahbar.shared"
|
||||
|
||||
/// URL des geteilten Containers. Fällt auf das Documents-Verzeichnis zurück,
|
||||
/// falls die App Group noch nicht eingerichtet ist (z.B. in frühen Dev-Builds).
|
||||
static var containerURL: URL {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
static func makeModelContainer() throws -> ModelContainer {
|
||||
let schema = Schema([Person.self, Moment.self, LogEntry.self])
|
||||
let storeURL = containerURL.appendingPathComponent("nahbar.store")
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+177
-31
@@ -1,5 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AI Analysis State
|
||||
|
||||
private enum AnalysisState {
|
||||
case idle
|
||||
case loading
|
||||
case result(AIAnalysisResult, Date)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
// MARK: - Timeline Item
|
||||
|
||||
private enum LogbuchItem: Identifiable {
|
||||
@@ -51,8 +60,13 @@ private enum LogbuchItem: Identifiable {
|
||||
|
||||
struct LogbuchView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@StateObject private var store = StoreManager.shared
|
||||
let person: Person
|
||||
|
||||
@State private var analysisState: AnalysisState = .idle
|
||||
@State private var showPaywall = false
|
||||
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
@@ -77,6 +91,13 @@ struct LogbuchView: View {
|
||||
.navigationTitle("Logbuch")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.themedNavBar()
|
||||
.sheet(isPresented: $showPaywall) { PaywallView() }
|
||||
.onAppear {
|
||||
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
||||
analysisState = .result(cached.asResult, cached.analyzedAt)
|
||||
}
|
||||
remainingRequests = AIAnalysisService.shared.remainingRequests
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Month Section
|
||||
@@ -161,6 +182,7 @@ struct LogbuchView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
|
||||
if !store.isPro {
|
||||
Text("PRO")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(theme.accent)
|
||||
@@ -169,45 +191,169 @@ struct LogbuchView: View {
|
||||
.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 {
|
||||
if !store.isPro {
|
||||
// Locked state
|
||||
Button { showPaywall = true } label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(theme.accent)
|
||||
Text("nahbar Pro freischalten für KI-Analyse")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(theme.accent)
|
||||
Spacer()
|
||||
Label("Bald verfügbar", systemImage: "lock")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.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)
|
||||
}
|
||||
} else {
|
||||
// Active state
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch analysisState {
|
||||
case .idle:
|
||||
Button {
|
||||
Task { await runAnalysis() }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(theme.accent)
|
||||
Text("\(person.firstName) analysieren")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
case .loading:
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().tint(theme.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Analysiere Logbuch…")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
Text("Das kann bis zu einer Minute dauern.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
case .result(let result, let date):
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns)
|
||||
RowDivider()
|
||||
analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship)
|
||||
RowDivider()
|
||||
analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation)
|
||||
RowDivider()
|
||||
HStack(spacing: 0) {
|
||||
// Zeitstempel
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Analysiert")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE"))))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Aktualisieren
|
||||
Button {
|
||||
Task { await runAnalysis() }
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 12))
|
||||
Text(remainingRequests > 0 ? "Aktualisieren (\(remainingRequests))" : "Limit erreicht")
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary)
|
||||
}
|
||||
.disabled(remainingRequests == 0 || isPurchasing)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
case .error(let msg):
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
Text(msg)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
Button {
|
||||
Task { await runAnalysis() }
|
||||
} label: {
|
||||
Text("Erneut versuchen")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func analysisSection(icon: String, title: String, text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.accent)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 14, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var isPurchasing: Bool {
|
||||
if case .loading = analysisState { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func runAnalysis() async {
|
||||
guard !mergedItems.isEmpty else { return }
|
||||
guard !AIAnalysisService.shared.isRateLimited else { return }
|
||||
analysisState = .loading
|
||||
do {
|
||||
let result = try await AIAnalysisService.shared.analyze(person: person)
|
||||
remainingRequests = AIAnalysisService.shared.remainingRequests
|
||||
analysisState = .result(result, Date())
|
||||
} catch {
|
||||
// Bei Fehler alten Cache wiederherstellen falls vorhanden
|
||||
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
||||
analysisState = .result(cached.asResult, cached.analyzedAt)
|
||||
} else {
|
||||
analysisState = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ struct NahbarApp: App {
|
||||
@StateObject private var appLockManager = AppLockManager.shared
|
||||
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var showSplash = true
|
||||
|
||||
private var activeTheme: NahbarTheme {
|
||||
NahbarTheme.theme(for: ThemeID(rawValue: activeThemeIDRaw) ?? .linen)
|
||||
@@ -19,20 +20,30 @@ struct NahbarApp: App {
|
||||
.environmentObject(callWindowManager)
|
||||
.environmentObject(appLockManager)
|
||||
|
||||
if appLockManager.isLocked {
|
||||
if appLockManager.isLocked && !showSplash {
|
||||
AppLockView()
|
||||
.environmentObject(appLockManager)
|
||||
.transition(.opacity)
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
if showSplash {
|
||||
SplashView(onFinished: {
|
||||
appLockManager.lockIfEnabled()
|
||||
showSplash = false
|
||||
})
|
||||
.transition(.opacity)
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: appLockManager.isLocked)
|
||||
.animation(.easeInOut(duration: 0.4), value: showSplash)
|
||||
.environment(\.nahbarTheme, activeTheme)
|
||||
.tint(activeTheme.accent)
|
||||
.onAppear { applyTabBarAppearance(activeTheme) }
|
||||
.onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) }
|
||||
}
|
||||
.modelContainer(for: [Person.self, Moment.self, LogEntry.self])
|
||||
.modelContainer(try! AppGroup.makeModelContainer())
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .background {
|
||||
appLockManager.lockIfEnabled()
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
struct PaywallView: View {
|
||||
@Environment(\.nahbarTheme) private var theme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var store = StoreManager.shared
|
||||
|
||||
@State private var isPurchasing = false
|
||||
@State private var isRestoring = false
|
||||
|
||||
private let features: [(icon: String, title: String, subtitle: String)] = [
|
||||
("brain.head.profile", "KI-Analyse", "Muster, Beziehungsqualität & konkrete Empfehlungen per KI"),
|
||||
("gift.fill", "Geschenkideen", "KI-basierte Vorschläge bei bevorstehenden Geburtstagen"),
|
||||
("square.and.arrow.up", "Messenger-Import", "Nachrichten aus WhatsApp, Telegram & Co. direkt ins Logbuch"),
|
||||
("paintpalette.fill", "Alle Themes", "Grove, Ink, Copper, Abyss, Dusk & Basalt"),
|
||||
("sparkles", "Neurodivers-Themes", "Reizarme Designs mit reduzierter Bewegung"),
|
||||
("star.fill", "Zukünftige Features", "Alle kommenden Pro-Features inklusive"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Handle
|
||||
Capsule()
|
||||
.fill(theme.borderSubtle)
|
||||
.frame(width: 36, height: 4)
|
||||
.padding(.top, 12)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
|
||||
// Header
|
||||
VStack(spacing: 8) {
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.padding(.top, 24)
|
||||
|
||||
Text("nahbar Pro")
|
||||
.font(.system(size: 28, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
|
||||
Text("Hol das Beste aus nahbar heraus.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// Features
|
||||
VStack(spacing: 0) {
|
||||
ForEach(features.indices, id: \.self) { i in
|
||||
if i > 0 { RowDivider() }
|
||||
featureRow(features[i])
|
||||
}
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// Price + CTA
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
isPurchasing = true
|
||||
await store.purchase()
|
||||
isPurchasing = false
|
||||
if store.isPro { dismiss() }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text(priceLabel)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(theme.accent)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
.disabled(isPurchasing || isRestoring)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
isRestoring = true
|
||||
await store.restorePurchases()
|
||||
isRestoring = false
|
||||
if store.isPro { dismiss() }
|
||||
}
|
||||
} label: {
|
||||
if isRestoring {
|
||||
ProgressView().tint(theme.contentTertiary)
|
||||
} else {
|
||||
Text("Kauf wiederherstellen")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
.disabled(isPurchasing || isRestoring)
|
||||
|
||||
if let error = store.purchaseError {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
// Legal
|
||||
Text("Abonnement wird automatisch verlängert. In den iPhone-Einstellungen jederzeit kündbar.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var priceLabel: String {
|
||||
if let product = store.product {
|
||||
return "\(product.displayPrice) / \(periodLabel(product)) abonnieren"
|
||||
}
|
||||
return "Abonnieren"
|
||||
}
|
||||
|
||||
private func periodLabel(_ product: Product) -> String {
|
||||
guard let sub = product.subscription else { return "Monat" }
|
||||
switch sub.subscriptionPeriod.unit {
|
||||
case .day: return sub.subscriptionPeriod.value == 7 ? "Woche" : "Tag"
|
||||
case .week: return "Woche"
|
||||
case .month: return "Monat"
|
||||
case .year: return "Jahr"
|
||||
@unknown default: return "Zeitraum"
|
||||
}
|
||||
}
|
||||
|
||||
private func featureRow(_ feature: (icon: String, title: String, subtitle: String)) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: feature.icon)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(theme.accent.opacity(0.10))
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(feature.title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text(feature.subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,13 @@ struct SettingsView: View {
|
||||
@EnvironmentObject private var appLockManager: AppLockManager
|
||||
|
||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||
@AppStorage("aiBaseURL") private var aiBaseURL: String = AIConfig.fallback.baseURL
|
||||
@AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey
|
||||
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
|
||||
@StateObject private var store = StoreManager.shared
|
||||
@State private var showingPINSetup = false
|
||||
@State private var showingPINDisable = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
private var biometricLabel: String {
|
||||
switch appLockManager.biometricType {
|
||||
@@ -35,6 +40,56 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
// nahbar Pro (oben)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "nahbar Pro", icon: "star.fill")
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if store.isPro {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Aktiv")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text("Alle Pro-Features freigeschaltet")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.padding(.horizontal, 20)
|
||||
} else {
|
||||
Button { showPaywall = true } label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("nahbar Pro entdecken")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(theme.accent)
|
||||
Text("KI-Analyse, Themes & mehr")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) { PaywallView() }
|
||||
|
||||
// Theme picker
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "Atmosphäre", icon: "paintpalette")
|
||||
@@ -219,6 +274,21 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// KI-Einstellungen
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "KI-Analyse", icon: "sparkles")
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
settingsTextField(label: "Server-URL", value: $aiBaseURL, placeholder: AIConfig.fallback.baseURL)
|
||||
RowDivider()
|
||||
settingsTextField(label: "Modell", value: $aiModel, placeholder: AIConfig.fallback.model)
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// About
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "Über nahbar", icon: "info.circle")
|
||||
@@ -244,6 +314,27 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings TextField Helper
|
||||
|
||||
extension SettingsView {
|
||||
func settingsTextField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.frame(width: 90, alignment: .leading)
|
||||
TextField(placeholder, text: value)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Theme Option Row
|
||||
|
||||
struct ThemeOptionRow: View {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Quote Model
|
||||
|
||||
private struct ZitatResponse: Decodable {
|
||||
let quote: String
|
||||
let authorName: String
|
||||
}
|
||||
|
||||
// MARK: - Fallback Quotes
|
||||
|
||||
private struct LocalQuote {
|
||||
let text: String
|
||||
let author: String
|
||||
}
|
||||
|
||||
private let fallbackQuotes: [LocalQuote] = [
|
||||
LocalQuote(text: "Der Mensch ist dem Menschen am nötigsten.", author: "Lucius Annaeus Seneca"),
|
||||
LocalQuote(text: "Glück ist nur real, wenn es geteilt wird.", author: "Christopher McCandless"),
|
||||
LocalQuote(text: "Man reist nicht, um anzukommen, sondern um zu reisen.", author: "Johann Wolfgang von Goethe"),
|
||||
LocalQuote(text: "Freundschaft ist wie Gesundheit: Ihren Wert kennt man erst, wenn man sie verloren hat.", author: "Unbekannt"),
|
||||
LocalQuote(text: "Ein Freund ist jemand, der dich kennt und trotzdem mag.", author: "Elbert Hubbard"),
|
||||
LocalQuote(text: "Das Geheimnis der menschlichen Existenz liegt nicht nur darin, am Leben zu bleiben, sondern auch einen Grund zum Leben zu finden.", author: "Fjodor Dostojewski"),
|
||||
LocalQuote(text: "Wer einen Freund hat, hat einen Schatz.", author: "Sprichwort"),
|
||||
LocalQuote(text: "Nähe entsteht nicht durch Distanz.", author: "Unbekannt"),
|
||||
LocalQuote(text: "Der beste Spiegel ist ein alter Freund.", author: "George Herbert"),
|
||||
LocalQuote(text: "Manche Menschen kommen in unser Leben und hinterlassen Fußspuren in unseren Herzen.", author: "Unbekannt"),
|
||||
LocalQuote(text: "Echte Freundschaft zeigt sich in schwierigen Zeiten.", author: "Aristoteles"),
|
||||
LocalQuote(text: "Das Leben wird vorwärts gelebt und rückwärts verstanden.", author: "Søren Kierkegaard"),
|
||||
LocalQuote(text: "Verbindung ist das, worum es im Leben geht.", author: "Brené Brown"),
|
||||
LocalQuote(text: "Kleine Gesten der Fürsorge können das Leben eines Menschen verändern.", author: "Unbekannt"),
|
||||
LocalQuote(text: "Zeit ist das Wertvollste, das ein Mensch verschenken kann.", author: "Unbekannt"),
|
||||
]
|
||||
|
||||
// MARK: - SplashView
|
||||
|
||||
struct SplashView: View {
|
||||
@Environment(\.nahbarTheme) private var theme
|
||||
var onFinished: () -> Void
|
||||
|
||||
@State private var quoteText: String = ""
|
||||
@State private var authorName: String = ""
|
||||
@State private var logoScale: CGFloat = 0.85
|
||||
@State private var logoOpacity: CGFloat = 0
|
||||
@State private var quoteShownAt: Date? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// App Logo
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 140, height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||
.scaleEffect(logoScale)
|
||||
.opacity(logoOpacity)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Quote
|
||||
if !quoteText.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Text("\u{201E}\(quoteText)\u{201C}")
|
||||
.font(.system(.title3, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if !authorName.isEmpty && authorName != "Unbekannt" {
|
||||
Text("— \(authorName)")
|
||||
.font(.system(.subheadline, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 36)
|
||||
.padding(.bottom, 52)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(duration: 0.6)) {
|
||||
logoScale = 1.0
|
||||
logoOpacity = 1.0
|
||||
}
|
||||
Task {
|
||||
// API mit 1s Timeout probieren, sonst Fallback
|
||||
if let apiQuote = await fetchQuote() {
|
||||
showQuote(text: apiQuote.quote, author: apiQuote.authorName)
|
||||
} else {
|
||||
showFallbackQuote()
|
||||
}
|
||||
|
||||
// Mindestens 4 Sekunden Zitat sichtbar lassen
|
||||
let elapsed = quoteShownAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
let remaining = max(0, 5.0 - elapsed)
|
||||
if remaining > 0 {
|
||||
try? await Task.sleep(for: .seconds(remaining))
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
onFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showQuote(text: String, author: String) {
|
||||
withAnimation(.easeIn(duration: 0.5)) {
|
||||
quoteText = text
|
||||
authorName = author
|
||||
}
|
||||
quoteShownAt = Date()
|
||||
}
|
||||
|
||||
private func showFallbackQuote() {
|
||||
guard let local = fallbackQuotes.randomElement() else { return }
|
||||
showQuote(text: local.text, author: local.author)
|
||||
}
|
||||
|
||||
private func fetchQuote() async -> ZitatResponse? {
|
||||
guard let url = URL(string: "https://api.zitat-service.de/v1/quote?language=de") else { return nil }
|
||||
let request = URLRequest(url: url, timeoutInterval: 1)
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try JSONDecoder().decode(ZitatResponse.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class StoreManager: ObservableObject {
|
||||
static let shared = StoreManager()
|
||||
|
||||
@Published private(set) var isPro: Bool = false
|
||||
@Published private(set) var product: Product? = nil
|
||||
@Published private(set) var purchaseError: String? = nil
|
||||
|
||||
private let productID = "profeatures"
|
||||
private var transactionListenerTask: Task<Void, Never>? = nil
|
||||
|
||||
private init() {
|
||||
transactionListenerTask = listenForTransactions()
|
||||
Task { await loadProduct() }
|
||||
Task { await refreshStatus() }
|
||||
}
|
||||
|
||||
deinit {
|
||||
transactionListenerTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Load product
|
||||
|
||||
func loadProduct() async {
|
||||
do {
|
||||
let products = try await Product.products(for: [productID])
|
||||
product = products.first
|
||||
} catch {
|
||||
// Produkt konnte nicht geladen werden — kein Absturz
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
func purchase() async {
|
||||
if product == nil { await loadProduct() }
|
||||
guard let product else {
|
||||
purchaseError = "Produkt konnte nicht geladen werden. Bitte Internetverbindung prüfen."
|
||||
return
|
||||
}
|
||||
purchaseError = nil
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
if case .verified(let transaction) = verification {
|
||||
await transaction.finish()
|
||||
await refreshStatus()
|
||||
}
|
||||
case .userCancelled:
|
||||
break
|
||||
case .pending:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
purchaseError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Restore
|
||||
|
||||
func restorePurchases() async {
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await refreshStatus()
|
||||
} catch {
|
||||
purchaseError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
func refreshStatus() async {
|
||||
for await result in Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result,
|
||||
transaction.productID == productID,
|
||||
transaction.revocationDate == nil {
|
||||
isPro = true
|
||||
return
|
||||
}
|
||||
}
|
||||
isPro = false
|
||||
}
|
||||
|
||||
// MARK: - Transaction listener
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Never> {
|
||||
Task(priority: .background) {
|
||||
for await result in Transaction.updates {
|
||||
if case .verified(let transaction) = result {
|
||||
await transaction.finish()
|
||||
await refreshStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import SwiftUI
|
||||
struct ThemePickerView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
|
||||
@StateObject private var store = StoreManager.shared
|
||||
|
||||
@State private var previewID: ThemeID = .linen
|
||||
@State private var showPaywall = false
|
||||
|
||||
private var activeThemeID: ThemeID {
|
||||
ThemeID(rawValue: activeThemeIDRaw) ?? .linen
|
||||
@@ -67,6 +69,9 @@ struct ThemePickerView: View {
|
||||
.onAppear {
|
||||
previewID = activeThemeID
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group
|
||||
@@ -92,8 +97,12 @@ struct ThemePickerView: View {
|
||||
let isPreviewing = id == previewID
|
||||
|
||||
return Button {
|
||||
if id.isPremium && !store.isPro {
|
||||
showPaywall = true
|
||||
} else {
|
||||
previewID = id
|
||||
activeThemeIDRaw = id.rawValue
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
// Color swatch
|
||||
|
||||
@@ -86,10 +86,13 @@ struct TodayView: View {
|
||||
if !birthdayPeople.isEmpty {
|
||||
TodaySection(title: birthdaySectionTitle, icon: "gift") {
|
||||
ForEach(birthdayPeople) { person in
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: birthdayHint(for: person))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
GiftSuggestionRow(person: person)
|
||||
}
|
||||
if person.id != birthdayPeople.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
@@ -200,6 +203,182 @@ struct TodayView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gift Suggestion Row
|
||||
|
||||
private enum GiftSuggestionState {
|
||||
case idle
|
||||
case loading
|
||||
case result(String, Date)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
struct GiftSuggestionRow: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
let person: Person
|
||||
|
||||
@StateObject private var store = StoreManager.shared
|
||||
@State private var state: GiftSuggestionState = .idle
|
||||
@State private var isExpanded = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
switch state {
|
||||
case .idle:
|
||||
idleButton
|
||||
case .loading:
|
||||
loadingView
|
||||
case .result(let text, let date):
|
||||
if isExpanded {
|
||||
resultView(text: text, date: date)
|
||||
} else {
|
||||
collapsedButton
|
||||
}
|
||||
case .error(let message):
|
||||
errorView(message: message)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let cached = AIAnalysisService.shared.loadCachedGift(for: person) {
|
||||
state = .result(cached.text, cached.generatedAt)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: isExpanded)
|
||||
.sheet(isPresented: $showPaywall) { PaywallView() }
|
||||
}
|
||||
|
||||
private var idleButton: some View {
|
||||
Button {
|
||||
guard store.isPro else { showPaywall = true; return }
|
||||
Task { await loadGift() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gift")
|
||||
.font(.system(size: 13))
|
||||
Text("Geschenkidee vorschlagen")
|
||||
.font(.system(size: 13))
|
||||
Spacer()
|
||||
if !store.isPro {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(store.isPro ? theme.accent : theme.contentSecondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var collapsedButton: some View {
|
||||
Button { isExpanded = true } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gift")
|
||||
.font(.system(size: 13))
|
||||
Text("Geschenkidee anzeigen")
|
||||
.font(.system(size: 13))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(theme.accent)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.75)
|
||||
Text("Ideen werden generiert…")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func resultView(text: String, date: Date) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button { isExpanded = false } label: {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack {
|
||||
Text(date, style: .date)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await loadGift() }
|
||||
} label: {
|
||||
Text("Neu laden")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(AIAnalysisService.shared.isRateLimited)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func errorView(message: String) -> some View {
|
||||
HStack {
|
||||
Text("Fehler: \(message)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await loadGift() }
|
||||
} label: {
|
||||
Text("Erneut versuchen")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadGift() async {
|
||||
state = .loading
|
||||
do {
|
||||
let suggestion = try await AIAnalysisService.shared.suggestGift(person: person)
|
||||
isExpanded = true
|
||||
state = .result(suggestion, Date())
|
||||
} catch {
|
||||
// Falls Cache vorhanden, zeige ihn zurück; sonst Fehler
|
||||
if let cached = AIAnalysisService.shared.loadCachedGift(for: person) {
|
||||
state = .result(cached.text, cached.generatedAt)
|
||||
} else {
|
||||
state = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today Section
|
||||
|
||||
struct TodaySection<Content: View>: View {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.nahbar.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Share View Controller-->
|
||||
<scene sceneID="ceB-am-kn3">
|
||||
<objects>
|
||||
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ShareExtensionView: View {
|
||||
let sharedText: String
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var text: String
|
||||
@State private var momentType: MomentType = .conversation
|
||||
@State private var people: [Person] = []
|
||||
@State private var selectedPerson: Person?
|
||||
@State private var searchText = ""
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
init(sharedText: String, onDismiss: @escaping () -> Void) {
|
||||
self.sharedText = sharedText
|
||||
self.onDismiss = onDismiss
|
||||
self._text = State(initialValue: sharedText)
|
||||
}
|
||||
|
||||
private var filteredPeople: [Person] {
|
||||
searchText.isEmpty ? people : people.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Nachricht") {
|
||||
TextEditor(text: $text)
|
||||
.frame(minHeight: 80)
|
||||
.font(.system(size: 15))
|
||||
}
|
||||
|
||||
Section("Typ") {
|
||||
Picker("Typ", selection: $momentType) {
|
||||
ForEach(MomentType.allCases, id: \.self) { type in
|
||||
Label(type.rawValue, systemImage: type.icon).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section("Kontakt") {
|
||||
if people.isEmpty {
|
||||
Text("Keine Kontakte gefunden")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: 14))
|
||||
} else {
|
||||
TextField("Suchen…", text: $searchText)
|
||||
ForEach(filteredPeople) { person in
|
||||
PersonPickerRow(
|
||||
person: person,
|
||||
isSelected: selectedPerson?.id == person.id
|
||||
) {
|
||||
selectedPerson = (selectedPerson?.id == person.id) ? nil : person
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("In nahbar speichern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen", action: onDismiss)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Speichern") { save() }
|
||||
.disabled(selectedPerson == nil || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { loadPeople() }
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
struct PersonPickerRow: View {
|
||||
let person: Person
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(person.name)
|
||||
.foregroundStyle(.primary)
|
||||
if let tag = PersonTag(rawValue: person.tagRaw) {
|
||||
Text(tag.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
private func loadPeople() {
|
||||
do {
|
||||
let container = try AppGroup.makeModelContainer()
|
||||
let context = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<Person>(sortBy: [SortDescriptor(\.name)])
|
||||
people = (try? context.fetch(descriptor)) ?? []
|
||||
} catch {
|
||||
errorMessage = "Kontakte konnten nicht geladen werden."
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let person = selectedPerson else { return }
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let container = try AppGroup.makeModelContainer()
|
||||
let context = ModelContext(container)
|
||||
let personID = person.id
|
||||
let descriptor = FetchDescriptor<Person>(predicate: #Predicate { $0.id == personID })
|
||||
guard let target = try context.fetch(descriptor).first else {
|
||||
errorMessage = "Kontakt nicht gefunden."
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
let moment = Moment(
|
||||
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: momentType,
|
||||
person: target
|
||||
)
|
||||
context.insert(moment)
|
||||
try context.save()
|
||||
onDismiss()
|
||||
} catch {
|
||||
errorMessage = "Speichern fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Einstiegspunkt der Share Extension.
|
||||
/// Extrahiert den geteilten Text und präsentiert ShareExtensionView.
|
||||
@objc(ShareViewController)
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task {
|
||||
let text = await extractSharedText()
|
||||
await MainActor.run { presentShareView(text: text) }
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShareView(text: String) {
|
||||
let shareView = ShareExtensionView(sharedText: text) { [weak self] in
|
||||
self?.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
let host = UIHostingController(rootView: shareView)
|
||||
addChild(host)
|
||||
host.view.frame = view.bounds
|
||||
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
}
|
||||
|
||||
private func extractSharedText() async -> String {
|
||||
guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { return "" }
|
||||
for item in items {
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
if let result = try? await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier),
|
||||
let text = result as? String {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.nahbar.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "66DC6506",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6762391341",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "EKFHUHT63T",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 798118032.521047,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "22038114",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "",
|
||||
"displayName" : "Pro Features",
|
||||
"locale" : "de"
|
||||
}
|
||||
],
|
||||
"name" : "Pro Features",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "6.99",
|
||||
"familyShareable" : true,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6762459001",
|
||||
"introductoryOffers" : [
|
||||
|
||||
],
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Zusätzliche Themes, KI-Empfehlungen, nützliche Analysen",
|
||||
"displayName" : "Pro Features freischalten",
|
||||
"locale" : "de"
|
||||
}
|
||||
],
|
||||
"productID" : "profeatures",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Pro Features freischalten",
|
||||
"subscriptionGroupID" : "22038114",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 5,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user