AI, Geburtstag, Abo

This commit is contained in:
2026-04-17 15:59:33 +02:00
parent 39a60c10b3
commit 49c1825c0f
26 changed files with 1954 additions and 47 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+209
View File
@@ -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 */;
@@ -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>
@@ -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>
+332
View File
@@ -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")
)
}
}
+30
View File
@@ -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]"
}
+22
View File
@@ -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
}
}
+186 -40
View File
@@ -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,53 +182,178 @@ struct LogbuchView: 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())
if !store.isPro {
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)
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()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
.padding(16)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
} 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)
}
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)
}
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)
HStack {
Spacer()
Label("Bald verfügbar", systemImage: "lock")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.contentTertiary)
Spacer()
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)
}
}
.padding(.top, 4)
.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)
}
.padding(16)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.borderSubtle, lineWidth: 1)
)
.opacity(0.7)
}
}
+13 -2
View File
@@ -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()
+172
View File
@@ -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)
}
}
+91
View File
@@ -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 {
+135
View File
@@ -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
}
}
}
+103
View File
@@ -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()
}
}
}
}
}
+11 -2
View File
@@ -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 {
previewID = id
activeThemeIDRaw = id.rawValue
if id.isPremium && !store.isPro {
showPaywall = true
} else {
previewID = id
activeThemeIDRaw = id.rawValue
}
} label: {
HStack(spacing: 14) {
// Color swatch
+182 -3
View File
@@ -86,10 +86,13 @@ struct TodayView: View {
if !birthdayPeople.isEmpty {
TodaySection(title: birthdaySectionTitle, icon: "gift") {
ForEach(birthdayPeople) { person in
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: birthdayHint(for: person))
VStack(spacing: 0) {
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: birthdayHint(for: person))
}
.buttonStyle(.plain)
GiftSuggestionRow(person: person)
}
.buttonStyle(.plain)
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 {
+10
View File
@@ -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>
+21
View File
@@ -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>
+85
View File
@@ -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
}
}