commit 10fca0756d1e21d61b948e5e65b0355dba49158b Author: MrDiderot Date: Wed Jul 22 22:18:33 2026 +0200 Initial FeelAloud project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a582c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# macOS +.DS_Store + +# Xcode user state +xcuserdata/ +*.xcuserstate +*.xccheckout +*.xcscmblueprint + +# Build products +build/ +DerivedData/ +*.ipa +*.dSYM +*.dSYM.zip +*.xcarchive + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# Local configuration and secrets +.env +.env.* +*.p12 +*.mobileprovision +*.cer + +# Temporary exports +exports/ +*.tmp + diff --git a/AppIcon.svg b/AppIcon.svg new file mode 100644 index 0000000..4fe9359 --- /dev/null +++ b/AppIcon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..360926f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +Alle wesentlichen Änderungen an FeelAloud werden in dieser Datei dokumentiert. +Das Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). + +## [Unreleased] + +### Geplant + +- Versionierte SwiftData-Migrationen +- Automatisierte Unit- und UI-Tests +- In-App-Funktion zum Löschen aller lokalen Daten +- Englische Oberflächenlokalisierung + +## [1.0.0] – 2026-07-20 + +### Added + +- Sprachbasierte Stimmungseinträge mit erzwungener lokaler Spracherkennung +- Strukturierte lokale Auswertung über Apples Foundation Models +- Fünfstufige emotionale Momentaufnahmen +- Zwei oder drei konfigurierbare lokale Erinnerungen pro Tag +- Kontextfragen für besonders positive und negative Check-ins +- Verlauf mit Datumsfilter, Durchschnitt und Swift-Charts-Diagramm +- XLSX- und CSV-Export +- App Intent für Kurzbefehle und Action Button +- App-Icon als Asset und SVG-Quelle +- Privacy Manifest für die lokale Speicherung von Einstellungen +- Architektur-, Datenschutz-, TestFlight- und QA-Dokumentation + +### Changed + +- Projekt, Target, Bundle-Anzeige, Exporte und interne Notification-IDs auf + **FeelAloud** umbenannt +- Bundle-ID auf `de.feelaloud` gesetzt +- Versionsstand für die neue App-Identität auf 1.0 (Build 1) gesetzt + +### Security + +- Keine Analyse-, Werbe- oder Tracking-Abhängigkeiten +- Keine Zugangsdaten, Zertifikate oder Provisioning Profiles im Repository diff --git a/FeelAloud.xcodeproj/project.pbxproj b/FeelAloud.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a2e7ec0 --- /dev/null +++ b/FeelAloud.xcodeproj/project.pbxproj @@ -0,0 +1,609 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 70; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000001 /* FeelAloudApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* FeelAloudApp.swift */; }; + A10000000000000000000002 /* MoodEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* MoodEntry.swift */; }; + A10000000000000000000003 /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* SpeechRecognizer.swift */; }; + A10000000000000000000004 /* MoodAnalysisService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* MoodAnalysisService.swift */; }; + A10000000000000000000005 /* CSVExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000005 /* CSVExporter.swift */; }; + A10000000000000000000006 /* StartDiaryEntryIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000006 /* StartDiaryEntryIntent.swift */; }; + A10000000000000000000007 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000007 /* RootView.swift */; }; + A10000000000000000000008 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000008 /* HomeView.swift */; }; + A10000000000000000000009 /* EntryFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* EntryFlowView.swift */; }; + A1000000000000000000000A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000A /* HistoryView.swift */; }; + A1000000000000000000000B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000B /* SettingsView.swift */; }; + A1000000000000000000000C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000C /* Assets.xcassets */; }; + A1000000000000000000000D /* MoodSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000E /* MoodSnapshot.swift */; }; + A1000000000000000000000E /* SnapshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000F /* SnapshotView.swift */; }; + A1000000000000000000000F /* SnapshotNotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000010 /* SnapshotNotificationScheduler.swift */; }; + A10000000000000000000010 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000011 /* ShareSheet.swift */; }; + A10000000000000000000011 /* XLSXExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000012 /* XLSXExporter.swift */; }; + A10000000000000000000012 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000013 /* PrivacyInfo.xcprivacy */; }; + E296A4333010C0CA003F93AA /* TherapySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A4323010C0CA003F93AA /* TherapySupport.swift */; }; + E296A4353010C110003F93AA /* TherapySupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A4343010C110003F93AA /* TherapySupportView.swift */; }; + E296A4373010C14D003F93AA /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A4363010C14D003F93AA /* AppLockView.swift */; }; + E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A4383010FDD6003F93AA /* AppTheme.swift */; }; + E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44030110491003F93AA /* WidgetKit.framework */; }; + E296A44330110492003F93AA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44230110492003F93AA /* SwiftUI.framework */; }; + E296A45430110492003F93AA /* MomentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E296A43E30110491003F93AA /* MomentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E296A45230110492003F93AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F10000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E296A43D30110491003F93AA; + remoteInfo = MomentExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E296A45930110492003F93AA /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + E296A45430110492003F93AA /* MomentExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + B10000000000000000000001 /* FeelAloudApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeelAloudApp.swift; sourceTree = ""; }; + B10000000000000000000002 /* MoodEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodEntry.swift; sourceTree = ""; }; + B10000000000000000000003 /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; + B10000000000000000000004 /* MoodAnalysisService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodAnalysisService.swift; sourceTree = ""; }; + B10000000000000000000005 /* CSVExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExporter.swift; sourceTree = ""; }; + B10000000000000000000006 /* StartDiaryEntryIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartDiaryEntryIntent.swift; sourceTree = ""; }; + B10000000000000000000007 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + B10000000000000000000008 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + B10000000000000000000009 /* EntryFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryFlowView.swift; sourceTree = ""; }; + B1000000000000000000000A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + B1000000000000000000000B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B1000000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B1000000000000000000000D /* FeelAloud.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FeelAloud.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B1000000000000000000000E /* MoodSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodSnapshot.swift; sourceTree = ""; }; + B1000000000000000000000F /* SnapshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotView.swift; sourceTree = ""; }; + B10000000000000000000010 /* SnapshotNotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotNotificationScheduler.swift; sourceTree = ""; }; + B10000000000000000000011 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; + B10000000000000000000012 /* XLSXExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XLSXExporter.swift; sourceTree = ""; }; + B10000000000000000000013 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + E296A4323010C0CA003F93AA /* TherapySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySupport.swift; sourceTree = ""; }; + E296A4343010C110003F93AA /* TherapySupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySupportView.swift; sourceTree = ""; }; + E296A4363010C14D003F93AA /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; }; + E296A4383010FDD6003F93AA /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + E296A43E30110491003F93AA /* MomentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MomentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E296A44030110491003F93AA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + E296A44230110492003F93AA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + E296A45E30110993003F93AA /* MomentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MomentExtension.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E296A45530110492003F93AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = E296A43D30110491003F93AA /* MomentExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E296A44430110492003F93AA /* Moment */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E296A45530110492003F93AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Moment; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + E296A43B30110491003F93AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E296A44330110492003F93AA /* SwiftUI.framework in Frameworks */, + E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + G10000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C10000000000000000000001 = { + isa = PBXGroup; + children = ( + E296A45E30110993003F93AA /* MomentExtension.entitlements */, + C10000000000000000000002 /* FeelAloud */, + E296A44430110492003F93AA /* Moment */, + E296A43F30110491003F93AA /* Frameworks */, + C10000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + C10000000000000000000002 /* FeelAloud */ = { + isa = PBXGroup; + children = ( + B10000000000000000000001 /* FeelAloudApp.swift */, + B10000000000000000000002 /* MoodEntry.swift */, + B10000000000000000000003 /* SpeechRecognizer.swift */, + B10000000000000000000004 /* MoodAnalysisService.swift */, + B10000000000000000000005 /* CSVExporter.swift */, + B10000000000000000000006 /* StartDiaryEntryIntent.swift */, + B10000000000000000000007 /* RootView.swift */, + B10000000000000000000008 /* HomeView.swift */, + B10000000000000000000009 /* EntryFlowView.swift */, + B1000000000000000000000A /* HistoryView.swift */, + B1000000000000000000000B /* SettingsView.swift */, + B1000000000000000000000E /* MoodSnapshot.swift */, + B1000000000000000000000F /* SnapshotView.swift */, + B10000000000000000000010 /* SnapshotNotificationScheduler.swift */, + B10000000000000000000011 /* ShareSheet.swift */, + B10000000000000000000012 /* XLSXExporter.swift */, + B10000000000000000000013 /* PrivacyInfo.xcprivacy */, + B1000000000000000000000C /* Assets.xcassets */, + E296A4323010C0CA003F93AA /* TherapySupport.swift */, + E296A4343010C110003F93AA /* TherapySupportView.swift */, + E296A4363010C14D003F93AA /* AppLockView.swift */, + E296A4383010FDD6003F93AA /* AppTheme.swift */, + ); + path = FeelAloud; + sourceTree = ""; + }; + C10000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + B1000000000000000000000D /* FeelAloud.app */, + E296A43E30110491003F93AA /* MomentExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + E296A43F30110491003F93AA /* Frameworks */ = { + isa = PBXGroup; + children = ( + E296A44030110491003F93AA /* WidgetKit.framework */, + E296A44230110492003F93AA /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E10000000000000000000001 /* FeelAloud */ = { + isa = PBXNativeTarget; + buildConfigurationList = I10000000000000000000002 /* Build configuration list for PBXNativeTarget "FeelAloud" */; + buildPhases = ( + G10000000000000000000002 /* Sources */, + G10000000000000000000001 /* Frameworks */, + G10000000000000000000003 /* Resources */, + E296A45930110492003F93AA /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + E296A45330110492003F93AA /* PBXTargetDependency */, + ); + name = FeelAloud; + productName = FeelAloud; + productReference = B1000000000000000000000D /* FeelAloud.app */; + productType = "com.apple.product-type.application"; + }; + E296A43D30110491003F93AA /* MomentExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = E296A45630110492003F93AA /* Build configuration list for PBXNativeTarget "MomentExtension" */; + buildPhases = ( + E296A43A30110491003F93AA /* Sources */, + E296A43B30110491003F93AA /* Frameworks */, + E296A43C30110491003F93AA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E296A44430110492003F93AA /* Moment */, + ); + name = MomentExtension; + packageProductDependencies = ( + ); + productName = MomentExtension; + productReference = E296A43E30110491003F93AA /* MomentExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F10000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2600; + TargetAttributes = { + E10000000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + E296A43D30110491003F93AA = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = I10000000000000000000001 /* Build configuration list for PBXProject "FeelAloud" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = de; + hasScannedForEncodings = 0; + knownRegions = ( + de, + Base, + ); + mainGroup = C10000000000000000000001; + productRefGroup = C10000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E10000000000000000000001 /* FeelAloud */, + E296A43D30110491003F93AA /* MomentExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E296A43C30110491003F93AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + G10000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000000C /* Assets.xcassets in Resources */, + A10000000000000000000012 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E296A43A30110491003F93AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + G10000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E296A4333010C0CA003F93AA /* TherapySupport.swift in Sources */, + A10000000000000000000001 /* FeelAloudApp.swift in Sources */, + A10000000000000000000002 /* MoodEntry.swift in Sources */, + A10000000000000000000003 /* SpeechRecognizer.swift in Sources */, + A10000000000000000000004 /* MoodAnalysisService.swift in Sources */, + A10000000000000000000005 /* CSVExporter.swift in Sources */, + A10000000000000000000006 /* StartDiaryEntryIntent.swift in Sources */, + A10000000000000000000007 /* RootView.swift in Sources */, + A10000000000000000000008 /* HomeView.swift in Sources */, + E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */, + A10000000000000000000009 /* EntryFlowView.swift in Sources */, + A1000000000000000000000A /* HistoryView.swift in Sources */, + A1000000000000000000000B /* SettingsView.swift in Sources */, + A1000000000000000000000D /* MoodSnapshot.swift in Sources */, + A1000000000000000000000E /* SnapshotView.swift in Sources */, + A1000000000000000000000F /* SnapshotNotificationScheduler.swift in Sources */, + A10000000000000000000010 /* ShareSheet.swift in Sources */, + E296A4353010C110003F93AA /* TherapySupportView.swift in Sources */, + A10000000000000000000011 /* XLSXExporter.swift in Sources */, + E296A4373010C14D003F93AA /* AppLockView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E296A45330110492003F93AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E296A43D30110491003F93AA /* MomentExtension */; + targetProxy = E296A45230110492003F93AA /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + E296A45730110492003F93AA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = MomentExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PSL297GL9Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Moment/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Moment; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.feelaloud.Moment; + 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; + }; + E296A45830110492003F93AA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = MomentExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PSL297GL9Y; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Moment/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Moment; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.feelaloud.Moment; + 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; + }; + H10000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + H10000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + H10000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = PSL297GL9Y; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FeelAloud; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "FeelAloud schützt deine privaten Tagebuchdaten"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Die App benötigt das Mikrofon, damit du deinen Stimmungseintrag sprechen kannst."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Die App transkribiert deinen gesprochenen Eintrag lokal auf diesem iPhone."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.feelaloud; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + H10000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = PSL297GL9Y; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FeelAloud; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "FeelAloud schützt deine privaten Tagebuchdaten"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Die App benötigt das Mikrofon, damit du deinen Stimmungseintrag sprechen kannst."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Die App transkribiert deinen gesprochenen Eintrag lokal auf diesem iPhone."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.feelaloud; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E296A45630110492003F93AA /* Build configuration list for PBXNativeTarget "MomentExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E296A45730110492003F93AA /* Debug */, + E296A45830110492003F93AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + I10000000000000000000001 /* Build configuration list for PBXProject "FeelAloud" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + H10000000000000000000001 /* Debug */, + H10000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + I10000000000000000000002 /* Build configuration list for PBXNativeTarget "FeelAloud" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + H10000000000000000000003 /* Debug */, + H10000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F10000000000000000000001 /* Project object */; +} diff --git a/FeelAloud.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/FeelAloud.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/FeelAloud.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/FeelAloud.xcodeproj/xcshareddata/xcschemes/FeelAloud.xcscheme b/FeelAloud.xcodeproj/xcshareddata/xcschemes/FeelAloud.xcscheme new file mode 100644 index 0000000..2e3c08a --- /dev/null +++ b/FeelAloud.xcodeproj/xcshareddata/xcschemes/FeelAloud.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FeelAloud/AppLockView.swift b/FeelAloud/AppLockView.swift new file mode 100644 index 0000000..72644e2 --- /dev/null +++ b/FeelAloud/AppLockView.swift @@ -0,0 +1,114 @@ +import LocalAuthentication +import SwiftUI + +struct AppLockView: View { + @Binding var isUnlocked: Bool + + @Environment(\.appColorProfile) private var colorProfile + @AppStorage("appLockEnabled") private var appLockEnabled = false + @State private var isAuthenticating = false + @State private var message = "" + + var body: some View { + VStack(spacing: 18) { + Image(systemName: "lock.shield") + .font(.system(size: 54)) + .foregroundStyle(colorProfile.accent) + + VStack(spacing: 6) { + Text("FeelAloud ist gesperrt") + .font(.title2.bold()) + Text("Entsperre die App mit Face ID, Touch ID oder Gerätecode.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + if !message.isEmpty { + Text(message) + .font(.footnote) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + + Button { + Task { await authenticate() } + } label: { + Label(isAuthenticating ? "Wird geprüft" : "Entsperren", systemImage: "faceid") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .disabled(isAuthenticating) + } + .padding(28) + .frame(maxWidth: 360) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + .task { + await authenticate() + } + } + + @MainActor + private func authenticate() async { + guard !isAuthenticating else { return } + + guard AppLockAvailability.canAuthenticate else { + appLockEnabled = false + isUnlocked = true + message = "Die App-Sperre wurde deaktiviert, weil keine lokale Entsperrung eingerichtet ist." + return + } + + guard !AppLockAvailability.requiresMissingFaceIDDescription else { + appLockEnabled = false + isUnlocked = true + message = "Die App-Sperre wurde deaktiviert, weil der Face-ID-Nutzungstext fehlt." + return + } + + isAuthenticating = true + message = "" + + let context = LAContext() + context.localizedCancelTitle = "Abbrechen" + let reason = "Schütze deine privaten Tagebucheinträge und Momentaufnahmen." + + do { + let success = try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + isUnlocked = success + if !success { + message = "Entsperrung wurde nicht bestätigt." + } + } catch { + message = error.localizedDescription + } + + isAuthenticating = false + } +} + +enum AppLockAvailability { + static var canAuthenticate: Bool { + var error: NSError? + return LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) + } + + static var requiresMissingFaceIDDescription: Bool { + let context = LAContext() + var error: NSError? + context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + guard context.biometryType == .faceID else { return false } + return Bundle.main.object(forInfoDictionaryKey: "NSFaceIDUsageDescription") == nil + } + + static var settingsStatus: String { + if requiresMissingFaceIDDescription { + return "Info-Plist fehlt" + } + return canAuthenticate ? "Verfügbar" : "Nicht eingerichtet" + } +} diff --git a/FeelAloud/AppTheme.swift b/FeelAloud/AppTheme.swift new file mode 100644 index 0000000..b84e524 --- /dev/null +++ b/FeelAloud/AppTheme.swift @@ -0,0 +1,392 @@ +import Observation +import StoreKit +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +enum AppColorProfile: String, CaseIterable, Identifiable { + case lavender + case sage + case sky + case rose + case peach + + var id: String { rawValue } + + var name: String { + switch self { + case .lavender: "Lavendel" + case .sage: "Salbei" + case .sky: "Himmel" + case .rose: "Rose" + case .peach: "Pfirsich" + } + } + + var accent: Color { + switch self { + case .lavender: Color(red: 0.58, green: 0.43, blue: 0.82) + case .sage: Color(red: 0.39, green: 0.62, blue: 0.51) + case .sky: Color(red: 0.34, green: 0.60, blue: 0.84) + case .rose: Color(red: 0.82, green: 0.45, blue: 0.58) + case .peach: Color(red: 0.86, green: 0.55, blue: 0.38) + } + } + + var secondary: Color { + switch self { + case .lavender: Color(red: 0.42, green: 0.46, blue: 0.77) + case .sage: Color(red: 0.52, green: 0.70, blue: 0.61) + case .sky: Color(red: 0.50, green: 0.74, blue: 0.88) + case .rose: Color(red: 0.91, green: 0.61, blue: 0.70) + case .peach: Color(red: 0.93, green: 0.68, blue: 0.45) + } + } + + var softBackground: Color { + switch self { + case .lavender: Color(red: 0.94, green: 0.91, blue: 0.98) + case .sage: Color(red: 0.91, green: 0.96, blue: 0.92) + case .sky: Color(red: 0.91, green: 0.96, blue: 0.99) + case .rose: Color(red: 0.99, green: 0.92, blue: 0.95) + case .peach: Color(red: 1.00, green: 0.94, blue: 0.88) + } + } + + var gradient: [Color] { + [accent, secondary] + } + + static func stored(_ rawValue: String) -> AppColorProfile { + AppColorProfile(rawValue: rawValue) ?? .lavender + } +} + +private struct AppColorProfileKey: EnvironmentKey { + static let defaultValue: AppColorProfile = .lavender +} + +extension EnvironmentValues { + var appColorProfile: AppColorProfile { + get { self[AppColorProfileKey.self] } + set { self[AppColorProfileKey.self] = newValue } + } +} + +#if canImport(UIKit) +extension View { + func appKeyboardBehavior() -> some View { + scrollDismissesKeyboard(.interactively) + } +} +#else +extension View { + func appKeyboardBehavior() -> some View { + self + } +} +#endif + +enum PremiumAccess { + static let storageKey = "premiumFeaturesEnabled" +} + +@MainActor +@Observable +final class PremiumStore { + static let supportProductID = "premium_support_lifetime" + + private(set) var supportProduct: Product? + private(set) var purchaseState: PremiumPurchaseState = .idle + private(set) var productLoadingFailed = false + + private var updatesTask: Task? + + var premiumFeaturesEnabled: Bool { + UserDefaults.standard.bool(forKey: PremiumAccess.storageKey) + } + + var displayPrice: String { + supportProduct?.displayPrice ?? "Einmaliger Kauf" + } + + func start() async { + guard updatesTask == nil else { return } + + updatesTask = Task(priority: .background) { [weak self] in + for await result in StoreKit.Transaction.updates { + await self?.handle(result) + } + } + + await loadProducts() + await refreshEntitlements() + } + + func loadProducts() async { + do { + let products = try await Product.products(for: [Self.supportProductID]) + supportProduct = products.first + productLoadingFailed = products.isEmpty + } catch { + supportProduct = nil + productLoadingFailed = true + } + } + + func purchaseSupport() async { + guard let supportProduct else { + purchaseState = .failed("Das Testprodukt ist noch nicht verfügbar. Prüfe die StoreKit-Konfiguration.") + return + } + + purchaseState = .purchasing + + do { + let result = try await supportProduct.purchase() + + switch result { + case .success(let verificationResult): + await handle(verificationResult) + purchaseState = premiumFeaturesEnabled ? .purchased : .failed("Der Kauf konnte nicht verifiziert werden.") + case .userCancelled: + purchaseState = .idle + case .pending: + purchaseState = .pending + @unknown default: + purchaseState = .failed("Unbekanntes StoreKit-Ergebnis.") + } + } catch { + purchaseState = .failed(error.localizedDescription) + } + } + + func restorePurchases() async { + purchaseState = .purchasing + + do { + try await AppStore.sync() + await refreshEntitlements() + purchaseState = premiumFeaturesEnabled ? .purchased : .idle + } catch { + purchaseState = .failed(error.localizedDescription) + } + } + + func enableLocalTestPremium() { + setPremiumEnabled(true) + purchaseState = .purchased + } + + func setPremiumEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: PremiumAccess.storageKey) + } + + func refreshEntitlements() async { + var hasSupportPurchase = false + + for await result in StoreKit.Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { continue } + guard transaction.productID == Self.supportProductID else { continue } + guard transaction.revocationDate == nil else { continue } + + if let expirationDate = transaction.expirationDate, expirationDate < Date() { + continue + } + + hasSupportPurchase = true + } + + if hasSupportPurchase { + setPremiumEnabled(true) + } + } + + private func handle(_ result: VerificationResult) async { + guard case .verified(let transaction) = result else { + purchaseState = .failed("StoreKit konnte die Transaktion nicht verifizieren.") + return + } + + guard transaction.productID == Self.supportProductID else { + await transaction.finish() + return + } + + if transaction.revocationDate == nil { + setPremiumEnabled(true) + } else { + setPremiumEnabled(false) + } + + await transaction.finish() + } +} + +enum PremiumPurchaseState: Equatable { + case idle + case purchasing + case purchased + case pending + case failed(String) + + var message: String? { + switch self { + case .idle: + nil + case .purchasing: + "StoreKit wird vorbereitet …" + case .purchased: + "Premium ist freigeschaltet." + case .pending: + "Der Kauf wartet noch auf Bestätigung." + case .failed(let message): + message + } + } +} + +struct PremiumPaywallView: View { + let title: String + let message: String + + @Environment(\.appColorProfile) private var colorProfile + @Environment(\.dismiss) private var dismiss + @Environment(PremiumStore.self) private var premiumStore + + var body: some View { + NavigationStack { + VStack(spacing: 18) { + Spacer(minLength: 24) + + Image(systemName: "heart.circle.fill") + .font(.system(size: 62)) + .foregroundStyle(colorProfile.accent) + + VStack(spacing: 8) { + Text(title) + .font(.title2.bold()) + .multilineTextAlignment(.center) + + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(alignment: .leading, spacing: 12) { + PremiumFeatureRow(systemImage: "folder", text: "Projektmappen für Therapie-Themen") + PremiumFeatureRow(systemImage: "tablecells", text: "Therapeuten-Export als Excel-Report") + PremiumFeatureRow(systemImage: "heart.fill", text: "Werbefrei unterstützen und Bonusfunktionen freischalten") + } + .padding() + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + + Button { + Task { + if premiumStore.supportProduct == nil { + premiumStore.enableLocalTestPremium() + } else { + await premiumStore.purchaseSupport() + } + + if premiumStore.premiumFeaturesEnabled { + dismiss() + } + } + } label: { + if premiumStore.purchaseState == .purchasing { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Label(primaryButtonTitle, systemImage: "heart.fill") + .frame(maxWidth: .infinity) + } + } + .disabled(premiumStore.purchaseState == .purchasing) + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + + Button { + Task { + await premiumStore.restorePurchases() + if premiumStore.premiumFeaturesEnabled { + dismiss() + } + } + } label: { + Text("Käufe wiederherstellen") + .frame(maxWidth: .infinity) + } + .disabled(premiumStore.purchaseState == .purchasing) + .buttonStyle(.bordered) + .tint(colorProfile.accent) + + if let statusMessage = premiumStore.purchaseState.message { + Text(statusMessage) + .font(.footnote) + .foregroundStyle(statusColor) + .multilineTextAlignment(.center) + } + + Text(helperText) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Spacer(minLength: 12) + } + .padding() + .background(Color(.systemGroupedBackground)) + .navigationTitle("Unterstützen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Schließen") { + dismiss() + } + } + } + } + } + + private var primaryButtonTitle: String { + if premiumStore.supportProduct == nil { + return "Testweise freischalten" + } + + return "Unterstützen für \(premiumStore.displayPrice)" + } + + private var helperText: String { + if premiumStore.supportProduct == nil { + return "Testphase mit Personal Team: Ohne StoreKit-Testprodukt schaltet dieser Button Premium lokal frei." + } + + return "Der Kauf läuft über Apple. Zahlungsdaten bleiben bei Apple und werden nicht in FeelAloud gespeichert." + } + + private var statusColor: Color { + if case .failed = premiumStore.purchaseState { + return .orange + } + + return .secondary + } +} + +private struct PremiumFeatureRow: View { + let systemImage: String + let text: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .frame(width: 22) + Text(text) + .font(.subheadline) + Spacer(minLength: 0) + } + } +} diff --git a/FeelAloud/Assets.xcassets/AccentColor.colorset/Contents.json b/FeelAloud/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..af93a1a --- /dev/null +++ b/FeelAloud/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,21 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.741", + "green" : "0.251", + "red" : "0.502" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} + diff --git a/FeelAloud/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/FeelAloud/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..db53309 Binary files /dev/null and b/FeelAloud/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/FeelAloud/Assets.xcassets/AppIcon.appiconset/Contents.json b/FeelAloud/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4de16cc --- /dev/null +++ b/FeelAloud/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} + diff --git a/FeelAloud/Assets.xcassets/Contents.json b/FeelAloud/Assets.xcassets/Contents.json new file mode 100644 index 0000000..6cc1226 --- /dev/null +++ b/FeelAloud/Assets.xcassets/Contents.json @@ -0,0 +1,7 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} + diff --git a/FeelAloud/CSVExporter.swift b/FeelAloud/CSVExporter.swift new file mode 100644 index 0000000..69239e4 --- /dev/null +++ b/FeelAloud/CSVExporter.swift @@ -0,0 +1,54 @@ +import Foundation + +enum CSVExporter { + static func createFile(entries: [MoodEntry]) throws -> URL { + let header = [ + "Datum", + "Stimmung", + "MusikImKopf", + "SongName", + "OhrwurmStärke", + "Sozialkontakt", + "Leidensdruck", + "Gegenmaßnahmen", + "GegenmaßnahmeAktivität", + "Stress" + ].joined(separator: ";") + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + + let rows = entries.sorted { $0.createdAt < $1.createdAt }.map { entry in + let values = [ + formatter.string(from: entry.createdAt), + entry.moodRaw, + entry.musicInHead ? "Ja" : "Nein", + entry.musicInHead ? entry.songName : "", + entry.musicInHead ? entry.earwormStrengthRaw : "", + entry.socialContact ? "Ja" : "Nein", + entry.musicInHead ? entry.distressRaw : "", + entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : "", + entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : "", + entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein") + ] + return values.map(escaped).joined(separator: ";") + } + + let content = "\u{FEFF}" + ([header] + rows).joined(separator: "\n") + let filenameFormatter = DateFormatter() + filenameFormatter.locale = Locale(identifier: "en_US_POSIX") + filenameFormatter.dateFormat = "yyyy-MM-dd" + let date = filenameFormatter.string(from: .now) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("FeelAloud-\(date).csv") + try content.write(to: url, atomically: true, encoding: .utf8) + return url + } + + private static func escaped(_ value: String) -> String { + guard value.contains(";") || value.contains("\"") || value.contains("\n") else { + return value + } + return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\"" + } +} diff --git a/FeelAloud/EntryFlowView.swift b/FeelAloud/EntryFlowView.swift new file mode 100644 index 0000000..75c6ade --- /dev/null +++ b/FeelAloud/EntryFlowView.swift @@ -0,0 +1,296 @@ +import SwiftData +import SwiftUI + +struct EntryFlowView: View { + private enum Phase: Equatable { + case ready + case recording + case analyzing + case review + } + + let autoStart: Bool + + @Environment(\.appColorProfile) private var colorProfile + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @StateObject private var speech = SpeechRecognizer() + @State private var phase: Phase = .ready + @State private var draft = DraftMoodEntry() + @State private var errorMessage: String? + @State private var hasAutoStarted = false + @State private var isStartingRecording = false + @State private var manualText = "" + @State private var customCategories: [DiaryCategory] = [] + @FocusState private var isManualTextFocused: Bool + + var body: some View { + NavigationStack { + Group { + switch phase { + case .ready, .recording: + recordingView + case .analyzing: + analyzingView + case .review: + reviewView + } + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + speech.cancel() + dismiss() + } + } + + } + } + .task { + customCategories = DiaryCategoryStore.load() + guard autoStart, !hasAutoStarted else { return } + hasAutoStarted = true + await startRecording() + } + .onDisappear { + speech.cancel() + } + .alert("Das hat nicht geklappt", isPresented: errorBinding) { + Button("Manuell eintragen") { + draft.transcript = speech.transcript + phase = .review + } + Button("Erneut versuchen") { + Task { await startRecording() } + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text(errorMessage ?? "Unbekannter Fehler") + } + } + + private var recordingView: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + isManualTextFocused = false + } + + VStack(spacing: 24) { + Spacer(minLength: 16) + + ZStack { + Circle() + .fill(phase == .recording ? Color.red.opacity(0.14) : colorProfile.accent.opacity(0.12)) + .frame(width: 190, height: 190) + + Circle() + .fill(phase == .recording ? Color.red : colorProfile.accent) + .frame(width: 124, height: 124) + .shadow(color: (phase == .recording ? Color.red : colorProfile.accent).opacity(0.28), radius: 18) + + Image(systemName: phase == .recording ? "stop.fill" : "waveform.and.mic") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.white) + } + .contentShape(Circle()) + .onTapGesture { + isManualTextFocused = false + if phase == .recording { + Task { await finishRecording() } + } else if !isStartingRecording { + Task { await startRecording() } + } + } + .accessibilityLabel(phase == .recording ? "Aufnahme beenden" : "Aufnahme starten") + + VStack(spacing: 8) { + Text(phase == .recording ? "Ich höre zu …" : "Bereit für deinen Eintrag") + .font(.title2.bold()) + Text("Sprich deinen Eintrag ein oder schreibe frei auf, wie es dir gerade geht.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + } + + ZStack(alignment: .topLeading) { + TextField("", text: $manualText, axis: .vertical) + .lineLimit(5...10) + .textFieldStyle(.plain) + .submitLabel(.done) + .focused($isManualTextFocused) + .onSubmit { + isManualTextFocused = false + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(14) + .disabled(phase == .recording) + + if manualText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(phase == .recording ? "Nach der Aufnahme erscheint dein Text hier." : "Schreibe hier direkt deinen Eintrag …") + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 16) + .allowsHitTesting(false) + } + } + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 18)) + + if phase == .ready { + Button { + isManualTextFocused = false + Task { await analyzeManualText() } + } label: { + Label("Text auswerten", systemImage: "paperplane.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .disabled(manualText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + Spacer(minLength: 8) + } + .padding() + } + .appKeyboardBehavior() + } + + private var analyzingView: some View { + VStack(spacing: 20) { + ProgressView() + .controlSize(.large) + .tint(colorProfile.accent) + Text("Dein iPhone sortiert den Eintrag …") + .font(.headline) + Text("Die Auswertung findet lokal auf diesem Gerät statt.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + } + + private var reviewView: some View { + Form { + Section("Stimmung") { + Picker("Stimmung", selection: $draft.mood) { + ForEach(MoodRating.allCases) { mood in + Text(mood.rawValue).tag(mood) + } + } + } + + Section("Kategorien") { + Toggle("Stress", isOn: $draft.stress) + Toggle("Sozialkontakt", isOn: $draft.socialContact) + + ForEach(customCategories) { category in + Toggle(category.name, isOn: customCategoryBinding(for: category)) + } + + if customCategories.isEmpty { + Text("Eigene Kategorien kannst du in den Einstellungen anlegen.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if !draft.transcript.isEmpty { + Section("Tagebuchtext") { + Text(draft.transcript) + .foregroundStyle(.secondary) + } + } + + Section { + Button { + save() + } label: { + Label("Eintrag speichern", systemImage: "checkmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + } + + private var navigationTitle: String { + switch phase { + case .ready, .recording: "Neuer Eintrag" + case .analyzing: "Lokale Auswertung" + case .review: "Angaben prüfen" + } + } + + private var errorBinding: Binding { + Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + } + + private func customCategoryBinding(for category: DiaryCategory) -> Binding { + Binding( + get: { draft.customCategoryValues[category.id.uuidString, default: false] }, + set: { draft.customCategoryValues[category.id.uuidString] = $0 } + ) + } + + private func startRecording() async { + guard !isStartingRecording else { return } + isStartingRecording = true + defer { isStartingRecording = false } + + do { + try await speech.start() + phase = .recording + } catch { + errorMessage = error.localizedDescription + phase = .ready + } + } + + private func finishRecording() async { + phase = .analyzing + + do { + try await speech.stop() + manualText = speech.transcript + draft = try await MoodAnalysisService.analyze(manualText) + phase = .review + } catch { + draft.transcript = speech.transcript + manualText = speech.transcript + errorMessage = error.localizedDescription + phase = .ready + } + } + + private func analyzeManualText() async { + phase = .analyzing + + do { + draft = try await MoodAnalysisService.analyze(manualText) + phase = .review + } catch { + errorMessage = error.localizedDescription + phase = .ready + } + } + + private func save() { + let entry = MoodEntry(draft: draft) + modelContext.insert(entry) + try? modelContext.save() + speech.cancel() + dismiss() + } +} diff --git a/FeelAloud/FeelAloud.entitlements b/FeelAloud/FeelAloud.entitlements new file mode 100644 index 0000000..a5dd682 --- /dev/null +++ b/FeelAloud/FeelAloud.entitlements @@ -0,0 +1,24 @@ + + + + + aps-environment + development + com.apple.security.application-groups + + group.de.feelaloud + + com.apple.developer.icloud-container-identifiers + + iCloud.de.feelaloud + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.ubiquity-container-identifiers + + iCloud.de.feelaloud + + + diff --git a/FeelAloud/FeelAloudApp.swift b/FeelAloud/FeelAloudApp.swift new file mode 100644 index 0000000..415d2e6 --- /dev/null +++ b/FeelAloud/FeelAloudApp.swift @@ -0,0 +1,101 @@ +import AppIntents +import SwiftUI +import SwiftData +import Observation + +@main +struct FeelAloudApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var router = AppRouter.shared + @State private var premiumStore = PremiumStore() + + var body: some Scene { + WindowGroup { + RootView() + .environment(router) + .environment(premiumStore) + .task { + FeelAloudShortcuts.updateAppShortcutParameters() + await premiumStore.start() + } + } + .modelContainer(FeelAloudModelStore.container) + } +} + +enum FeelAloudModelStore { + static let usedFallbackKey = "swiftDataUsedInMemoryFallback" + + static let container: ModelContainer = { + do { + let container = try ModelContainer( + for: MoodEntry.self, + MoodSnapshot.self, + TherapyItem.self, + TherapyProject.self, + TherapyProjectNote.self, + TherapyProjectAttachment.self, + EmergencyPlan.self + ) + UserDefaults.standard.set(false, forKey: usedFallbackKey) + return container + } catch { + UserDefaults.standard.set(true, forKey: usedFallbackKey) + assertionFailure("SwiftData persistent store failed: \(error.localizedDescription)") + + do { + let fallback = ModelConfiguration(isStoredInMemoryOnly: true) + return try ModelContainer( + for: MoodEntry.self, + MoodSnapshot.self, + TherapyItem.self, + TherapyProject.self, + TherapyProjectNote.self, + TherapyProjectAttachment.self, + EmergencyPlan.self, + configurations: fallback + ) + } catch { + fatalError("SwiftData in-memory fallback failed: \(error.localizedDescription)") + } + } + }() +} + +@MainActor +@Observable +final class AppRouter { + static let shared = AppRouter() + + var showsNewEntry = false + var shouldStartRecording = false + var showsSnapshot = false + var snapshotSource: SnapshotSource = .manual + var suppressesNextFollowUpCheck = false + var pendingSnapshotSource: SnapshotSource? + var pendingSnapshotRequestID = 0 + + private init() { } + + func presentNewEntry(startRecording: Bool) { + shouldStartRecording = startRecording + showsNewEntry = true + } + + func presentSnapshot(source: SnapshotSource) { + snapshotSource = source + suppressesNextFollowUpCheck = source == .notification + showsSnapshot = true + } + + func queueSnapshotPresentation(source: SnapshotSource) { + pendingSnapshotSource = source + pendingSnapshotRequestID += 1 + } + + func consumePendingSnapshotSource() -> SnapshotSource? { + let source = pendingSnapshotSource + pendingSnapshotSource = nil + return source + } +} diff --git a/FeelAloud/HistoryView.swift b/FeelAloud/HistoryView.swift new file mode 100644 index 0000000..26ac79f --- /dev/null +++ b/FeelAloud/HistoryView.swift @@ -0,0 +1,1206 @@ +import Charts +import SwiftData +import SwiftUI + +private enum SnapshotPeriod: String, CaseIterable, Identifiable { + case week = "7 Tage" + case month = "30 Tage" + case all = "Gesamt" + case custom = "Eigener Zeitraum" + + var id: String { rawValue } +} + +struct HistoryView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.appColorProfile) private var colorProfile + @AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false + @Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot] + @Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry] + + @State private var selectedPeriod: SnapshotPeriod = .week + @State private var customStart = Calendar.current.date( + byAdding: .day, + value: -6, + to: Calendar.current.startOfDay(for: .now) + ) ?? .now + @State private var customEnd = Date.now + @State private var exportFile: ExportFile? + @State private var exportError: String? + @State private var showsExportOptions = false + @State private var showsPremiumPaywall = false + @State private var exportPeriod: SnapshotPeriod = .week + @State private var exportCustomStart = Calendar.current.date( + byAdding: .day, + value: -6, + to: Calendar.current.startOfDay(for: .now) + ) ?? .now + @State private var exportCustomEnd = Date.now + + var body: some View { + NavigationStack { + List { + Section("Zeitraum") { + Picker("Auswahl", selection: $selectedPeriod) { + ForEach(SnapshotPeriod.allCases) { period in + Text(period.rawValue).tag(period) + } + } + + if selectedPeriod == .custom { + DatePicker( + "Von", + selection: $customStart, + displayedComponents: .date + ) + DatePicker( + "Bis", + selection: $customEnd, + displayedComponents: .date + ) + } + } + + Section("Momentaufnahmen") { + summary + + if filteredSnapshots.isEmpty { + ContentUnavailableView( + "Keine Momentaufnahmen", + systemImage: "face.dashed", + description: Text("Für diesen Zeitraum wurden noch keine kurzen Checks gespeichert.") + ) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } else { + snapshotChart + .frame(height: 220) + .padding(.vertical, 8) + } + } + + Section("Wochenrückblick") { + weeklyReview + } + + Section { + Button { + if premiumFeaturesEnabled { + exportPeriod = selectedPeriod + exportCustomStart = customStart + exportCustomEnd = customEnd + showsExportOptions = true + } else { + showsPremiumPaywall = true + } + } label: { + Label( + premiumFeaturesEnabled ? "Zeitraum als Excel exportieren" : "Therapeuten-Export freischalten", + systemImage: premiumFeaturesEnabled ? "tablecells" : "lock.fill" + ) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .disabled(premiumFeaturesEnabled && filteredSnapshots.isEmpty && filteredEntries.isEmpty) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + Section("Listen") { + NavigationLink { + SnapshotListView(snapshots: filteredSnapshots) + } label: { + listSummaryRow( + title: "Momentaufnahmen", + subtitle: "\(filteredSnapshots.count) Checks im Zeitraum", + systemImage: "face.smiling" + ) + } + + NavigationLink { + DiaryEntryListView(entries: filteredEntries) + } label: { + listSummaryRow( + title: "Tagebucheinträge", + subtitle: "\(filteredEntries.count) Einträge im Zeitraum", + systemImage: "book.pages" + ) + } + + NavigationLink { + TherapyEntryListView(entries: therapyEntries) + } label: { + listSummaryRow( + title: "In Therapie besprechen", + subtitle: "\(therapyEntries.count) markierte Einträge", + systemImage: "person.2.wave.2" + ) + } + } + } + .navigationTitle("Verlauf") + } + .appKeyboardBehavior() + .onAppear { + importWidgetSnapshots() + } + .sheet(item: $exportFile) { file in + ShareSheet(items: [file.url]) + } + .sheet(isPresented: $showsExportOptions) { + exportOptionsView + } + .sheet(isPresented: $showsPremiumPaywall) { + PremiumPaywallView( + title: "Therapeuten-Export freischalten", + message: "Der ausführliche Excel-Report ist ein Bonus für Unterstützer:innen. Die Basisfunktionen und deine gespeicherten Daten bleiben frei zugänglich." + ) + } + .alert("Export nicht möglich", isPresented: exportErrorBinding) { + Button("OK", role: .cancel) { } + } message: { + Text(exportError ?? "Unbekannter Fehler") + } + } + + private func importWidgetSnapshots() { + let snapshots = WidgetSnapshotImport.consumePendingSnapshots() + guard !snapshots.isEmpty else { return } + + for snapshot in snapshots { + modelContext.insert(snapshot) + } + + try? modelContext.save() + } + + private var summary: some View { + HStack(spacing: 14) { + Text(averageRating?.emoji ?? "–") + .font(.system(size: 38)) + .frame(width: 56, height: 56) + .background(colorProfile.accent.opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(averageLabel) + .font(.title3.bold()) + Text("Ø aus \(filteredSnapshots.count) Checks") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(rangeLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private var snapshotChart: some View { + Chart(Array(filteredSnapshots.reversed())) { snapshot in + LineMark( + x: .value("Zeit", snapshot.createdAt), + y: .value("Gefühl", snapshot.rating.score) + ) + .foregroundStyle(colorProfile.accent) + .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Zeit", snapshot.createdAt), + y: .value("Gefühl", snapshot.rating.score) + ) + .foregroundStyle(snapshot.rating.color) + .symbolSize(55) + } + .chartYScale(domain: 0.8...5.2) + .chartYAxis { + AxisMarks(values: [1, 2, 3, 4, 5]) { value in + AxisGridLine() + AxisValueLabel { + if let score = value.as(Int.self) { + Text(axisLabel(score)) + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 5)) { + AxisGridLine() + AxisValueLabel(format: .dateTime.day().month(.abbreviated)) + } + } + .appKeyboardBehavior() + } + + @ViewBuilder + private var weeklyReview: some View { + let snapshots = snapshots(in: weekReviewRange) + let entries = entries(in: weekReviewRange) + + if snapshots.isEmpty && entries.isEmpty { + ContentUnavailableView( + "Noch kein Rückblick", + systemImage: "calendar.badge.clock", + description: Text("Sobald Daten der letzten 7 Tage vorliegen, entsteht hier eine kurze Wochenübersicht.") + ) + .listRowBackground(Color.clear) + } else { + let snapshotAverage = averageSnapshotScore(for: snapshots) + let entryAverage = averageEntryScore(for: entries) + let difficultCount = snapshots.filter { $0.rating.score <= 2 }.count + + entries.filter { $0.mood.score <= 2 }.count + let markedCount = entries.filter { $0.discussInTherapy }.count + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + weeklyMetric( + title: "Checks", + value: "\(snapshots.count)", + systemImage: "face.smiling" + ) + weeklyMetric( + title: "Einträge", + value: "\(entries.count)", + systemImage: "book.pages" + ) + } + + HStack(spacing: 12) { + weeklyMetric( + title: "Durchschnitt", + value: weeklyAverageLabel(snapshotAverage, entryAverage), + systemImage: "chart.bar" + ) + weeklyMetric( + title: "Therapie", + value: "\(markedCount)", + systemImage: "person.2.wave.2" + ) + } + + if difficultCount > 0 { + Label( + "\(difficultCount) schwierige Markierung(en) in den letzten 7 Tagen", + systemImage: "exclamationmark.triangle" + ) + .font(.footnote) + .foregroundStyle(.orange) + } + + if let note = latestWeeklyNote(from: snapshots, entries: entries) { + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + .padding(.vertical, 4) + } + } + + private func weeklyMetric(title: String, value: String, systemImage: String) -> some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(colorProfile.accent) + .frame(width: 22) + VStack(alignment: .leading, spacing: 1) { + Text(value) + .font(.headline) + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(10) + .background(colorProfile.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + + private func listSummaryRow( + title: String, + subtitle: String, + systemImage: String + ) -> some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(colorProfile.accent) + .frame(width: 36, height: 36) + .background(colorProfile.accent.opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 3) + } + + private func snapshotRow(_ snapshot: MoodSnapshot) -> some View { + HStack(spacing: 12) { + Text(snapshot.rating.emoji) + .font(.title) + .frame(width: 46, height: 46) + .background(snapshot.rating.color.opacity(0.11), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(snapshot.rating.rawValue) + .font(.headline) + Text(snapshot.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + if !snapshot.followUpNote.isEmpty { + Text(snapshot.followUpNote) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .padding(.vertical, 3) + } + + private func entryRow(_ entry: MoodEntry) -> some View { + HStack(spacing: 12) { + Image(systemName: entry.mood.symbol) + .font(.title2) + .foregroundStyle(entry.mood.color) + .frame(width: 42, height: 42) + .background(entry.mood.color.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(entry.mood.rawValue) + .font(.headline) + Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + if entry.musicInHead { + Image(systemName: "music.note") + .foregroundStyle(.purple) + } + } + .padding(.vertical, 3) + } + + private var exportOptionsView: some View { + NavigationStack { + Form { + Section("Zeitraum") { + Picker("Auswahl", selection: $exportPeriod) { + ForEach(SnapshotPeriod.allCases) { period in + Text(period.rawValue).tag(period) + } + } + + if exportPeriod == .custom { + DatePicker( + "Von", + selection: $exportCustomStart, + displayedComponents: .date + ) + DatePicker( + "Bis", + selection: $exportCustomEnd, + displayedComponents: .date + ) + } + } + + Section { + Text(exportRangeHint) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Excel exportieren") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + showsExportOptions = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Exportieren") { + showsExportOptions = false + exportExcel() + } + } + } + } + } + + private var dateRange: (start: Date, endExclusive: Date, displayEnd: Date) { + range(for: selectedPeriod, customStart: customStart, customEnd: customEnd) + } + + private var exportDateRange: (start: Date, endExclusive: Date, displayEnd: Date) { + range(for: exportPeriod, customStart: exportCustomStart, customEnd: exportCustomEnd) + } + + private var weekReviewRange: (start: Date, endExclusive: Date, displayEnd: Date) { + range(for: .week, customStart: customStart, customEnd: customEnd) + } + + private var exportRangeHint: String { + let range = exportDateRange + let snapshots = snapshots(in: range) + let entries = entries(in: range) + let days = dayCount(from: range.start, through: range.displayEnd) + let sheetText = days <= 14 + ? "Der Export wird auf einem Arbeitsblatt zusammengefasst." + : "Der Export wird in Übersicht, Momentaufnahmen und Tagebuch aufgeteilt." + + return "\(range.start.formatted(date: .abbreviated, time: .omitted)) – \(range.displayEnd.formatted(date: .abbreviated, time: .omitted)), \(snapshots.count) Checks, \(entries.count) Tagebucheinträge. \(sheetText)" + } + + private func range( + for period: SnapshotPeriod, + customStart: Date, + customEnd: Date + ) -> (start: Date, endExclusive: Date, displayEnd: Date) { + let calendar = Calendar.current + let today = calendar.startOfDay(for: .now) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .distantFuture + let start: Date + let endExclusive: Date + + switch period { + case .week: + start = calendar.date(byAdding: .day, value: -6, to: today) ?? .distantPast + endExclusive = tomorrow + case .month: + start = calendar.date(byAdding: .day, value: -29, to: today) ?? .distantPast + endExclusive = tomorrow + case .all: + let oldestSnapshot = snapshots.last?.createdAt + let oldestEntry = entries.last?.createdAt + start = [oldestSnapshot, oldestEntry] + .compactMap { $0 } + .min() + .map { calendar.startOfDay(for: $0) } ?? today + endExclusive = tomorrow + case .custom: + let first = calendar.startOfDay(for: customStart) + let second = calendar.startOfDay(for: customEnd) + start = min(first, second) + let last = max(first, second) + endExclusive = calendar.date(byAdding: .day, value: 1, to: last) ?? tomorrow + } + + let displayEnd = calendar.date(byAdding: .second, value: -1, to: endExclusive) + ?? endExclusive + return (start, endExclusive, displayEnd) + } + + private var filteredSnapshots: [MoodSnapshot] { + snapshots(in: dateRange) + } + + private var filteredEntries: [MoodEntry] { + entries(in: dateRange) + } + + private var therapyEntries: [MoodEntry] { + entries.filter { $0.discussInTherapy } + } + + private func snapshots(in range: (start: Date, endExclusive: Date, displayEnd: Date)) -> [MoodSnapshot] { + snapshots.filter { + $0.createdAt >= range.start && $0.createdAt < range.endExclusive + } + } + + private func entries(in range: (start: Date, endExclusive: Date, displayEnd: Date)) -> [MoodEntry] { + entries.filter { + $0.createdAt >= range.start && $0.createdAt < range.endExclusive + } + } + + private func averageSnapshotScore(for snapshots: [MoodSnapshot]) -> Double? { + guard !snapshots.isEmpty else { return nil } + return snapshots.map(\.rating.score).reduce(0, +) / Double(snapshots.count) + } + + private func averageEntryScore(for entries: [MoodEntry]) -> Double? { + guard !entries.isEmpty else { return nil } + return entries.map(\.mood.score).reduce(0, +) / Double(entries.count) + } + + private func weeklyAverageLabel(_ snapshotAverage: Double?, _ entryAverage: Double?) -> String { + let values = [snapshotAverage, entryAverage].compactMap { $0 } + guard !values.isEmpty else { return "–" } + return SnapshotRating.label(for: values.reduce(0, +) / Double(values.count)) + } + + private func latestWeeklyNote(from snapshots: [MoodSnapshot], entries: [MoodEntry]) -> String? { + let snapshotNote = snapshots.first { !$0.followUpNote.isEmpty }?.followUpNote + let entryNote = entries.first { !$0.transcript.isEmpty }?.transcript + return snapshotNote ?? entryNote + } + + private func dayCount(from start: Date, through end: Date) -> Int { + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: start) + let endDay = calendar.startOfDay(for: end) + return (calendar.dateComponents([.day], from: startDay, to: endDay).day ?? 0) + 1 + } + + private var average: Double? { + guard !filteredSnapshots.isEmpty else { return nil } + return filteredSnapshots.map(\.rating.score).reduce(0, +) + / Double(filteredSnapshots.count) + } + + private var averageRating: SnapshotRating? { + guard let average else { return nil } + return SnapshotRating(rawValue: SnapshotRating.label(for: average)) + } + + private var averageLabel: String { + guard let average else { return "Noch keine Daten" } + return SnapshotRating.label(for: average) + } + + private var rangeLabel: String { + "\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) – \(dateRange.displayEnd.formatted(date: .abbreviated, time: .omitted))" + } + + private func axisLabel(_ score: Int) -> String { + switch score { + case 5: "Sehr gut" + case 4: "Gut" + case 3: "Mittel" + case 2: "Schlecht" + default: "Sehr schlecht" + } + } + + private var exportErrorBinding: Binding { + Binding( + get: { exportError != nil }, + set: { if !$0 { exportError = nil } } + ) + } + + private func exportExcel() { + let range = exportDateRange + let exportSnapshots = snapshots(in: range) + let exportEntries = entries(in: range) + + guard !exportSnapshots.isEmpty || !exportEntries.isEmpty else { + exportError = "Für diesen Zeitraum gibt es keine Daten." + return + } + + do { + let url = try XLSXExporter.createFile( + snapshots: exportSnapshots, + entries: exportEntries, + from: range.start, + through: range.displayEnd + ) + guard FileManager.default.fileExists(atPath: url.path) else { + exportError = "Die Excel-Datei konnte nicht erstellt werden." + return + } + exportFile = ExportFile(url: url) + } catch { + exportError = error.localizedDescription + } + } +} + +private struct ExportFile: Identifiable { + let url: URL + + var id: String { url.absoluteString } +} + +private struct SnapshotListView: View { + @Environment(\.modelContext) private var modelContext + let snapshots: [MoodSnapshot] + + @State private var selectedIDs = Set() + @State private var isSelecting = false + + var body: some View { + List { + if snapshots.isEmpty { + ContentUnavailableView( + "Keine Momentaufnahmen", + systemImage: "face.dashed", + description: Text("Für diesen Zeitraum gibt es keine gespeicherten Checks.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(snapshots) { snapshot in + snapshotRow(snapshot) + .contentShape(Rectangle()) + .onTapGesture { + if isSelecting { + toggleSelection(snapshot.id) + } + } + .onLongPressGesture { + isSelecting = true + selectedIDs.insert(snapshot.id) + } + .swipeActions(edge: .trailing) { + deleteButton { + delete(snapshot) + } + } + .swipeActions(edge: .leading) { + deleteButton { + delete(snapshot) + } + } + } + } + } + .navigationTitle("Momentaufnahmen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isSelecting { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + selectedIDs.removeAll() + isSelecting = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Löschen", role: .destructive) { + deleteSelected() + } + .disabled(selectedIDs.isEmpty) + } + } + } + } + + private func snapshotRow(_ snapshot: MoodSnapshot) -> some View { + HStack(spacing: 12) { + if isSelecting { + Image(systemName: selectedIDs.contains(snapshot.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedIDs.contains(snapshot.id) ? .purple : .secondary) + } + + Text(snapshot.rating.emoji) + .font(.title) + .frame(width: 46, height: 46) + .background(snapshot.rating.color.opacity(0.11), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(snapshot.rating.rawValue) + .font(.headline) + Text(snapshot.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + if !snapshot.followUpNote.isEmpty { + Text(snapshot.followUpNote) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .padding(.vertical, 3) + } + + private func toggleSelection(_ id: UUID) { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + } else { + selectedIDs.insert(id) + } + if selectedIDs.isEmpty { + isSelecting = false + } + } + + private func delete(_ snapshot: MoodSnapshot) { + modelContext.delete(snapshot) + try? modelContext.save() + } + + private func deleteSelected() { + snapshots + .filter { selectedIDs.contains($0.id) } + .forEach(modelContext.delete) + try? modelContext.save() + selectedIDs.removeAll() + isSelecting = false + } +} + +private struct DiaryEntryListView: View { + @Environment(\.modelContext) private var modelContext + let entries: [MoodEntry] + + @State private var selectedIDs = Set() + @State private var isSelecting = false + @State private var openedEntry: MoodEntry? + + var body: some View { + List { + if entries.isEmpty { + ContentUnavailableView( + "Keine Tagebucheinträge", + systemImage: "book.pages", + description: Text("Für diesen Zeitraum gibt es keine ausführlichen Einträge.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(entries) { entry in + entryRow(entry) + .contentShape(Rectangle()) + .onTapGesture { + if isSelecting { + toggleSelection(entry.id) + } else { + openedEntry = entry + } + } + .onLongPressGesture { + isSelecting = true + selectedIDs.insert(entry.id) + } + .swipeActions(edge: .trailing) { + deleteButton { + delete(entry) + } + } + .swipeActions(edge: .leading) { + deleteButton { + delete(entry) + } + } + } + } + } + .navigationTitle("Tagebucheinträge") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $openedEntry) { entry in + EntryDetailView(entry: entry) + } + .toolbar { + if isSelecting { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + selectedIDs.removeAll() + isSelecting = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Löschen", role: .destructive) { + deleteSelected() + } + .disabled(selectedIDs.isEmpty) + } + } + } + } + + private func entryRow(_ entry: MoodEntry) -> some View { + HStack(spacing: 12) { + if isSelecting { + Image(systemName: selectedIDs.contains(entry.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedIDs.contains(entry.id) ? .purple : .secondary) + } + + Image(systemName: entry.mood.symbol) + .font(.title2) + .foregroundStyle(entry.mood.color) + .frame(width: 42, height: 42) + .background(entry.mood.color.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(entry.mood.rawValue) + .font(.headline) + if entry.discussInTherapy { + Image(systemName: "person.2.wave.2.fill") + .foregroundStyle(.purple) + } + } + Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + if !entry.transcript.isEmpty { + Text(entry.transcript) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + if entry.musicInHead { + Image(systemName: "music.note") + .foregroundStyle(.purple) + } + } + .padding(.vertical, 3) + } + + private func toggleSelection(_ id: UUID) { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + } else { + selectedIDs.insert(id) + } + if selectedIDs.isEmpty { + isSelecting = false + } + } + + private func delete(_ entry: MoodEntry) { + modelContext.delete(entry) + try? modelContext.save() + } + + private func deleteSelected() { + entries + .filter { selectedIDs.contains($0.id) } + .forEach(modelContext.delete) + try? modelContext.save() + selectedIDs.removeAll() + isSelecting = false + } +} + +private struct TherapyEntryListView: View { + @Environment(\.modelContext) private var modelContext + let entries: [MoodEntry] + + @State private var selectedIDs = Set() + @State private var isSelecting = false + @State private var openedEntry: MoodEntry? + + var body: some View { + List { + if entries.isEmpty { + ContentUnavailableView( + "Keine markierten Einträge", + systemImage: "person.2.wave.2", + description: Text("Markiere Tagebucheinträge mit „In Therapie besprechen“, damit sie hier erscheinen.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(entries) { entry in + therapyRow(entry) + .contentShape(Rectangle()) + .onTapGesture { + if isSelecting { + toggleSelection(entry.id) + } else { + openedEntry = entry + } + } + .onLongPressGesture { + isSelecting = true + selectedIDs.insert(entry.id) + } + .swipeActions(edge: .trailing) { + Button("Entfernen") { + entry.discussInTherapy = false + try? modelContext.save() + } + .tint(.orange) + + deleteButton { + delete(entry) + } + } + .swipeActions(edge: .leading) { + deleteButton { + delete(entry) + } + } + } + } + } + .navigationTitle("Therapie") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $openedEntry) { entry in + EntryDetailView(entry: entry) + } + .toolbar { + if isSelecting { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + selectedIDs.removeAll() + isSelecting = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Löschen", role: .destructive) { + deleteSelected() + } + .disabled(selectedIDs.isEmpty) + } + } + } + } + + private func therapyRow(_ entry: MoodEntry) -> some View { + HStack(spacing: 12) { + if isSelecting { + Image(systemName: selectedIDs.contains(entry.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedIDs.contains(entry.id) ? .purple : .secondary) + } + + Image(systemName: "person.2.wave.2.fill") + .font(.title3) + .foregroundStyle(.purple) + .frame(width: 42, height: 42) + .background(Color.purple.opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(entry.mood.rawValue) + .font(.headline) + .foregroundStyle(.primary) + Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + if !entry.transcript.isEmpty { + Text(entry.transcript) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .padding(.vertical, 3) + } + + private func toggleSelection(_ id: UUID) { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + } else { + selectedIDs.insert(id) + } + if selectedIDs.isEmpty { + isSelecting = false + } + } + + private func delete(_ entry: MoodEntry) { + modelContext.delete(entry) + try? modelContext.save() + } + + private func deleteSelected() { + entries + .filter { selectedIDs.contains($0.id) } + .forEach(modelContext.delete) + try? modelContext.save() + selectedIDs.removeAll() + isSelecting = false + } +} + +private func deleteButton(_ action: @escaping @MainActor () -> Void) -> some View { + Button(role: .destructive) { + action() + } label: { + Label("Löschen", systemImage: "trash") + } +} + +struct EntryDetailView: View { + let entry: MoodEntry + + @Environment(\.modelContext) private var modelContext + @State private var showsEditor = false + @State private var customCategories: [DiaryCategory] = [] + + var body: some View { + List { + Section("Eintrag") { + LabeledContent("Datum", value: entry.createdAt.formatted(date: .long, time: .shortened)) + LabeledContent("Stimmung", value: entry.mood.rawValue) + LabeledContent("Stress", value: entry.stress ? "Ja" : "Nein") + LabeledContent("Sozialkontakt", value: entry.socialContact ? "Ja" : "Nein") + LabeledContent("Therapie", value: entry.discussInTherapy ? "Markiert" : "Nicht markiert") + + Button { + entry.discussInTherapy.toggle() + try? modelContext.save() + } label: { + Label( + entry.discussInTherapy + ? "Nicht mehr in Therapie besprechen" + : "In Therapie besprechen", + systemImage: entry.discussInTherapy + ? "person.2.wave.2.fill" + : "person.2.wave.2" + ) + } + } + + if !customCategories.isEmpty { + Section("Eigene Kategorien") { + ForEach(customCategories) { category in + LabeledContent( + category.name, + value: entry.customCategoryValues[category.id.uuidString, default: false] ? "Ja" : "Nein" + ) + } + } + } + + if !entry.transcript.isEmpty { + Section("Tagebuchtext") { + Text(entry.transcript) + } + } + } + .navigationTitle(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + entry.discussInTherapy.toggle() + try? modelContext.save() + } label: { + Image(systemName: entry.discussInTherapy ? "person.2.wave.2.fill" : "person.2.wave.2") + } + .accessibilityLabel(entry.discussInTherapy ? "Nicht mehr in Therapie besprechen" : "In Therapie besprechen") + + Button("Bearbeiten") { + showsEditor = true + } + } + } + .sheet(isPresented: $showsEditor) { + EditEntryView(entry: entry) + } + .task { + customCategories = DiaryCategoryStore.load() + } + } +} + +private struct EditEntryView: View { + let entry: MoodEntry + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var createdAt: Date + @State private var mood: MoodRating + @State private var musicInHead: Bool + @State private var songName: String + @State private var earwormStrength: EarwormStrength + @State private var socialContact: Bool + @State private var distress: DistressLevel + @State private var countermeasures: Bool + @State private var countermeasureActivity: String + @State private var stress: Bool + @State private var transcript: String + @State private var discussInTherapy: Bool + @State private var customCategories: [DiaryCategory] + @State private var customCategoryValues: [String: Bool] + + init(entry: MoodEntry) { + self.entry = entry + _createdAt = State(initialValue: entry.createdAt) + _mood = State(initialValue: entry.mood) + _musicInHead = State(initialValue: entry.musicInHead) + _songName = State(initialValue: entry.songName) + _earwormStrength = State(initialValue: EarwormStrength.normalized(entry.earwormStrengthRaw)) + _socialContact = State(initialValue: entry.socialContact) + _distress = State(initialValue: DistressLevel.normalized(entry.distressRaw)) + _countermeasures = State(initialValue: entry.countermeasures) + _countermeasureActivity = State(initialValue: entry.countermeasureActivity) + _stress = State(initialValue: entry.stress) + _transcript = State(initialValue: entry.transcript) + _discussInTherapy = State(initialValue: entry.discussInTherapy) + let categories = DiaryCategoryStore.load() + _customCategories = State(initialValue: categories) + _customCategoryValues = State(initialValue: entry.customCategoryValues) + } + + var body: some View { + NavigationStack { + Form { + Section("Eintrag") { + DatePicker("Datum", selection: $createdAt) + + Picker("Stimmung", selection: $mood) { + ForEach(MoodRating.allCases) { mood in + Text(mood.rawValue).tag(mood) + } + } + + Toggle("Sozialkontakt", isOn: $socialContact) + Toggle("Stress", isOn: $stress) + Toggle("In Therapie besprechen", isOn: $discussInTherapy) + } + + if !customCategories.isEmpty { + Section("Eigene Kategorien") { + ForEach(customCategories) { category in + Toggle(category.name, isOn: customCategoryBinding(for: category)) + } + } + } + + Section("Tagebuchtext") { + TextEditor(text: $transcript) + .frame(minHeight: 140) + } + } + .navigationTitle("Eintrag bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern") { + save() + } + } + } + } + .appKeyboardBehavior() + } + + private func save() { + entry.createdAt = createdAt + entry.moodRaw = mood.rawValue + entry.musicInHead = musicInHead + entry.songName = musicInHead ? songName : "" + entry.earwormStrengthRaw = musicInHead ? earwormStrength.rawValue : "" + entry.socialContact = socialContact + entry.distressRaw = musicInHead ? distress.rawValue : "" + entry.countermeasures = musicInHead && countermeasures + entry.countermeasureActivity = musicInHead && countermeasures + ? countermeasureActivity + : "" + entry.stress = stress + entry.transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + entry.discussInTherapy = discussInTherapy + entry.customCategoryValuesJSON = DiaryCategoryStore.encodeValues(customCategoryValues) + + try? modelContext.save() + dismiss() + } + + private func customCategoryBinding(for category: DiaryCategory) -> Binding { + Binding( + get: { customCategoryValues[category.id.uuidString, default: false] }, + set: { customCategoryValues[category.id.uuidString] = $0 } + ) + } +} diff --git a/FeelAloud/HomeView.swift b/FeelAloud/HomeView.swift new file mode 100644 index 0000000..292fb23 --- /dev/null +++ b/FeelAloud/HomeView.swift @@ -0,0 +1,221 @@ +import SwiftData +import SwiftUI + +struct HomeView: View { + @Environment(AppRouter.self) private var router + @Environment(\.appColorProfile) private var colorProfile + @Query(sort: \MoodEntry.createdAt, order: .reverse) private var entries: [MoodEntry] + @Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot] + @State private var headerMessage = HomeHeaderMessage.random() + + private var recentEntries: [MoodEntry] { + let start = Calendar.current.date(byAdding: .day, value: -7, to: .now) ?? .distantPast + return entries.filter { $0.createdAt >= start } + } + + private var recentSnapshots: [MoodSnapshot] { + let start = Calendar.current.date(byAdding: .day, value: -7, to: .now) ?? .distantPast + return snapshots.filter { $0.createdAt >= start } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 18) { + appHeader + welcomeCard + snapshotButton + weekOverview + + if let latest = entries.first { + latestEntry(latest) + } + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .toolbar(.hidden, for: .navigationBar) + } + } + + private var appHeader: some View { + HStack(spacing: 12) { + Image(systemName: "heart.text.square.fill") + .font(.title2) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(colorProfile.accent, in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text("Feel Aloud") + .font(.title3.bold()) + Text(headerMessage.text) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.top, 4) + .onAppear { + headerMessage = HomeHeaderMessage.random() + } + } + + private var snapshotButton: some View { + Button { + router.presentSnapshot(source: .manual) + } label: { + HStack(spacing: 12) { + Text("🙂") + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text("Momentaufnahme") + .font(.headline) + Text("Wie geht es dir gerade? Ein Tipp genügt.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 18)) + } + .buttonStyle(.plain) + } + + private var welcomeCard: some View { + Button { + router.presentNewEntry(startRecording: false) + } label: { + HStack(spacing: 15) { + Image(systemName: "waveform.and.mic") + .font(.system(size: 34)) + .foregroundStyle(.white) + .frame(width: 62, height: 62) + .background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 18)) + + VStack(alignment: .leading, spacing: 5) { + Text("Wie geht es dir gerade?") + .font(.title3.bold()) + Text("Erzähl einfach frei. Dein iPhone sortiert die Angaben lokal für dich.") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.86)) + } + Spacer(minLength: 0) + Image(systemName: "arrow.right.circle.fill") + .font(.title2) + .foregroundStyle(.white.opacity(0.9)) + } + .foregroundStyle(.white) + .padding(18) + .background( + LinearGradient( + colors: colorProfile.gradient, + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 24) + ) + } + .buttonStyle(.plain) + .accessibilityHint("Startet eine lokale Sprachaufnahme") + } + + private var weekOverview: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Letzte 7 Tage") + .font(.headline) + + HStack(spacing: 10) { + metric( + value: "\(recentSnapshots.count)", + label: "Checks", + symbol: "face.smiling" + ) + metric( + value: averageSnapshotText, + label: "Ø Gefühl", + symbol: "chart.line.uptrend.xyaxis" + ) + metric( + value: "\(recentEntries.count)", + label: "Tagebuch", + symbol: "heart.text.square" + ) + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 18)) + } + + private func metric(value: String, label: String, symbol: String) -> some View { + VStack(spacing: 6) { + Image(systemName: symbol) + .foregroundStyle(colorProfile.accent) + Text(value) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.75) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private func latestEntry(_ entry: MoodEntry) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Letzter Eintrag") + .font(.headline) + + HStack(spacing: 12) { + Image(systemName: entry.mood.symbol) + .font(.title) + .foregroundStyle(entry.mood.color) + .frame(width: 50, height: 50) + .background(entry.mood.color.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(entry.mood.rawValue) + .font(.headline) + Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + if entry.musicInHead { + Image(systemName: "music.note") + .foregroundStyle(colorProfile.accent) + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 18)) + } + + private var averageSnapshotText: String { + guard !recentSnapshots.isEmpty else { return "–" } + let average = recentSnapshots.map(\.rating.score).reduce(0, +) + / Double(recentSnapshots.count) + return SnapshotRating.label(for: average) + } +} + +private enum HomeHeaderMessage: String, CaseIterable { + case one = "Ein kleiner Moment reicht, um dich selbst besser zu verstehen." + case two = "Du musst es nicht perfekt sagen. Fang einfach an." + case three = "Heute zählt nicht laut oder leise, sondern ehrlich." + case four = "Ein Gedanke nach dem anderen ist genug." + case five = "Deine Notizen sind ein Werkzeug, kein Urteil." + + var text: String { rawValue } + + static func random() -> HomeHeaderMessage { + allCases.randomElement() ?? .one + } +} diff --git a/FeelAloud/MoodAnalysisService.swift b/FeelAloud/MoodAnalysisService.swift new file mode 100644 index 0000000..9598411 --- /dev/null +++ b/FeelAloud/MoodAnalysisService.swift @@ -0,0 +1,92 @@ +import Foundation +import FoundationModels + +@Generable +private struct GeneratedDiaryEntry { + @Guide(description: "Aktuelle Stimmung", .anyOf(["Sehr gut", "Gut", "Mittel", "Schlecht", "Sehr schlecht"])) + var mood: String + + @Guide(description: "Ob die Person gerade Musik oder einen Song im Kopf hat") + var musicInHead: Bool + + @Guide(description: "Name des Songs; leer lassen, wenn kein Song genannt wurde") + var songName: String + + @Guide(description: "Stärke des Ohrwurms", .anyOf(["Leise", "Mittel", "Laut"])) + var earwormStrength: String + + @Guide(description: "Ob heute Sozialkontakt stattgefunden hat") + var socialContact: Bool + + @Guide(description: "Leidensdruck durch den Ohrwurm", .anyOf(["Niedrig", "Mittel", "Hoch"])) + var distress: String + + @Guide(description: "Ob Gegenmaßnahmen gegen den Ohrwurm versucht wurden") + var countermeasures: Bool + + @Guide(description: "Genannte Freizeitaktivität oder Gegenmaßnahme; sonst leer") + var countermeasureActivity: String + + @Guide(description: "Ob Stress vorhanden ist") + var stress: Bool +} + +enum MoodAnalysisError: LocalizedError { + case emptyTranscript + case modelUnavailable + + var errorDescription: String? { + switch self { + case .emptyTranscript: + "Es wurde noch kein Text erkannt. Sprich etwas länger oder trage die Angaben manuell ein." + case .modelUnavailable: + "Apples lokales KI-Modell ist noch nicht verfügbar. Prüfe, ob Apple Intelligence aktiviert und vollständig geladen ist." + } + } +} + +struct MoodAnalysisService { + static func analyze(_ transcript: String) async throws -> DraftMoodEntry { + let cleanTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanTranscript.isEmpty else { + throw MoodAnalysisError.emptyTranscript + } + + let model = SystemLanguageModel.default + guard model.availability == .available else { + throw MoodAnalysisError.modelUnavailable + } + + let session = LanguageModelSession( + model: model, + instructions: """ + Du extrahierst ausschließlich strukturierte Angaben aus einem privaten deutschen Stimmungstagebuch. + Erfinde keine Songnamen, Aktivitäten oder Erlebnisse. + Wenn etwas nicht erwähnt wird, verwende für Text ein leeres Feld und für Ja/Nein den Wert false. + Wenn Musik im Kopf vorhanden ist, ist Stress für diesen Eintrag false. + Wenn keine Musik im Kopf vorhanden ist, bleiben Songname, Ohrwurmstärke, Leidensdruck und Gegenmaßnahmen auf ihren neutralen Standardwerten. + Ordne die Stimmung auf der Skala Sehr gut, Gut, Mittel, Schlecht, Sehr schlecht ein. + Du gibst keine medizinische Einschätzung und keinen Rat. + """ + ) + + let response = try await session.respond( + to: "Analysiere diesen Tagebucheintrag:\n\n\(cleanTranscript)", + generating: GeneratedDiaryEntry.self + ) + let value = response.content + + return DraftMoodEntry( + mood: .normalized(value.mood), + musicInHead: value.musicInHead, + songName: value.songName, + earwormStrength: .normalized(value.earwormStrength), + socialContact: value.socialContact, + distress: .normalized(value.distress), + countermeasures: value.countermeasures, + countermeasureActivity: value.countermeasureActivity, + stress: value.stress, + transcript: cleanTranscript + ) + } +} diff --git a/FeelAloud/MoodEntry.swift b/FeelAloud/MoodEntry.swift new file mode 100644 index 0000000..f53bb61 --- /dev/null +++ b/FeelAloud/MoodEntry.swift @@ -0,0 +1,195 @@ +import Foundation +import SwiftData +import SwiftUI + +enum MoodRating: String, CaseIterable, Identifiable, Codable, Sendable { + case veryGood = "Sehr gut" + case good = "Gut" + case middle = "Mittel" + case bad = "Schlecht" + case veryBad = "Sehr schlecht" + + var id: String { rawValue } + + var score: Double { + switch self { + case .veryGood: 5 + case .good: 4 + case .middle: 3 + case .bad: 2 + case .veryBad: 1 + } + } + + var symbol: String { + switch self { + case .veryGood: "face.smiling.inverse" + case .good: "face.smiling" + case .middle: "face.dashed" + case .bad: "cloud.rain" + case .veryBad: "cloud.bolt.rain" + } + } + + var color: Color { + switch self { + case .veryGood: .green + case .good: .mint + case .middle: .orange + case .bad: .indigo + case .veryBad: .purple + } + } + + static func normalized(_ value: String) -> MoodRating { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if text.contains("sehr schlecht") || text.contains("very bad") { return .veryBad } + if text.contains("schlecht") || text.contains("bad") { return .bad } + if text.contains("sehr gut") || text.contains("very good") { return .veryGood } + if text.contains("gut") || text.contains("good") { return .good } + return .middle + } +} + +enum EarwormStrength: String, CaseIterable, Identifiable, Codable, Sendable { + case quiet = "Leise" + case medium = "Mittel" + case loud = "Laut" + + var id: String { rawValue } + + static func normalized(_ value: String) -> EarwormStrength { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if text.contains("laut") || text.contains("strong") { return .loud } + if text.contains("leise") || text.contains("quiet") { return .quiet } + return .medium + } +} + +enum DistressLevel: String, CaseIterable, Identifiable, Codable, Sendable { + case low = "Niedrig" + case medium = "Mittel" + case high = "Hoch" + + var id: String { rawValue } + + static func normalized(_ value: String) -> DistressLevel { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if text.contains("hoch") || text.contains("high") { return .high } + if text.contains("niedrig") || text.contains("low") { return .low } + return .medium + } +} + +struct DraftMoodEntry: Sendable { + var mood: MoodRating = .middle + var musicInHead = false + var songName = "" + var earwormStrength: EarwormStrength = .medium + var socialContact = false + var distress: DistressLevel = .low + var countermeasures = false + var countermeasureActivity = "" + var stress = false + var transcript = "" + var discussInTherapy = false + var customCategoryValues: [String: Bool] = [:] +} + +@Model +final class MoodEntry { + var id = UUID() + var createdAt = Date() + var moodRaw = MoodRating.middle.rawValue + var musicInHead = false + var songName = "" + var earwormStrengthRaw = "" + var socialContact = false + var distressRaw = "" + var countermeasures = false + var countermeasureActivity = "" + var stress = false + var transcript = "" + var discussInTherapy = false + var customCategoryValuesJSON = "{}" + + init(draft: DraftMoodEntry, createdAt: Date = .now) { + id = UUID() + self.createdAt = createdAt + moodRaw = draft.mood.rawValue + musicInHead = draft.musicInHead + songName = draft.musicInHead ? draft.songName : "" + earwormStrengthRaw = draft.musicInHead ? draft.earwormStrength.rawValue : "" + socialContact = draft.socialContact + distressRaw = draft.musicInHead ? draft.distress.rawValue : "" + countermeasures = draft.musicInHead && draft.countermeasures + countermeasureActivity = draft.musicInHead && draft.countermeasures + ? draft.countermeasureActivity + : "" + stress = !draft.musicInHead && draft.stress + transcript = draft.transcript + discussInTherapy = draft.discussInTherapy + customCategoryValuesJSON = DiaryCategoryStore.encodeValues(draft.customCategoryValues) + } + + var mood: MoodRating { + MoodRating.normalized(moodRaw) + } + + var customCategoryValues: [String: Bool] { + DiaryCategoryStore.decodeValues(customCategoryValuesJSON) + } +} + +struct DiaryCategory: Identifiable, Codable, Hashable, Sendable { + var id: UUID + var name: String + + init(id: UUID = UUID(), name: String) { + self.id = id + self.name = name + } +} + +enum DiaryCategoryStore { + static let storageKey = "customDiaryCategories" + + static func load() -> [DiaryCategory] { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let categories = try? JSONDecoder().decode([DiaryCategory].self, from: data) else { + return [] + } + + return categories + .filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + static func save(_ categories: [DiaryCategory]) { + let cleaned = categories + .map { DiaryCategory(id: $0.id, name: $0.name.trimmingCharacters(in: .whitespacesAndNewlines)) } + .filter { !$0.name.isEmpty } + + if let data = try? JSONEncoder().encode(cleaned) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } + + static func encodeValues(_ values: [String: Bool]) -> String { + guard let data = try? JSONEncoder().encode(values), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + + return json + } + + static func decodeValues(_ json: String) -> [String: Bool] { + guard let data = json.data(using: .utf8), + let values = try? JSONDecoder().decode([String: Bool].self, from: data) else { + return [:] + } + + return values + } +} diff --git a/FeelAloud/MoodSnapshot.swift b/FeelAloud/MoodSnapshot.swift new file mode 100644 index 0000000..a87c718 --- /dev/null +++ b/FeelAloud/MoodSnapshot.swift @@ -0,0 +1,144 @@ +import Foundation +import SwiftData +import SwiftUI + +enum SnapshotRating: String, CaseIterable, Identifiable, Codable, Sendable { + case veryGood = "Sehr gut" + case good = "Gut" + case middle = "Mittel" + case bad = "Schlecht" + case veryBad = "Sehr schlecht" + + var id: String { rawValue } + + var score: Double { + switch self { + case .veryGood: 5 + case .good: 4 + case .middle: 3 + case .bad: 2 + case .veryBad: 1 + } + } + + var emoji: String { + switch self { + case .veryGood: "😄" + case .good: "🙂" + case .middle: "😐" + case .bad: "🙁" + case .veryBad: "😞" + } + } + + var color: Color { + switch self { + case .veryGood: .green + case .good: .mint + case .middle: .orange + case .bad: .indigo + case .veryBad: .purple + } + } + + var needsFollowUp: Bool { + switch self { + case .veryGood, .bad, .veryBad: true + case .good, .middle: false + } + } + + var followUpQuestion: String { + switch self { + case .veryGood: + "Was war heute besonders gut?" + case .bad, .veryBad: + "Was war heute besonders schwierig?" + case .good, .middle: + "Möchtest du noch etwas festhalten?" + } + } + + static func label(for average: Double) -> String { + if average >= 4.5 { return SnapshotRating.veryGood.rawValue } + if average >= 3.5 { return SnapshotRating.good.rawValue } + if average >= 2.5 { return SnapshotRating.middle.rawValue } + if average >= 1.5 { return SnapshotRating.bad.rawValue } + return SnapshotRating.veryBad.rawValue + } +} + +enum SnapshotSource: String, Codable, Sendable { + case notification + case manual + case widget +} + +@Model +final class MoodSnapshot { + var id = UUID() + var createdAt = Date() + var ratingRaw = SnapshotRating.middle.rawValue + var followUpNote = "" + var followUpDismissed = false + var sourceRaw = SnapshotSource.manual.rawValue + + init( + rating: SnapshotRating, + source: SnapshotSource, + createdAt: Date = .now + ) { + id = UUID() + self.createdAt = createdAt + ratingRaw = rating.rawValue + followUpNote = "" + followUpDismissed = false + sourceRaw = source.rawValue + } + + var rating: SnapshotRating { + SnapshotRating(rawValue: ratingRaw) ?? .middle + } + + var source: SnapshotSource { + SnapshotSource(rawValue: sourceRaw) ?? .manual + } + + var awaitsFollowUp: Bool { + rating.needsFollowUp + && followUpNote.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !followUpDismissed + } +} + +enum WidgetSnapshotImport { + private static let appGroupIdentifier = "group.de.feelaloud" + private static let key = "pendingWidgetSnapshots" + + static func consumePendingSnapshots() -> [MoodSnapshot] { + guard let defaults = UserDefaults(suiteName: appGroupIdentifier), + let data = defaults.data(forKey: key), + let pendingSnapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else { + return [] + } + + defaults.removeObject(forKey: key) + + return pendingSnapshots.compactMap { pendingSnapshot in + guard let rating = SnapshotRating(rawValue: pendingSnapshot.ratingRawValue) else { + return nil + } + + return MoodSnapshot( + rating: rating, + source: .widget, + createdAt: pendingSnapshot.createdAt + ) + } + } +} + +private struct PendingWidgetSnapshot: Codable { + var ratingRawValue: String + var createdAt: Date +} diff --git a/FeelAloud/PrivacyInfo.xcprivacy b/FeelAloud/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5704bed --- /dev/null +++ b/FeelAloud/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/FeelAloud/RootView.swift b/FeelAloud/RootView.swift new file mode 100644 index 0000000..ccc1970 --- /dev/null +++ b/FeelAloud/RootView.swift @@ -0,0 +1,609 @@ +import AppIntents +import PhotosUI +import SwiftData +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +struct RootView: View { + @Environment(AppRouter.self) private var router + @Environment(\.modelContext) private var modelContext + @Environment(\.scenePhase) private var scenePhase + @Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot] + @AppStorage("appLockEnabled") private var appLockEnabled = false + @AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue + @AppStorage("forceLightAppearance") private var forceLightAppearance = false + @AppStorage("lastPresentedFollowUpSnapshotID") private var lastPresentedFollowUpSnapshotID = "" + @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false + @State private var isUnlocked = false + @State private var followUpSnapshot: MoodSnapshot? + + private var colorProfile: AppColorProfile { + AppColorProfile.stored(colorProfileRaw) + } + + var body: some View { + @Bindable var router = router + + ZStack { + if hasCompletedOnboarding { + TabView { + HomeView() + .tabItem { + Label("Heute", systemImage: "heart.text.square") + } + + HistoryView() + .tabItem { + Label("Verlauf", systemImage: "chart.xyaxis.line") + } + + TherapySupportView() + .tabItem { + Label("Therapie", systemImage: "person.2.wave.2") + } + + SettingsView() + .tabItem { + Label("Einstellungen", systemImage: "gearshape") + } + } + .tint(colorProfile.accent) + + if appLockEnabled && !isUnlocked { + AppLockView(isUnlocked: $isUnlocked) + } + } else { + OnboardingView() + } + } + .preferredColorScheme(forceLightAppearance ? .light : nil) + .appKeyboardBehavior() + .environment(\.appColorProfile, colorProfile) + .sheet(isPresented: $router.showsNewEntry) { + EntryFlowView(autoStart: router.shouldStartRecording) + .environment(\.appColorProfile, colorProfile) + .interactiveDismissDisabled(false) + } + .sheet(isPresented: $router.showsSnapshot) { + SnapshotView(source: router.snapshotSource) + .environment(\.appColorProfile, colorProfile) + .presentationDetents([.large]) + } + .sheet(item: $followUpSnapshot) { snapshot in + SnapshotFollowUpView(snapshot: snapshot) + .environment(\.appColorProfile, colorProfile) + .presentationDetents([.large]) + } + .onAppIntentExecution(StartDiaryEntryIntent.self) { _ in + router.presentNewEntry(startRecording: true) + } + .task { + guard hasCompletedOnboarding else { return } + + isUnlocked = !appLockEnabled + importWidgetSnapshots() + await presentPendingSnapshotIfNeeded() + if isUnlocked { + await checkForFollowUp() + } + } + .onChange(of: appLockEnabled) { _, enabled in + isUnlocked = !enabled + } + .onChange(of: hasCompletedOnboarding) { _, completed in + guard completed else { return } + + isUnlocked = !appLockEnabled + importWidgetSnapshots() + Task { + await presentPendingSnapshotIfNeeded() + if isUnlocked { + await checkForFollowUp() + } + } + } + .onChange(of: isUnlocked) { _, unlocked in + guard unlocked else { return } + Task { await checkForFollowUp() } + } + .onChange(of: router.pendingSnapshotRequestID) { _, _ in + Task { await presentPendingSnapshotIfNeeded() } + } + .onChange(of: scenePhase) { oldPhase, newPhase in + if appLockEnabled, newPhase != .active { + isUnlocked = false + } + + guard oldPhase != .active, newPhase == .active else { return } + importWidgetSnapshots() + Task { + await presentPendingSnapshotIfNeeded() + if isUnlocked { + await checkForFollowUp() + } + } + } + } + + private func importWidgetSnapshots() { + let snapshots = WidgetSnapshotImport.consumePendingSnapshots() + guard !snapshots.isEmpty else { return } + + for snapshot in snapshots { + modelContext.insert(snapshot) + } + + try? modelContext.save() + } + + @MainActor + private func presentPendingSnapshotIfNeeded() async { + let hasStoredNotification = SnapshotNotificationScheduler.consumePendingSnapshotPresentation() + guard router.pendingSnapshotSource != nil || hasStoredNotification else { return } + try? await Task.sleep(for: .milliseconds(700)) + + let source = router.consumePendingSnapshotSource() + ?? (hasStoredNotification ? SnapshotSource.notification : nil) + + guard !router.showsNewEntry, + !router.showsSnapshot, + followUpSnapshot == nil, + let source else { + return + } + + followUpSnapshot = nil + router.presentSnapshot(source: source) + } + + @MainActor + private func checkForFollowUp() async { + try? await Task.sleep(for: .milliseconds(450)) + + if router.suppressesNextFollowUpCheck { + router.suppressesNextFollowUpCheck = false + return + } + + guard !router.showsNewEntry, + !router.showsSnapshot, + followUpSnapshot == nil else { + return + } + + followUpSnapshot = snapshots.first { snapshot in + Calendar.current.isDateInToday(snapshot.createdAt) + && snapshot.awaitsFollowUp + && snapshot.id.uuidString != lastPresentedFollowUpSnapshotID + } + if let followUpSnapshot { + lastPresentedFollowUpSnapshotID = followUpSnapshot.id.uuidString + } + } +} + +enum UserProfileStore { + static let onboardingCompletedKey = "hasCompletedOnboarding" + static let displayNameKey = "userDisplayName" + static let profileImageDataKey = "userProfileImageData" + + static func saveProfile(displayName: String, imageData: Data?) { + UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey) + + if let imageData { + UserDefaults.standard.set(imageData, forKey: profileImageDataKey) + } + } +} + +private struct OnboardingView: View { + @Environment(\.appColorProfile) private var colorProfile + @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false + @AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = "" + + @State private var page = 0 + @State private var displayName = "" + @State private var selectedPhoto: PhotosPickerItem? + @State private var profileImageData: Data? + @State private var customCategories: [DiaryCategory] = [] + @State private var newCategoryName = "" + + var body: some View { + ZStack { + LinearGradient( + colors: [colorProfile.softBackground, Color(.systemBackground)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 18) { + progressDots + + TabView(selection: $page) { + welcomePage.tag(0) + diaryPage.tag(1) + snapshotPage.tag(2) + profilePage.tag(3) + categoriesPage.tag(4) + accountPage.tag(5) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + + navigationControls + } + .padding(.horizontal, 22) + .padding(.vertical, 18) + } + .appKeyboardBehavior() + .onAppear { + displayName = storedDisplayName + customCategories = DiaryCategoryStore.load() + } + .onChange(of: selectedPhoto) { _, item in + Task { await loadSelectedPhoto(item) } + } + } + + private var progressDots: some View { + HStack(spacing: 7) { + ForEach(0..<6, id: \.self) { index in + Capsule() + .fill(index == page ? colorProfile.accent : Color.secondary.opacity(0.25)) + .frame(width: index == page ? 26 : 8, height: 8) + .animation(.snappy, value: page) + } + } + .padding(.top, 8) + } + + private var welcomePage: some View { + onboardingPage( + symbol: "heart.text.square.fill", + title: "Willkommen bei Feel Aloud", + text: "Die App hilft dir, Gedanken, Stimmung und wichtige Therapiethemen schnell festzuhalten, ohne dass daraus ein kompliziertes Tagebuch wird.", + rows: [ + OnboardingRow(symbol: "waveform", title: "Sprechen oder schreiben", text: "Halte fest, was gerade da ist."), + OnboardingRow(symbol: "lock.shield", title: "Privat zuerst", text: "Deine Einträge bleiben auf deinem Gerät und optional in deiner iCloud."), + OnboardingRow(symbol: "chart.xyaxis.line", title: "Muster erkennen", text: "Verlauf und Exporte helfen bei Therapiegesprächen.") + ] + ) + } + + private var diaryPage: some View { + onboardingPage( + symbol: "mic.badge.plus", + title: "Tagebuch ohne Hürde", + text: "Du kannst frei erzählen oder tippen. Feel Aloud sortiert den Eintrag anschließend in verständliche Kategorien ein.", + rows: [ + OnboardingRow(symbol: "brain.head.profile", title: "Reflexion", text: "Gedanken werden zu greifbaren Notizen."), + OnboardingRow(symbol: "tag", title: "Kategorien", text: "Stress, Sozialkontakte und eigene Themen bleiben sichtbar."), + OnboardingRow(symbol: "person.2.wave.2", title: "Therapie", text: "Wichtige Einträge kannst du gezielt markieren.") + ] + ) + } + + private var snapshotPage: some View { + onboardingPage( + symbol: "face.smiling", + title: "Kurze Momentaufnahme", + text: "Mit fünf einfachen Stimmungen trackst du schnell, wie es dir im Alltag geht, auch über Erinnerungen oder das Widget.", + rows: MoodRating.allCases.map { rating in + OnboardingRow(symbol: rating.symbol, title: rating.rawValue, text: "Ein schneller Check-in für deinen Verlauf.") + } + ) + } + + private var profilePage: some View { + let imageData = profileImageData + let accent = colorProfile.accent + let softBackground = colorProfile.softBackground + + return VStack(spacing: 20) { + onboardingHeader( + symbol: "person.crop.circle", + title: "Dein Profil", + text: "Diese Angaben helfen, die App persönlicher zu machen. Das Profilbild ist optional." + ) + + VStack(spacing: 16) { + PhotosPicker(selection: $selectedPhoto, matching: .images) { + OnboardingProfileImage( + imageData: imageData, + accent: accent, + softBackground: softBackground + ) + .overlay(alignment: .bottomTrailing) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(accent) + .background(.background, in: Circle()) + } + } + .buttonStyle(.plain) + + TextField("Benutzername", text: $displayName) + .textInputAutocapitalization(.words) + .submitLabel(.done) + .padding(14) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + + Text("Du kannst den Namen später weiterverwenden, ohne medizinische Daten preiszugeben.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(16) + .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) + + Spacer(minLength: 0) + } + } + + private var categoriesPage: some View { + VStack(spacing: 20) { + onboardingHeader( + symbol: "tag.fill", + title: "Deine Themen", + text: "Stimmung, Stress und Sozialkontakte sind voreingestellt. Ergänze hier Themen, die für dich in Therapie oder Alltag wichtig sind." + ) + + VStack(alignment: .leading, spacing: 12) { + Text("Voreingestellt") + .font(.headline) + tagFlow(["Stimmung", "Stress", "Sozialkontakte"]) + + Divider() + + Text("Eigene Kategorien") + .font(.headline) + + if customCategories.isEmpty { + Text("Noch keine eigenen Kategorien angelegt.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + tagFlow(customCategories.map(\.name)) + } + + HStack { + TextField("Neue Kategorie", text: $newCategoryName) + .submitLabel(.done) + .onSubmit(addCategory) + Button { + addCategory() + } label: { + Image(systemName: "plus.circle.fill") + .font(.title3) + } + .disabled(newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + } + .padding(16) + .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) + + Spacer(minLength: 0) + } + } + + private var accountPage: some View { + VStack(spacing: 20) { + onboardingHeader( + symbol: "icloud.and.arrow.up.fill", + title: "Zugang sichern", + text: "Zum Abschluss wird dein lokales Profil gespeichert. Premium-Käufe laufen später über deine Apple-ID, und deine Daten können über iCloud synchronisiert werden, sobald die iCloud-Funktion aktiv ist." + ) + + VStack(alignment: .leading, spacing: 14) { + accountRow(symbol: "person.crop.circle.badge.checkmark", title: "Profil", text: displayName.isEmpty ? "Ohne Namen fortfahren" : displayName) + accountRow(symbol: "icloud", title: "Datenzugang", text: "Über deinen Apple-Account und iCloud absichern") + accountRow(symbol: "checkmark.seal", title: "Premium", text: "Käufe können über die Apple-ID wiederhergestellt werden") + } + .padding(16) + .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) + + Text("Das ist keine medizinische Diagnose und ersetzt keine Behandlung. Es ist ein Werkzeug zur persönlichen Dokumentation und Vorbereitung auf Gespräche.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Spacer(minLength: 0) + } + } + + private var navigationControls: some View { + HStack(spacing: 12) { + if page > 0 { + Button("Zurück") { + withAnimation(.snappy) { page -= 1 } + } + .buttonStyle(.bordered) + } + + Button { + if page < 5 { + withAnimation(.snappy) { page += 1 } + } else { + finishOnboarding() + } + } label: { + Text(page < 5 ? "Weiter" : "Registrierung abschließen") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + } + } + + private func onboardingPage(symbol: String, title: String, text: String, rows: [OnboardingRow]) -> some View { + VStack(spacing: 20) { + onboardingHeader(symbol: symbol, title: title, text: text) + + VStack(spacing: 12) { + ForEach(rows) { row in + HStack(spacing: 12) { + Image(systemName: row.symbol) + .font(.headline) + .foregroundStyle(colorProfile.accent) + .frame(width: 34, height: 34) + .background(colorProfile.softBackground, in: RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(.headline) + Text(row.text) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + .padding(12) + .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 14)) + } + } + + Spacer(minLength: 0) + } + } + + private func onboardingHeader(symbol: String, title: String, text: String) -> some View { + VStack(spacing: 14) { + Image(systemName: symbol) + .font(.system(size: 54, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 104, height: 104) + .background( + LinearGradient(colors: colorProfile.gradient, startPoint: .topLeading, endPoint: .bottomTrailing), + in: RoundedRectangle(cornerRadius: 28) + ) + .shadow(color: colorProfile.accent.opacity(0.24), radius: 20, y: 10) + + VStack(spacing: 8) { + Text(title) + .font(.title.bold()) + .multilineTextAlignment(.center) + Text(text) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(.top, 10) + } + + private func tagFlow(_ tags: [String]) -> some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 118), spacing: 8)], spacing: 8) { + ForEach(tags, id: \.self) { tag in + Text(tag) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(colorProfile.softBackground, in: Capsule()) + .foregroundStyle(colorProfile.accent) + } + } + } + + private func accountRow(symbol: String, title: String, text: String) -> some View { + HStack(spacing: 12) { + Image(systemName: symbol) + .foregroundStyle(colorProfile.accent) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + Text(text) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private func addCategory() { + let name = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + guard !customCategories.contains(where: { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }) else { + newCategoryName = "" + return + } + + customCategories.append(DiaryCategory(name: name)) + customCategories.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + DiaryCategoryStore.save(customCategories) + newCategoryName = "" + } + + private func finishOnboarding() { + UserProfileStore.saveProfile(displayName: displayName, imageData: profileImageData) + DiaryCategoryStore.save(customCategories) + hasCompletedOnboarding = true + } + + private func loadSelectedPhoto(_ item: PhotosPickerItem?) async { + guard let item, + let data = try? await item.loadTransferable(type: Data.self) else { + return + } + + #if canImport(UIKit) + guard let image = UIImage(data: data), + let compressedData = image.jpegData(compressionQuality: 0.72) else { + profileImageData = data + return + } + profileImageData = compressedData + #else + profileImageData = data + #endif + } +} + +private struct OnboardingRow: Identifiable { + let id = UUID() + let symbol: String + let title: String + let text: String +} + +private struct OnboardingProfileImage: View { + let imageData: Data? + let accent: Color + let softBackground: Color + + var body: some View { + content + .frame(width: 112, height: 112) + .clipShape(Circle()) + } + + @ViewBuilder + private var content: some View { + #if canImport(UIKit) + if let imageData, let image = UIImage(data: imageData) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + placeholder + } + #else + placeholder + #endif + } + + private var placeholder: some View { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(accent) + .frame(width: 112, height: 112) + .background(softBackground) + } +} diff --git a/FeelAloud/SettingsView.swift b/FeelAloud/SettingsView.swift new file mode 100644 index 0000000..48b2ab5 --- /dev/null +++ b/FeelAloud/SettingsView.swift @@ -0,0 +1,737 @@ +import AppIntents +import FoundationModels +import SwiftData +import SwiftUI +import UserNotifications +#if canImport(UIKit) +import UIKit +#endif + +struct SettingsView: View { + @Environment(\.openURL) private var openURL + @Environment(PremiumStore.self) private var premiumStore + @Query(sort: \MoodEntry.createdAt) private var entries: [MoodEntry] + + @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false + @AppStorage(UserProfileStore.displayNameKey) private var displayName = "" + @AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data() + @AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false + @AppStorage("snapshotReminderCount") private var reminderCount = 3 + @AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60 + @AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60 + @AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60 + @AppStorage("appLockEnabled") private var appLockEnabled = false + @AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue + @AppStorage("forceLightAppearance") private var forceLightAppearance = false + @AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false + @AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false + + @State private var notificationStatus = "Wird geprüft …" + @State private var notificationMessage: String? + @State private var iCloudStatus = "Noch nicht aktiviert" + @State private var exportFile: SettingsExportFile? + @State private var exportError: String? + @State private var appLockMessage: String? + @State private var customCategories: [DiaryCategory] = [] + @State private var newCategoryName = "" + @State private var profileMessage: String? + @State private var showsLogoutConfirmation = false + + var body: some View { + NavigationStack { + List { + Section("Erfassung") { + NavigationLink { + remindersSettings + } label: { + settingsNavigationRow( + title: "Momentaufnahmen", + subtitle: remindersEnabled ? "\(safeReminderCount) Erinnerungen pro Tag" : "Erinnerungen und Status", + systemImage: "bell.badge" + ) + } + + NavigationLink { + categoriesSettings + } label: { + settingsNavigationRow( + title: "Kategorien", + subtitle: "\(customCategories.count) eigene Kategorien", + systemImage: "tag" + ) + } + } + + Section("Privatsphäre") { + NavigationLink { + profileSettings + } label: { + settingsNavigationRow( + title: "Profil", + subtitle: displayName.isEmpty ? "Account, Premium und Logout" : displayName, + systemImage: "person.crop.circle" + ) + } + + NavigationLink { + privacySettings + } label: { + settingsNavigationRow( + title: "Datenschutz", + subtitle: "App-Sperre und lokale KI", + systemImage: "lock.shield" + ) + } + + NavigationLink { + syncSettings + } label: { + settingsNavigationRow( + title: "iCloud Sync", + subtitle: "Account und Synchronisation", + systemImage: "icloud" + ) + } + } + + Section("App") { + NavigationLink { + appearanceSettings + } label: { + settingsNavigationRow( + title: "Darstellung", + subtitle: AppColorProfile(rawValue: colorProfileRaw)?.name ?? "Farbprofil", + systemImage: "paintpalette" + ) + } + + NavigationLink { + exportSettings + } label: { + settingsNavigationRow( + title: "Daten und Export", + subtitle: "Reports, CSV und Speicherstatus", + systemImage: "square.and.arrow.up" + ) + } + + NavigationLink { + premiumSettings + } label: { + settingsNavigationRow( + title: "Unterstützen", + subtitle: premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen testen", + systemImage: "heart" + ) + } + } + + Section("System") { + NavigationLink { + shortcutsSettings + } label: { + settingsNavigationRow( + title: "Kurzbefehle", + subtitle: "Action Button einrichten", + systemImage: "button.programmable" + ) + } + + NavigationLink { + appInfoSettings + } label: { + settingsNavigationRow( + title: "App-Info", + subtitle: "Hinweise zur Nutzung", + systemImage: "info.circle" + ) + } + } + } + .navigationTitle("Einstellungen") + .navigationBarTitleDisplayMode(.inline) + } + .appKeyboardBehavior() + .task { + customCategories = DiaryCategoryStore.load() + await refreshNotificationStatus() + if remindersEnabled { + await configureReminders(requestAuthorization: false) + } + } + .sheet(item: $exportFile) { file in + ShareSheet(items: [file.url]) + } + .alert("Export nicht möglich", isPresented: exportErrorBinding) { + Button("OK", role: .cancel) { } + } message: { + Text(exportError ?? "Unbekannter Fehler") + } + .alert("Profil", isPresented: profileMessageBinding) { + Button("OK", role: .cancel) { } + } message: { + Text(profileMessage ?? "") + } + .confirmationDialog("Logout", isPresented: $showsLogoutConfirmation, titleVisibility: .visible) { + Button("Lokales Profil abmelden", role: .destructive) { + logoutProfile() + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Deine Tagebucheinträge und Momentaufnahmen bleiben erhalten. Nur Profilangaben und der abgeschlossene Onboarding-Status werden zurückgesetzt.") + } + } + + private var profileSettings: some View { + List { + Section("Profil") { + HStack(spacing: 12) { + profileAvatar + + VStack(alignment: .leading, spacing: 3) { + Text(displayName.isEmpty ? "Ohne Namen" : displayName) + .font(.headline) + Text("Lokales Feel Aloud Profil") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + + LabeledContent("Account", value: "Apple-ID / iCloud") + } + + Section("Premium") { + Label( + premiumFeaturesEnabled ? "Premium aktiv" : "Premium nicht aktiv", + systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart" + ) + .foregroundStyle(premiumFeaturesEnabled ? .green : .secondary) + + Button { + openPremiumManagement() + } label: { + Label("Premium verwalten", systemImage: "creditcard") + } + + Button { + Task { + await premiumStore.restorePurchases() + premiumFeaturesEnabled = premiumStore.premiumFeaturesEnabled + } + } label: { + Label("Kauf wiederherstellen", systemImage: "arrow.clockwise") + } + } + + Section("Zugang") { + Button { + profileMessage = "FeelAloud verwendet aktuell kein eigenes Passwort. Der Zugang läuft über dein Gerät, Face ID und später über deine Apple-ID/iCloud. Ein App-Passwort kann erst mit einem echten Login-System ergänzt werden." + } label: { + Label("Passwort ändern", systemImage: "key") + } + + Button(role: .destructive) { + showsLogoutConfirmation = true + } label: { + Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + .navigationTitle("Profil") + .navigationBarTitleDisplayMode(.inline) + } + + private var remindersSettings: some View { + List { + Section("Momentaufnahmen") { + Toggle("Tägliche Erinnerungen", isOn: reminderToggle) + + if remindersEnabled { + Picker("Anzahl pro Tag", selection: reminderCountBinding) { + Text("2-mal").tag(2) + Text("3-mal").tag(3) + } + .pickerStyle(.segmented) + + ForEach(0.. some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.headline) + .foregroundStyle(.tint) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + private var profileAvatar: some View { + Group { + #if canImport(UIKit) + if let image = UIImage(data: profileImageData) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .font(.title) + .foregroundStyle(.secondary) + } + #else + Image(systemName: "person.crop.circle.fill") + .font(.title) + .foregroundStyle(.secondary) + #endif + } + .frame(width: 48, height: 48) + .background(Color(.secondarySystemGroupedBackground), in: Circle()) + .clipShape(Circle()) + } + + private var aiAvailable: Bool { + SystemLanguageModel.default.availability == .available + } + + private var aiStatus: String { + aiAvailable ? "Bereit" : "Nicht bereit" + } + + private var appLockToggle: Binding { + Binding( + get: { appLockEnabled }, + set: { enabled in + appLockMessage = nil + guard enabled else { + appLockEnabled = false + return + } + + guard AppLockAvailability.canAuthenticate else { + appLockMessage = "Auf diesem Gerät ist keine lokale Entsperrung eingerichtet." + appLockEnabled = false + return + } + + guard !AppLockAvailability.requiresMissingFaceIDDescription else { + appLockMessage = "Face ID braucht noch den Info-Plist-Eintrag Privacy - Face ID Usage Description." + appLockEnabled = false + return + } + + appLockEnabled = true + } + ) + } + + private var premiumFeaturesToggle: Binding { + Binding( + get: { premiumFeaturesEnabled }, + set: { enabled in + premiumStore.setPremiumEnabled(enabled) + premiumFeaturesEnabled = enabled + } + ) + } + + private var reminderToggle: Binding { + Binding( + get: { remindersEnabled }, + set: { enabled in + remindersEnabled = enabled + Task { + await configureReminders(requestAuthorization: enabled) + } + } + ) + } + + private var reminderCountBinding: Binding { + Binding( + get: { reminderCount }, + set: { count in + reminderCount = min(max(count, 2), 3) + Task { await rescheduleIfEnabled() } + } + ) + } + + private func timeBinding(for index: Int) -> Binding { + Binding( + get: { + let value = storedMinuteValue(for: index) + return Calendar.current.date( + bySettingHour: value / 60, + minute: value % 60, + second: 0, + of: .now + ) ?? .now + }, + set: { date in + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + let value = (components.hour ?? 0) * 60 + (components.minute ?? 0) + setStoredMinuteValue(value, for: index) + Task { await rescheduleIfEnabled() } + } + ) + } + + private func storedMinuteValue(for index: Int) -> Int { + switch index { + case 0: reminderTime1 + case 1: reminderTime2 + default: reminderTime3 + } + } + + private func setStoredMinuteValue(_ value: Int, for index: Int) { + switch index { + case 0: reminderTime1 = value + case 1: reminderTime2 = value + default: reminderTime3 = value + } + } + + private var activeReminderTimes: [Int] { + Array([reminderTime1, reminderTime2, reminderTime3].prefix(safeReminderCount)) + } + + private var safeReminderCount: Int { + min(max(reminderCount, 2), 3) + } + + @MainActor + private func configureReminders(requestAuthorization: Bool) async { + notificationMessage = nil + + guard remindersEnabled else { + SnapshotNotificationScheduler.cancelDailyReminders() + await refreshNotificationStatus() + return + } + + do { + try await SnapshotNotificationScheduler.scheduleDailyReminders( + minuteValues: activeReminderTimes, + requestAuthorization: requestAuthorization + ) + } catch { + remindersEnabled = false + notificationMessage = error.localizedDescription + } + await refreshNotificationStatus() + } + + @MainActor + private func rescheduleIfEnabled() async { + guard remindersEnabled else { return } + await configureReminders(requestAuthorization: false) + } + + @MainActor + private func refreshNotificationStatus() async { + let status = await SnapshotNotificationScheduler.authorizationStatus() + switch status { + case .notDetermined: + notificationStatus = "Noch nicht gefragt" + case .denied: + notificationStatus = "Nicht erlaubt" + case .authorized, .provisional, .ephemeral: + notificationStatus = "Erlaubt" + @unknown default: + notificationStatus = "Unbekannt" + } + } + + private var exportErrorBinding: Binding { + Binding( + get: { exportError != nil }, + set: { if !$0 { exportError = nil } } + ) + } + + private var profileMessageBinding: Binding { + Binding( + get: { profileMessage != nil }, + set: { if !$0 { profileMessage = nil } } + ) + } + + private func exportCSV() { + do { + let url = try CSVExporter.createFile(entries: entries) + guard FileManager.default.fileExists(atPath: url.path) else { + exportError = "Die CSV-Datei konnte nicht erstellt werden." + return + } + exportFile = SettingsExportFile(url: url) + } catch { + exportError = error.localizedDescription + } + } + + private func addCategory() { + let name = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + guard !customCategories.contains(where: { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }) else { + newCategoryName = "" + return + } + + customCategories.append(DiaryCategory(name: name)) + customCategories.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + DiaryCategoryStore.save(customCategories) + newCategoryName = "" + } + + private func deleteCategory(_ category: DiaryCategory) { + customCategories.removeAll { $0.id == category.id } + DiaryCategoryStore.save(customCategories) + } + + private func openPremiumManagement() { + guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return } + openURL(url) + } + + private func logoutProfile() { + displayName = "" + profileImageData = Data() + hasCompletedOnboarding = false + } +} + +private struct SettingsExportFile: Identifiable { + let url: URL + + var id: String { url.absoluteString } +} diff --git a/FeelAloud/ShareSheet.swift b/FeelAloud/ShareSheet.swift new file mode 100644 index 0000000..7cf712f --- /dev/null +++ b/FeelAloud/ShareSheet.swift @@ -0,0 +1,15 @@ +import SwiftUI +import UIKit + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController( + _ uiViewController: UIActivityViewController, + context: Context + ) { } +} diff --git a/FeelAloud/SnapshotNotificationScheduler.swift b/FeelAloud/SnapshotNotificationScheduler.swift new file mode 100644 index 0000000..68fe308 --- /dev/null +++ b/FeelAloud/SnapshotNotificationScheduler.swift @@ -0,0 +1,116 @@ +import UIKit +import UserNotifications + +enum SnapshotNotificationError: LocalizedError { + case permissionDenied + + var errorDescription: String? { + switch self { + case .permissionDenied: + "Mitteilungen sind für diese App nicht erlaubt. Du kannst sie in den iPhone-Einstellungen aktivieren." + } + } +} + +enum SnapshotNotificationScheduler { + static let destinationKey = "feelaloud.destination" + static let snapshotDestination = "snapshot" + + private static let identifiers = (0..<3).map { "feelaloud.snapshot.\($0)" } + private static let pendingSnapshotKey = "feelaloud.pendingSnapshotFromNotification" + + static func scheduleDailyReminders( + minuteValues: [Int], + requestAuthorization: Bool + ) async throws { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + var isAllowed = settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional + || settings.authorizationStatus == .ephemeral + + if settings.authorizationStatus == .notDetermined, requestAuthorization { + isAllowed = try await center.requestAuthorization(options: [.alert, .sound]) + } + + guard isAllowed else { + throw SnapshotNotificationError.permissionDenied + } + + center.removePendingNotificationRequests(withIdentifiers: identifiers) + + for (index, value) in minuteValues.prefix(3).enumerated() { + let minutes = max(0, min(value, (24 * 60) - 1)) + var components = DateComponents() + components.hour = minutes / 60 + components.minute = minutes % 60 + + let content = UNMutableNotificationContent() + content.title = "Wie geht es dir gerade?" + content.body = "Ein kurzer Tipp auf eines der fünf Gesichter genügt." + content.sound = .default + content.userInfo = [destinationKey: snapshotDestination] + content.threadIdentifier = "feelaloud.snapshot" + + let trigger = UNCalendarNotificationTrigger( + dateMatching: components, + repeats: true + ) + let request = UNNotificationRequest( + identifier: identifiers[index], + content: content, + trigger: trigger + ) + try await center.add(request) + } + } + + static func cancelDailyReminders() { + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: identifiers) + } + + static func authorizationStatus() async -> UNAuthorizationStatus { + await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + } + + static func storePendingSnapshotPresentation() { + UserDefaults.standard.set(true, forKey: pendingSnapshotKey) + } + + static func consumePendingSnapshotPresentation() -> Bool { + let hasPendingPresentation = UserDefaults.standard.bool(forKey: pendingSnapshotKey) + UserDefaults.standard.set(false, forKey: pendingSnapshotKey) + return hasPendingPresentation + } +} + +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .sound] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let destination = response.notification.request.content.userInfo[ + SnapshotNotificationScheduler.destinationKey + ] as? String + + guard destination == SnapshotNotificationScheduler.snapshotDestination else { return } + + SnapshotNotificationScheduler.storePendingSnapshotPresentation() + } +} diff --git a/FeelAloud/SnapshotView.swift b/FeelAloud/SnapshotView.swift new file mode 100644 index 0000000..db02114 --- /dev/null +++ b/FeelAloud/SnapshotView.swift @@ -0,0 +1,155 @@ +import SwiftData +import SwiftUI +import UIKit + +struct SnapshotView: View { + let source: SnapshotSource + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @State private var savedRating: SnapshotRating? + + private let columns = Array( + repeating: GridItem(.flexible(), spacing: 6), + count: SnapshotRating.allCases.count + ) + + var body: some View { + VStack(spacing: 28) { + Spacer() + + VStack(spacing: 8) { + Text("Wie geht es dir gerade?") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text("Ein Tipp genügt.") + .foregroundStyle(.secondary) + } + + LazyVGrid(columns: columns, spacing: 10) { + ForEach(SnapshotRating.allCases) { rating in + Button { + save(rating) + } label: { + VStack(spacing: 9) { + Text(rating.emoji) + .font(.system(size: 43)) + .scaleEffect(savedRating == rating ? 1.12 : 1) + Text(rating.rawValue) + .font(.caption2.weight(.medium)) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, minHeight: 100) + .padding(.vertical, 8) + .background( + savedRating == rating + ? rating.color.opacity(0.17) + : Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 18) + ) + } + .buttonStyle(.plain) + .disabled(savedRating != nil) + .accessibilityLabel(rating.rawValue) + } + } + + Text("Die Momentaufnahme wird ausschließlich auf diesem iPhone gespeichert.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Spacer() + } + .padding(20) + .background(Color(.systemGroupedBackground)) + .appKeyboardBehavior() + .presentationDragIndicator(.visible) + } + + private func save(_ rating: SnapshotRating) { + guard savedRating == nil else { return } + savedRating = rating + + modelContext.insert(MoodSnapshot(rating: rating, source: source)) + try? modelContext.save() + UINotificationFeedbackGenerator().notificationOccurred(.success) + + Task { + try? await Task.sleep(for: .milliseconds(260)) + dismiss() + } + } +} + +struct SnapshotFollowUpView: View { + @Bindable var snapshot: MoodSnapshot + + @Environment(\.appColorProfile) private var colorProfile + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @State private var note: String + + init(snapshot: MoodSnapshot) { + self.snapshot = snapshot + _note = State(initialValue: snapshot.followUpNote) + } + + var body: some View { + NavigationStack { + Form { + Section { + HStack(spacing: 14) { + Text(snapshot.rating.emoji) + .font(.system(size: 42)) + VStack(alignment: .leading, spacing: 3) { + Text(snapshot.rating.rawValue) + .font(.headline) + Text(snapshot.createdAt.formatted(date: .omitted, time: .shortened)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + Section(snapshot.rating.followUpQuestion) { + TextEditor(text: $note) + .frame(minHeight: 120) + } + + Section { + Button { + save() + } label: { + Label("Antwort speichern", systemImage: "checkmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(colorProfile.accent) + .disabled(note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Button("Heute nicht mehr fragen", role: .cancel) { + snapshot.followUpDismissed = true + try? modelContext.save() + dismiss() + } + .frame(maxWidth: .infinity) + } + } + .navigationTitle("Kurzer Rückblick") + .navigationBarTitleDisplayMode(.inline) + } + .appKeyboardBehavior() + } + + private func save() { + snapshot.followUpNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + try? modelContext.save() + dismiss() + } +} diff --git a/FeelAloud/SpeechRecognizer.swift b/FeelAloud/SpeechRecognizer.swift new file mode 100644 index 0000000..7f893a8 --- /dev/null +++ b/FeelAloud/SpeechRecognizer.swift @@ -0,0 +1,212 @@ +import AVFoundation +import Combine +import Foundation +import Speech + +@MainActor +final class SpeechRecognizer: ObservableObject { + enum SpeechError: LocalizedError { + case microphoneDenied + case speechDenied + case recognizerUnavailable + + var errorDescription: String? { + switch self { + case .microphoneDenied: + "Der Mikrofonzugriff wurde nicht erlaubt. Du kannst ihn in den iPhone-Einstellungen aktivieren." + case .speechDenied: + "Die Spracherkennung wurde nicht erlaubt. Du kannst sie in den iPhone-Einstellungen aktivieren." + case .recognizerUnavailable: + "Die deutsche Spracherkennung ist gerade nicht verfügbar." + } + } + } + + @Published private(set) var transcript = "" + @Published private(set) var isRecording = false + @Published private(set) var errorMessage: String? + + private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "de-DE")) + private var recorder: AVAudioRecorder? + private var recordingURL: URL? + private var task: SFSpeechRecognitionTask? + private var isStarting = false + + func start() async throws { + guard !isRecording, !isStarting else { return } + isStarting = true + defer { isStarting = false } + + errorMessage = nil + transcript = "" + + do { + guard await requestMicrophonePermission() else { + throw SpeechError.microphoneDenied + } + guard await requestSpeechPermission() else { + throw SpeechError.speechDenied + } + guard let recognizer, recognizer.isAvailable else { + throw SpeechError.recognizerUnavailable + } + + cleanupRecordingState() + + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers]) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("m4a") + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + let recorder = try AVAudioRecorder(url: url, settings: settings) + recorder.prepareToRecord() + guard recorder.record() else { + throw SpeechError.recognizerUnavailable + } + + self.recorder = recorder + recordingURL = url + isRecording = true + } catch { + cleanupRecordingState() + throw error + } + } + + func stop() async throws { + guard isRecording else { return } + + recorder?.stop() + recorder = nil + isRecording = false + + try? AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) + + guard let recordingURL else { return } + defer { + try? FileManager.default.removeItem(at: recordingURL) + self.recordingURL = nil + task = nil + } + + transcript = try await transcribeRecording(at: recordingURL) + } + + func cancel() { + cleanupRecordingState() + } + + private func transcribeRecording(at url: URL) async throws -> String { + guard let recognizer, recognizer.isAvailable else { + throw SpeechError.recognizerUnavailable + } + + let request = SFSpeechURLRecognitionRequest(url: url) + request.shouldReportPartialResults = false + request.taskHint = .dictation + + return try await withCheckedThrowingContinuation { continuation in + let lock = NSLock() + var didResume = false + + func resumeOnce(with result: Result) { + lock.lock() + guard !didResume else { + lock.unlock() + return + } + didResume = true + lock.unlock() + + switch result { + case .success(let transcript): + continuation.resume(returning: transcript) + case .failure(let error): + continuation.resume(throwing: error) + } + } + + task = recognizer.recognitionTask(with: request) { result, error in + if let result { + if result.isFinal { + resumeOnce(with: .success(result.bestTranscription.formattedString)) + } + } + + if let error { + resumeOnce(with: .failure(error)) + } + } + } + } + + private func cleanupRecordingState() { + recorder?.stop() + task?.cancel() + recorder = nil + if let recordingURL { + try? FileManager.default.removeItem(at: recordingURL) + } + recordingURL = nil + task = nil + isRecording = false + try? AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) + } + + private func requestMicrophonePermission() async -> Bool { + switch AVAudioApplication.shared.recordPermission { + case .granted: + true + case .denied: + false + case .undetermined: + await AVAudioApplication.requestRecordPermission() + @unknown default: + false + } + } + + private func requestSpeechPermission() async -> Bool { + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: + return true + case .denied, .restricted: + return false + case .notDetermined: + let status = await withCheckedContinuation { continuation in + let lock = NSLock() + var didResume = false + + SFSpeechRecognizer.requestAuthorization { status in + lock.lock() + guard !didResume else { + lock.unlock() + return + } + didResume = true + lock.unlock() + + continuation.resume(returning: status) + } + } + return status == .authorized + @unknown default: + return false + } + } +} diff --git a/FeelAloud/StartDiaryEntryIntent.swift b/FeelAloud/StartDiaryEntryIntent.swift new file mode 100644 index 0000000..aec3fe3 --- /dev/null +++ b/FeelAloud/StartDiaryEntryIntent.swift @@ -0,0 +1,31 @@ +import AppIntents + +struct StartDiaryEntryIntent: AppIntent, TargetContentProvidingIntent { + static let title: LocalizedStringResource = "Eintrag aufnehmen" + static let description = IntentDescription( + "Öffnet FeelAloud und startet einen neuen Spracheintrag." + ) + static let openAppWhenRun = true + static let supportedModes: IntentModes = [.foreground] + + func perform() async throws -> some IntentResult { + .result() + } +} + +struct FeelAloudShortcuts: AppShortcutsProvider { + static let shortcutTileColor: ShortcutTileColor = .purple + + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartDiaryEntryIntent(), + phrases: [ + "Eintrag in \(.applicationName) aufnehmen", + "Neuer Eintrag in \(.applicationName)", + "\(.applicationName) Eintrag sprechen" + ], + shortTitle: "Eintrag aufnehmen", + systemImageName: "waveform.and.mic" + ) + } +} diff --git a/FeelAloud/TherapySupport.swift b/FeelAloud/TherapySupport.swift new file mode 100644 index 0000000..49a7d37 --- /dev/null +++ b/FeelAloud/TherapySupport.swift @@ -0,0 +1,157 @@ +import Foundation +import SwiftData + + +enum TherapyItemKind: String, CaseIterable, Identifiable, Codable, Sendable { + case goal = "Ziel" + case project = "Projekt" + case task = "Aufgabe" + + var id: String { rawValue } + + var systemImage: String { + switch self { + case .goal: "target" + case .project: "folder.badge.gearshape" + case .task: "checklist" + } + } +} + +enum ProjectAttachmentKind: String, Codable, Sendable { + case file = "Datei" + case folder = "Ordner" + + var systemImage: String { + switch self { + case .file: "doc" + case .folder: "folder" + } + } +} + +@Model +final class TherapyItem { + var id = UUID() + var createdAt = Date() + var title = "" + var notes = "" + var kindRaw = TherapyItemKind.task.rawValue + var dueDate: Date? + var isCompleted = false + var isArchived = false + + init( + title: String, + notes: String = "", + kind: TherapyItemKind = .task, + dueDate: Date? = nil, + createdAt: Date = .now + ) { + id = UUID() + self.createdAt = createdAt + self.title = title + self.notes = notes + kindRaw = kind.rawValue + self.dueDate = dueDate + isCompleted = false + isArchived = false + } + + var kind: TherapyItemKind { + TherapyItemKind(rawValue: kindRaw) ?? .task + } +} + +@Model +final class TherapyProject { + var id = UUID() + var createdAt = Date() + var title = "" + var summary = "" + var isArchived = false + + init(title: String, summary: String = "", createdAt: Date = .now) { + id = UUID() + self.createdAt = createdAt + self.title = title + self.summary = summary + isArchived = false + } +} + +@Model +final class TherapyProjectNote { + var id = UUID() + var projectID = UUID() + var createdAt = Date() + var updatedAt = Date() + var title = "" + var text = "" + + init( + projectID: UUID, + title: String, + text: String = "", + createdAt: Date = .now + ) { + id = UUID() + self.projectID = projectID + self.createdAt = createdAt + updatedAt = createdAt + self.title = title + self.text = text + } +} + +@Model +final class TherapyProjectAttachment { + var id = UUID() + var projectID = UUID() + var createdAt = Date() + var originalFilename = "" + var storedRelativePath = "" + var kindRaw = ProjectAttachmentKind.file.rawValue + + init( + projectID: UUID, + originalFilename: String, + storedRelativePath: String, + kind: ProjectAttachmentKind, + createdAt: Date = .now + ) { + id = UUID() + self.projectID = projectID + self.createdAt = createdAt + self.originalFilename = originalFilename + self.storedRelativePath = storedRelativePath + kindRaw = kind.rawValue + } + + var kind: ProjectAttachmentKind { + ProjectAttachmentKind(rawValue: kindRaw) ?? .file + } +} + +@Model +final class EmergencyPlan { + var id = UUID() + var updatedAt = Date() + var warningSigns = "" + var calmingSteps = "" + var supportiveContacts = "" + var professionalContacts = "" + var safePlaces = "" + var notes = "" + + init() { + id = UUID() + updatedAt = .now + warningSigns = "" + calmingSteps = "" + supportiveContacts = "" + professionalContacts = "" + safePlaces = "" + notes = "" + } +} diff --git a/FeelAloud/TherapySupportView.swift b/FeelAloud/TherapySupportView.swift new file mode 100644 index 0000000..c704658 --- /dev/null +++ b/FeelAloud/TherapySupportView.swift @@ -0,0 +1,1215 @@ +import SwiftData +import SwiftUI +import UniformTypeIdentifiers + +struct TherapySupportView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.appColorProfile) private var colorProfile + @AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false + @Query(sort: \TherapyItem.createdAt, order: .reverse) private var items: [TherapyItem] + @Query(sort: \TherapyProject.createdAt, order: .reverse) private var projects: [TherapyProject] + @Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var projectNotes: [TherapyProjectNote] + @Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var attachments: [TherapyProjectAttachment] + @Query(sort: \EmergencyPlan.updatedAt, order: .reverse) private var plans: [EmergencyPlan] + + @State private var selectedKind: TherapyItemKind? + @State private var editedItem: TherapyItem? + @State private var showsNewItem = false + @State private var editedProject: TherapyProject? + @State private var showsNewProject = false + @State private var showsPremiumPaywall = false + @State private var infoKind: TherapyItemKind? + + private var activeItems: [TherapyItem] { + items.filter { item in + !item.isArchived + && item.kind != .project + && (selectedKind == nil || item.kind == selectedKind) + } + } + + private var activeProjects: [TherapyProject] { + projects.filter { !$0.isArchived } + } + + private var showsProjects: Bool { + selectedKind == nil || selectedKind == .project + } + + private var showsItems: Bool { + selectedKind == nil || selectedKind == .goal || selectedKind == .task + } + + private var openItemCount: Int { + items.filter { !$0.isArchived && !$0.isCompleted && $0.kind != .project }.count + } + + private var completedItemCount: Int { + items.filter { !$0.isArchived && $0.isCompleted && $0.kind != .project }.count + } + + private var plan: EmergencyPlan? { + plans.first + } + + var body: some View { + NavigationStack { + List { + Section("Notfall") { + if let plan { + NavigationLink { + EmergencyPlanView(plan: plan) + } label: { + SettingsLikeRow( + title: "Notfallplan", + subtitle: emergencyPlanSubtitle(for: plan), + systemImage: "cross.case" + ) + } + } + } + + Section("Therapiearbeit") { + therapyOverview + + Picker("Filter", selection: $selectedKind) { + Text("Alle").tag(nil as TherapyItemKind?) + ForEach([TherapyItemKind.goal, .task, .project]) { kind in + Text(kind.rawValue).tag(kind as TherapyItemKind?) + } + } + .pickerStyle(.segmented) + + kindInfoRow + } + + if showsProjects { + Section("Projektmappen") { + if premiumFeaturesEnabled { + if activeProjects.isEmpty { + ContentUnavailableView( + "Noch keine Projektmappe", + systemImage: "folder.badge.plus", + description: Text("Lege eine Mappe für ein Therapiethema an und sammle darin Notizen, Bilder, Dateien und Ordner.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(activeProjects) { project in + NavigationLink { + TherapyProjectDetailView(project: project) + } label: { + TherapyProjectRow( + project: project, + noteCount: projectNotes.filter { $0.projectID == project.id }.count, + attachmentCount: attachments.filter { $0.projectID == project.id }.count + ) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + delete(project) + } label: { + Label("Löschen", systemImage: "trash") + } + + Button { + project.isArchived = true + try? modelContext.save() + } label: { + Label("Archiv", systemImage: "archivebox") + } + .tint(.gray) + } + } + } + + Button { + showsNewProject = true + } label: { + Label("Projektmappe anlegen", systemImage: "folder.badge.plus") + } + } else { + PremiumLockedRow( + title: "Projektmappen freischalten", + subtitle: "Sammle Notizen, Bilder, Dateien und Ordner zu Therapiethemen als Bonusfunktion.", + systemImage: "folder.badge.plus" + ) { + showsPremiumPaywall = true + } + } + } + } + + if showsItems { + Section("Ziele und Aufgaben") { + if activeItems.isEmpty { + ContentUnavailableView( + "Noch nichts angelegt", + systemImage: "target", + description: Text("Mit + kannst du Therapieziele oder konkrete Aufgaben hinzufügen.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(activeItems) { item in + Button { + editedItem = item + } label: { + TherapyItemRow(item: item) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + modelContext.delete(item) + try? modelContext.save() + } label: { + Label("Löschen", systemImage: "trash") + } + + Button { + item.isArchived = true + try? modelContext.save() + } label: { + Label("Archiv", systemImage: "archivebox") + } + .tint(.gray) + } + .swipeActions(edge: .leading) { + Button { + item.isCompleted.toggle() + try? modelContext.save() + } label: { + Label( + item.isCompleted ? "Offen" : "Erledigt", + systemImage: item.isCompleted ? "circle" : "checkmark.circle.fill" + ) + } + .tint(item.isCompleted ? .orange : .green) + } + } + } + } + } + + } + .navigationTitle("Therapie") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + if premiumFeaturesEnabled { + showsNewProject = true + } else { + showsPremiumPaywall = true + } + } label: { + Label("Projektmappe", systemImage: premiumFeaturesEnabled ? "folder.badge.plus" : "lock.fill") + } + + Button { + showsNewItem = true + } label: { + Label("Ziel oder Aufgabe", systemImage: "plus.circle") + } + } label: { + Label("Neu", systemImage: "plus") + } + } + } + .alert(item: $infoKind) { kind in + Alert( + title: Text(kind.rawValue), + message: Text(kind.helpText), + dismissButton: .default(Text("OK")) + ) + } + } + .task { + ensureEmergencyPlanExists() + } + .sheet(isPresented: $showsNewItem) { + EditTherapyItemView(item: nil) + } + .sheet(item: $editedItem) { item in + EditTherapyItemView(item: item) + } + .sheet(isPresented: $showsNewProject) { + EditTherapyProjectView(project: nil) + } + .sheet(isPresented: $showsPremiumPaywall) { + PremiumPaywallView( + title: "Projektmappen freischalten", + message: "Projektordner sind eine Bonusfunktion für Unterstützer:innen. Ziele, Aufgaben und der Notfallplan bleiben frei nutzbar." + ) + } + .sheet(item: $editedProject) { project in + EditTherapyProjectView(project: project) + } + .appKeyboardBehavior() + } + + private var therapyOverview: some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), + spacing: 8 + ) { + TherapyMetricView( + title: "Mappen", + value: premiumFeaturesEnabled ? "\(activeProjects.count)" : "Bonus", + systemImage: premiumFeaturesEnabled ? "folder" : "lock" + ) + TherapyMetricView(title: "Offen", value: "\(openItemCount)", systemImage: "circle") + TherapyMetricView(title: "Erledigt", value: "\(completedItemCount)", systemImage: "checkmark.circle.fill") + } + .padding(.vertical, 4) + } + + private var kindInfoRow: some View { + HStack(spacing: 10) { + Button { + infoKind = .project + } label: { + Label("Projektmappe", systemImage: "questionmark.circle") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.borderless) + + ForEach([TherapyItemKind.goal, .task]) { kind in + Button { + infoKind = kind + } label: { + Label(kind.rawValue, systemImage: "questionmark.circle") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.borderless) + } + } + .foregroundStyle(colorProfile.accent) + } + + private func delete(_ project: TherapyProject) { + let projectAttachments = attachments.filter { $0.projectID == project.id } + for attachment in projectAttachments { + try? TherapyProjectFileStore.delete(attachment) + modelContext.delete(attachment) + } + projectNotes + .filter { $0.projectID == project.id } + .forEach(modelContext.delete) + modelContext.delete(project) + try? modelContext.save() + } + + private func emergencyPlanSubtitle(for plan: EmergencyPlan) -> String { + let filledFields = [ + plan.warningSigns, + plan.calmingSteps, + plan.supportiveContacts, + plan.professionalContacts, + plan.safePlaces, + plan.notes + ] + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .count + + return filledFields == 0 + ? "Warnzeichen, Schritte und Kontakte festhalten" + : "\(filledFields) von 6 Bereichen ausgefüllt" + } + + private func ensureEmergencyPlanExists() { + guard plans.isEmpty else { return } + modelContext.insert(EmergencyPlan()) + try? modelContext.save() + } +} + +private struct PremiumLockedRow: View { + let title: String + let subtitle: String + let systemImage: String + let action: () -> Void + + @Environment(\.appColorProfile) private var colorProfile + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(.white) + .frame(width: 42, height: 42) + .background(colorProfile.accent, in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } +} + +private struct TherapyProjectDetailView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \TherapyProjectNote.updatedAt, order: .reverse) private var allNotes: [TherapyProjectNote] + @Query(sort: \TherapyProjectAttachment.createdAt, order: .reverse) private var allAttachments: [TherapyProjectAttachment] + + let project: TherapyProject + + @State private var searchText = "" + @State private var editedProject: TherapyProject? + @State private var editedNote: TherapyProjectNote? + @State private var showsNewNote = false + @State private var importsFiles = false + @State private var importError: String? + @State private var shareFile: TherapyProjectShareFile? + + private var notes: [TherapyProjectNote] { + filtered(allNotes.filter { $0.projectID == project.id }) + } + + private var attachments: [TherapyProjectAttachment] { + filtered(allAttachments.filter { $0.projectID == project.id }) + } + + var body: some View { + List { + Section("Projektmappe") { + VStack(alignment: .leading, spacing: 6) { + Text(project.title.isEmpty ? "Ohne Titel" : project.title) + .font(.headline) + if !project.summary.isEmpty { + Text(project.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 3) + + Button { + editedProject = project + } label: { + Label("Mappe bearbeiten", systemImage: "square.and.pencil") + } + } + + Section("Notizen") { + if notes.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Noch keine Notizen" : "Keine Treffer", + systemImage: "note.text", + description: Text(searchText.isEmpty ? "Lege freie Notizen zu diesem Therapiethema an." : "Passe die Suche an oder lege eine neue Notiz an.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(notes) { note in + Button { + editedNote = note + } label: { + ProjectNoteRow(note: note) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + modelContext.delete(note) + try? modelContext.save() + } label: { + Label("Löschen", systemImage: "trash") + } + } + } + } + + Button { + showsNewNote = true + } label: { + Label("Notiz anlegen", systemImage: "square.and.pencil") + } + } + + Section("Dateien, Bilder und Ordner") { + if attachments.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Noch keine Anhänge" : "Keine Treffer", + systemImage: "doc.badge.plus", + description: Text("Importiere Bilder, Dateien oder Ordner aus der Dateien-App in diese Projektmappe.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(attachments) { attachment in + AttachmentRow(attachment: attachment) { + if let url = TherapyProjectFileStore.url(for: attachment) { + shareFile = TherapyProjectShareFile(url: url) + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + delete(attachment) + } label: { + Label("Löschen", systemImage: "trash") + } + } + } + } + + Button { + importsFiles = true + } label: { + Label("Dateien oder Ordner hinzufügen", systemImage: "paperclip") + } + } + } + .navigationTitle(project.title.isEmpty ? "Projektmappe" : project.title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Notizen und Anhänge suchen") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + showsNewNote = true + } label: { + Label("Notiz", systemImage: "square.and.pencil") + } + + Button { + importsFiles = true + } label: { + Label("Datei, Bild oder Ordner", systemImage: "paperclip") + } + } label: { + Label("Hinzufügen", systemImage: "plus") + } + } + } + .sheet(item: $editedProject) { project in + EditTherapyProjectView(project: project) + } + .sheet(isPresented: $showsNewNote) { + EditProjectNoteView(projectID: project.id, note: nil) + } + .sheet(item: $editedNote) { note in + EditProjectNoteView(projectID: project.id, note: note) + } + .sheet(item: $shareFile) { file in + ShareSheet(items: [file.url]) + } + .fileImporter( + isPresented: $importsFiles, + allowedContentTypes: [.item, .folder], + allowsMultipleSelection: true + ) { result in + importFiles(result) + } + .alert("Import nicht möglich", isPresented: importErrorBinding) { + Button("OK", role: .cancel) { } + } message: { + Text(importError ?? "Unbekannter Fehler") + } + } + + private func filtered(_ values: [T]) -> [T] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return values } + + return values.filter { value in + if let note = value as? TherapyProjectNote { + return note.title.lowercased().contains(query) + || note.text.lowercased().contains(query) + } + if let attachment = value as? TherapyProjectAttachment { + return attachment.originalFilename.lowercased().contains(query) + } + return false + } + } + + private var importErrorBinding: Binding { + Binding( + get: { importError != nil }, + set: { if !$0 { importError = nil } } + ) + } + + private func importFiles(_ result: Result<[URL], Error>) { + do { + let urls = try result.get() + for url in urls { + let attachment = try TherapyProjectFileStore.importItem(from: url, projectID: project.id) + modelContext.insert(attachment) + } + try modelContext.save() + } catch { + importError = error.localizedDescription + } + } + + private func delete(_ attachment: TherapyProjectAttachment) { + try? TherapyProjectFileStore.delete(attachment) + modelContext.delete(attachment) + try? modelContext.save() + } +} + +private struct TherapyProjectRow: View { + @Environment(\.appColorProfile) private var colorProfile + + let project: TherapyProject + let noteCount: Int + let attachmentCount: Int + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "folder.fill") + .font(.title3) + .foregroundStyle(colorProfile.accent) + .frame(width: 42, height: 42) + .background(colorProfile.accent.opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(project.title.isEmpty ? "Ohne Titel" : project.title) + .font(.headline) + .foregroundStyle(.primary) + + if !project.summary.isEmpty { + Text(project.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Label("\(noteCount) Notizen · \(attachmentCount) Anhänge", systemImage: "tray.full") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 3) + } +} + +private struct ProjectNoteRow: View { + let note: TherapyProjectNote + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(note.title.isEmpty ? "Ohne Titel" : note.title) + .font(.headline) + .foregroundStyle(.primary) + if !note.text.isEmpty { + Text(note.text) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + Text(note.updatedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 3) + } +} + +private struct AttachmentRow: View { + @Environment(\.appColorProfile) private var colorProfile + + let attachment: TherapyProjectAttachment + let open: () -> Void + + var body: some View { + Button(action: open) { + HStack(spacing: 12) { + Image(systemName: attachment.kind.systemImage) + .font(.title3) + .foregroundStyle(colorProfile.accent) + .frame(width: 38, height: 38) + .background(colorProfile.accent.opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(attachment.originalFilename) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + Text(attachment.kind.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 3) + } +} + +private struct EditTherapyProjectView: View { + let project: TherapyProject? + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var title: String + @State private var summary: String + + init(project: TherapyProject?) { + self.project = project + _title = State(initialValue: project?.title ?? "") + _summary = State(initialValue: project?.summary ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section("Projektmappe") { + TextField("Name", text: $title) + TextEditor(text: $summary) + .frame(minHeight: 110) + } + } + .appKeyboardBehavior() + .navigationTitle(project == nil ? "Neue Mappe" : "Mappe bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern") { save() } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private func save() { + let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanSummary = summary.trimmingCharacters(in: .whitespacesAndNewlines) + + if let project { + project.title = cleanTitle + project.summary = cleanSummary + } else { + modelContext.insert(TherapyProject(title: cleanTitle, summary: cleanSummary)) + } + + try? modelContext.save() + dismiss() + } +} + +private struct EditProjectNoteView: View { + let projectID: UUID + let note: TherapyProjectNote? + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var title: String + @State private var text: String + + init(projectID: UUID, note: TherapyProjectNote?) { + self.projectID = projectID + self.note = note + _title = State(initialValue: note?.title ?? "") + _text = State(initialValue: note?.text ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section("Notiz") { + TextField("Titel", text: $title) + TextEditor(text: $text) + .frame(minHeight: 180) + } + } + .appKeyboardBehavior() + .navigationTitle(note == nil ? "Neue Notiz" : "Notiz bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern") { save() } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private func save() { + let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + if let note { + note.title = cleanTitle + note.text = cleanText + note.updatedAt = .now + } else { + modelContext.insert(TherapyProjectNote(projectID: projectID, title: cleanTitle, text: cleanText)) + } + + try? modelContext.save() + dismiss() + } +} + +private struct TherapyMetricView: View { + @Environment(\.appColorProfile) private var colorProfile + + let title: String + let value: String + let systemImage: String + + var body: some View { + VStack(spacing: 5) { + Image(systemName: systemImage) + .font(.subheadline) + .foregroundStyle(colorProfile.accent) + .frame(height: 18) + Text(value) + .font(.headline) + .monospacedDigit() + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, minHeight: 76) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .background(colorProfile.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct SettingsLikeRow: View { + @Environment(\.appColorProfile) private var colorProfile + + let title: String + let subtitle: String + let systemImage: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(colorProfile.accent) + .frame(width: 40, height: 40) + .background(colorProfile.accent.opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 3) + } +} + +private struct EmergencyPlanView: View { + @Environment(\.modelContext) private var modelContext + @Bindable var plan: EmergencyPlan + + var body: some View { + Form { + Section("Akut sichtbar") { + EmergencyTextEditor( + title: "Was mir akut hilft", + placeholder: "z.B. Atemübung, kaltes Wasser, kurzer Spaziergang", + text: $plan.calmingSteps + ) + EmergencyTextEditor( + title: "Menschen, die ich kontaktieren kann", + placeholder: "Name, Telefonnummer, kurze Erinnerung wann anrufen", + text: $plan.supportiveContacts + ) + } + + Section("Einordnung") { + EmergencyTextEditor( + title: "Warnzeichen", + placeholder: "Woran merke ich, dass es kippt?", + text: $plan.warningSigns + ) + EmergencyTextEditor( + title: "Sichere Orte", + placeholder: "Orte, an denen ich runterkommen kann", + text: $plan.safePlaces + ) + } + + Section("Professionelle Hilfe") { + EmergencyTextEditor( + title: "Professionelle Kontakte", + placeholder: "Therapeut, Praxis, Krisendienst, ärztlicher Bereitschaftsdienst", + text: $plan.professionalContacts + ) + EmergencyTextEditor( + title: "Notizen", + placeholder: "Was sollte ich in einer Krise nicht vergessen?", + text: $plan.notes + ) + } + } + .navigationTitle("Notfallplan") + .navigationBarTitleDisplayMode(.inline) + .appKeyboardBehavior() + .onChange(of: plan.warningSigns) { _, _ in markChanged() } + .onChange(of: plan.calmingSteps) { _, _ in markChanged() } + .onChange(of: plan.supportiveContacts) { _, _ in markChanged() } + .onChange(of: plan.professionalContacts) { _, _ in markChanged() } + .onChange(of: plan.safePlaces) { _, _ in markChanged() } + .onChange(of: plan.notes) { _, _ in markChanged() } + .onDisappear { + savePlan() + } + } + + private func markChanged() { + plan.updatedAt = .now + } + + private func savePlan() { + try? modelContext.save() + } +} + +private struct EmergencyTextEditor: View { + let title: String + let placeholder: String + @Binding var text: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.weight(.semibold)) + + ZStack(alignment: .topLeading) { + if text.isEmpty { + Text(placeholder) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 10) + .allowsHitTesting(false) + } + + TextEditor(text: $text) + .frame(minHeight: 86) + .scrollContentBackground(.hidden) + .padding(.horizontal, 2) + } + } + .padding(.vertical, 4) + } +} + +private struct TherapyItemRow: View { + @Environment(\.appColorProfile) private var colorProfile + + let item: TherapyItem + + var body: some View { + HStack(spacing: 12) { + Image(systemName: item.isCompleted ? "checkmark.circle.fill" : item.kind.systemImage) + .font(.title3) + .foregroundStyle(item.isCompleted ? .green : colorProfile.accent) + .frame(width: 40, height: 40) + .background((item.isCompleted ? Color.green : colorProfile.accent).opacity(0.1), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(item.title.isEmpty ? "Ohne Titel" : item.title) + .font(.headline) + .foregroundStyle(.primary) + Text(item.kind.rawValue) + .font(.caption2.weight(.semibold)) + .foregroundStyle(colorProfile.accent) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(colorProfile.accent.opacity(0.1), in: Capsule()) + } + + if !item.notes.isEmpty { + Text(item.notes) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let dueDate = item.dueDate { + Label(dueDate.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar") + .font(.caption) + .foregroundStyle(dueDate < .now && !item.isCompleted ? .orange : .secondary) + } + } + } + .padding(.vertical, 3) + } +} + +private struct EditTherapyItemView: View { + let item: TherapyItem? + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var title: String + @State private var notes: String + @State private var kind: TherapyItemKind + @State private var hasDueDate: Bool + @State private var dueDate: Date + @State private var isCompleted: Bool + + init(item: TherapyItem?) { + self.item = item + _title = State(initialValue: item?.title ?? "") + _notes = State(initialValue: item?.notes ?? "") + _kind = State(initialValue: item?.kind == .project ? .task : item?.kind ?? .task) + _hasDueDate = State(initialValue: item?.dueDate != nil) + _dueDate = State(initialValue: item?.dueDate ?? .now) + _isCompleted = State(initialValue: item?.isCompleted ?? false) + } + + var body: some View { + NavigationStack { + Form { + Section("Eintrag") { + Picker("Typ", selection: $kind) { + ForEach([TherapyItemKind.goal, .task]) { kind in + Label(kind.rawValue, systemImage: kind.systemImage).tag(kind) + } + } + + TextField("Titel", text: $title) + Toggle("Erledigt", isOn: $isCompleted) + Toggle("Datum setzen", isOn: $hasDueDate) + + if hasDueDate { + DatePicker("Datum", selection: $dueDate, displayedComponents: .date) + } + } + + Section("Notizen") { + TextEditor(text: $notes) + .frame(minHeight: 140) + } + } + .appKeyboardBehavior() + .navigationTitle(item == nil ? "Neu" : "Bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern") { + save() + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private func save() { + let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) + + if let item { + item.title = cleanTitle + item.notes = cleanNotes + item.kindRaw = kind.rawValue + item.dueDate = hasDueDate ? dueDate : nil + item.isCompleted = isCompleted + } else { + let newItem = TherapyItem( + title: cleanTitle, + notes: cleanNotes, + kind: kind, + dueDate: hasDueDate ? dueDate : nil + ) + newItem.isCompleted = isCompleted + modelContext.insert(newItem) + } + + try? modelContext.save() + dismiss() + } +} + +private enum TherapyProjectFileStore { + private static let maxImportSize = 250 * 1024 * 1024 + + static func importItem(from sourceURL: URL, projectID: UUID) throws -> TherapyProjectAttachment { + let hasAccess = sourceURL.startAccessingSecurityScopedResource() + defer { + if hasAccess { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let values = try sourceURL.resourceValues(forKeys: [.isDirectoryKey, .localizedNameKey]) + let isDirectory = values.isDirectory == true + let sourceSize = try totalSize(of: sourceURL) + guard sourceSize <= maxImportSize else { + throw TherapyProjectFileStoreError.importTooLarge + } + + let filename = sanitizedFilename(values.localizedName ?? sourceURL.lastPathComponent) + let targetFolder = try folder(for: projectID) + let storedName = "\(UUID().uuidString)-\(filename)" + let targetURL = targetFolder.appendingPathComponent(storedName) + + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + try FileManager.default.copyItem(at: sourceURL, to: targetURL) + try protect(targetURL) + + return TherapyProjectAttachment( + projectID: projectID, + originalFilename: filename, + storedRelativePath: relativePath(projectID: projectID, filename: storedName), + kind: isDirectory ? .folder : .file + ) + } + + static func url(for attachment: TherapyProjectAttachment) -> URL? { + guard let baseURL = baseURL else { return nil } + return baseURL.appendingPathComponent(attachment.storedRelativePath) + } + + static func delete(_ attachment: TherapyProjectAttachment) throws { + guard let url = url(for: attachment), FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + private static func folder(for projectID: UUID) throws -> URL { + guard let baseURL else { + throw TherapyProjectFileStoreError.missingApplicationSupportDirectory + } + let folder = baseURL + .appendingPathComponent("TherapyProjects", isDirectory: true) + .appendingPathComponent(projectID.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try protect(folder) + return folder + } + + private static var baseURL: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + } + + private static func relativePath(projectID: UUID, filename: String) -> String { + "TherapyProjects/\(projectID.uuidString)/\(filename)" + } + + private static func sanitizedFilename(_ filename: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:") + .union(.newlines) + .union(.controlCharacters) + let cleaned = filename + .components(separatedBy: invalidCharacters) + .joined(separator: "_") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !cleaned.isEmpty else { return "Anhang" } + return String(cleaned.prefix(120)) + } + + private static func totalSize(of url: URL) throws -> Int { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) + guard values.isDirectory == true else { + return values.fileSize ?? 0 + } + + guard let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var size = 0 + for case let fileURL as URL in enumerator { + let fileValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard fileValues.isRegularFile == true else { continue } + size += fileValues.fileSize ?? 0 + if size > maxImportSize { + return size + } + } + return size + } + + private static func protect(_ url: URL) throws { + var protectedURL = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? protectedURL.setResourceValues(values) + + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + + let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey]) + guard resourceValues.isDirectory == true, + let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: nil) else { + return + } + + for case let childURL as URL in enumerator { + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: childURL.path + ) + } + } +} + +private enum TherapyProjectFileStoreError: LocalizedError { + case importTooLarge + case missingApplicationSupportDirectory + + var errorDescription: String? { + switch self { + case .importTooLarge: + "Der Import ist zu groß. Bitte wähle Dateien oder Ordner bis maximal 250 MB." + case .missingApplicationSupportDirectory: + "Der lokale App-Speicher konnte nicht vorbereitet werden." + } + } +} + +private struct TherapyProjectShareFile: Identifiable { + let url: URL + + var id: String { url.absoluteString } +} + +private extension TherapyItemKind { + var helpText: String { + switch self { + case .goal: + "Ein Ziel beschreibt eine Richtung, zum Beispiel ruhiger schlafen oder häufiger soziale Kontakte halten. Es muss nicht sofort erledigt sein." + case .project: + "Eine Projektmappe sammelt ein größeres Therapiethema mit freien Notizen, Bildern, Dateien und Ordnern. Lege sie im Abschnitt Projektmappen an." + case .task: + "Eine Aufgabe ist konkret und kurzfristig: etwas ausprobieren, beobachten, notieren oder bis zur nächsten Sitzung erledigen." + } + } +} diff --git a/FeelAloud/XLSXExporter.swift b/FeelAloud/XLSXExporter.swift new file mode 100644 index 0000000..b2b5da3 --- /dev/null +++ b/FeelAloud/XLSXExporter.swift @@ -0,0 +1,845 @@ +import Foundation + +enum XLSXExporter { + static func createFile( + snapshots: [MoodSnapshot], + entries: [MoodEntry], + from startDate: Date, + through endDate: Date + ) throws -> URL { + let orderedSnapshots = snapshots.sorted { $0.createdAt < $1.createdAt } + let orderedEntries = entries.sorted { $0.createdAt < $1.createdAt } + let workbook = WorkbookBuilder( + snapshots: orderedSnapshots, + entries: orderedEntries, + startDate: startDate, + endDate: endDate + ) + + let filenameDate = DateFormatter() + filenameDate.locale = Locale(identifier: "en_US_POSIX") + filenameDate.dateFormat = "yyyy-MM-dd" + let filename = "Stimmungsreport-\(filenameDate.string(from: .now)).xlsx" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + + try SimpleZipArchive.write(entries: workbook.archiveEntries, to: url) + return url + } +} + +private struct WorkbookBuilder { + let snapshots: [MoodSnapshot] + let entries: [MoodEntry] + let startDate: Date + let endDate: Date + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "de_DE") + formatter.dateFormat = "dd.MM.yyyy" + return formatter + } + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "de_DE") + formatter.dateFormat = "HH:mm" + return formatter + } + + var archiveEntries: [SimpleZipArchive.Entry] { + var entries: [SimpleZipArchive.Entry] = [ + .text("[Content_Types].xml", contentTypes), + .text("_rels/.rels", rootRelationships), + .text("docProps/app.xml", appProperties), + .text("docProps/core.xml", coreProperties), + .text("xl/workbook.xml", workbook), + .text("xl/_rels/workbook.xml.rels", workbookRelationships), + .text("xl/styles.xml", styles), + .text("xl/worksheets/sheet1.xml", isCompactReport ? compactReportSheet : summarySheet) + ] + + if !isCompactReport { + entries += [ + .text("xl/worksheets/sheet2.xml", snapshotsSheet), + .text("xl/worksheets/sheet3.xml", diarySheet) + ] + } + + if hasChart { + entries += [ + .text("xl/worksheets/_rels/sheet1.xml.rels", summarySheetRelationships), + .text("xl/drawings/drawing1.xml", summaryDrawing), + .text("xl/drawings/_rels/drawing1.xml.rels", summaryDrawingRelationships), + .text("xl/charts/chart1.xml", moodChart) + ] + } + + return entries + } + + private var average: Double? { + guard !snapshots.isEmpty else { return nil } + return snapshots.map(\.rating.score).reduce(0, +) / Double(snapshots.count) + } + + private var reportDayCount: Int { + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: startDate) + let endDay = calendar.startOfDay(for: endDate) + return (calendar.dateComponents([.day], from: startDay, to: endDay).day ?? 0) + 1 + } + + private var isCompactReport: Bool { + reportDayCount <= 14 + } + + private var hasChart: Bool { + !snapshots.isEmpty || !entries.isEmpty + } + + private var contentTypes: String { + let chartTypes = hasChart + ? """ + + + """ + : "" + let extraSheets = isCompactReport + ? "" + : """ + + + """ + + return """ + + + + + + + + + + \(extraSheets) + \(chartTypes) + + """ + } + + private var rootRelationships: String { + """ + + + + + + + """ + } + + private var summarySheetRelationships: String { + """ + + + + + """ + } + + private var summaryDrawingRelationships: String { + """ + + + + + """ + } + + private var appProperties: String { + let sheetCount = isCompactReport ? 1 : 3 + let sheetTitles = isCompactReport + ? "Report" + : "ÜbersichtMomentaufnahmenTagebuch" + + return """ + + + FeelAloud + 0 + false + Arbeitsblätter\(sheetCount) + \(sheetTitles) + + """ + } + + private var legacyAppProperties: String { + """ + + + FeelAloud + 0 + false + Arbeitsblätter3 + ÜbersichtMomentaufnahmenTagebuch + + """ + } + + private var coreProperties: String { + let isoDate = ISO8601DateFormatter().string(from: .now) + return """ + + + Stimmungsreport + FeelAloud + \(isoDate) + \(isoDate) + + """ + } + + private var workbook: String { + let sheets = isCompactReport + ? "" + : """ + + + + """ + + return """ + + + + + \(sheets) + + + + """ + } + + private var workbookRelationships: String { + let extraSheets = isCompactReport + ? "" + : """ + + + """ + + return """ + + + + \(extraSheets) + + + """ + } + + private var styles: String { + """ + + + + + + + + + + + + + + + + + + + + + """ + } + + private var summarySheet: String { + var rows: [[CellValue]] = [ + [.text("Stimmungsreport"), .text("Wert")], + [.text("Zeitraum von"), .text(dateFormatter.string(from: startDate))], + [.text("Zeitraum bis"), .text(dateFormatter.string(from: endDate))], + [.text("Momentaufnahmen"), .number(Double(snapshots.count))] + ] + + if let average { + rows.append([.text("Durchschnitt (1–5)"), .decimal(average)]) + rows.append([.text("Durchschnitt als Bewertung"), .text(SnapshotRating.label(for: average))]) + } else { + rows.append([.text("Durchschnitt (1–5)"), .text("Keine Daten")]) + rows.append([.text("Durchschnitt als Bewertung"), .text("Keine Daten")]) + } + + for rating in SnapshotRating.allCases { + let snapshotCount = snapshots.filter { $0.rating.rawValue == rating.rawValue }.count + let entryCount = entries.filter { $0.mood.rawValue == rating.rawValue }.count + let count = snapshotCount + entryCount + rows.append([.text(rating.rawValue), .number(Double(count))]) + } + rows.append([.text("Ausführliche Tagebucheinträge"), .number(Double(entries.count))]) + + return worksheet( + rows: rows, + columnWidths: [30, 24], + headerRow: true, + autoFilter: false, + drawingRelationshipID: hasChart ? "rId1" : nil, + freezeRow: nil + ) + } + + private var compactReportSheet: String { + var rows = summaryRows + rows.append([.text(""), .text("")]) + rows.append([.text("Stimmungsverteilung"), .text("Anzahl")]) + rows += ratingDistributionRows + rows.append([.text(""), .text("")]) + rows.append([ + .text("Momentaufnahmen"), + .text("Bewertung"), + .text("Wert"), + .text("Besonderheit"), + .text("Quelle") + ]) + rows += snapshots.map { snapshot in + [ + .text("\(dateFormatter.string(from: snapshot.createdAt)) \(timeFormatter.string(from: snapshot.createdAt))"), + .text(snapshot.rating.rawValue), + .number(snapshot.rating.score), + .text(snapshot.followUpNote), + .text(snapshot.source == .notification ? "Erinnerung" : "Manuell") + ] + } + rows.append([.text(""), .text("")]) + rows.append([ + .text("Tagebuch"), + .text("Stimmung"), + .text("Wert"), + .text("Sozialkontakt"), + .text("Musik"), + .text("Stress"), + .text("Text kurz") + ]) + rows += entries.map { compactDiaryRow(for: $0) } + rows.append([.text(""), .text("")]) + rows.append([ + .text("Tagebuch Details"), + .text("Song"), + .text("Ohrwurm"), + .text("Leidensdruck"), + .text("Gegenmaßnahme"), + .text("Gesprochener Text") + ]) + rows += entries.map { diaryDetailRow(for: $0) } + + return worksheet( + rows: rows, + columnWidths: [20, 16, 10, 18, 16, 42, 14], + headerRow: true, + autoFilter: false, + drawingRelationshipID: hasChart ? "rId1" : nil, + freezeRow: 15 + ) + } + + private var summaryRows: [[CellValue]] { + var rows: [[CellValue]] = [ + [.text("Stimmungsreport"), .text("Wert")], + [.text("Zeitraum von"), .text(dateFormatter.string(from: startDate))], + [.text("Zeitraum bis"), .text(dateFormatter.string(from: endDate))], + [.text("Momentaufnahmen"), .number(Double(snapshots.count))] + ] + + if let average { + rows.append([.text("Durchschnitt (1-5)"), .decimal(average)]) + rows.append([.text("Durchschnitt als Bewertung"), .text(SnapshotRating.label(for: average))]) + } else { + rows.append([.text("Durchschnitt (1-5)"), .text("Keine Daten")]) + rows.append([.text("Durchschnitt als Bewertung"), .text("Keine Daten")]) + } + + rows.append([.text("Ausführliche Tagebucheinträge"), .number(Double(entries.count))]) + return rows + } + + private var ratingDistributionRows: [[CellValue]] { + SnapshotRating.allCases.map { rating in + let snapshotCount = snapshots.filter { $0.rating.rawValue == rating.rawValue }.count + let entryCount = entries.filter { $0.mood.rawValue == rating.rawValue }.count + return [.text(rating.rawValue), .number(Double(snapshotCount + entryCount))] + } + } + + private var snapshotsSheet: String { + var rows: [[CellValue]] = [[ + .text("Datum"), + .text("Uhrzeit"), + .text("Bewertung"), + .text("Wert"), + .text("Besonderheit"), + .text("Quelle") + ]] + + rows += snapshots.map { snapshot in + [ + .text(dateFormatter.string(from: snapshot.createdAt)), + .text(timeFormatter.string(from: snapshot.createdAt)), + .text(snapshot.rating.rawValue), + .number(snapshot.rating.score), + .text(snapshot.followUpNote), + .text(snapshot.source == .notification ? "Erinnerung" : "Manuell") + ] + } + + return worksheet( + rows: rows, + columnWidths: [14, 11, 18, 10, 48, 14], + headerRow: true, + autoFilter: true, + drawingRelationshipID: nil, + freezeRow: 1 + ) + } + + private var diarySheet: String { + var rows: [[CellValue]] = [[ + .text("Datum"), + .text("Uhrzeit"), + .text("Stimmung"), + .text("Wert"), + .text("MusikImKopf"), + .text("SongName"), + .text("OhrwurmStärke"), + .text("Sozialkontakt"), + .text("Leidensdruck"), + .text("Gegenmaßnahmen"), + .text("GegenmaßnahmeAktivität"), + .text("Stress"), + .text("GesprochenerText") + ]] + + rows += entries.map { entry in + diaryRow(for: entry) + } + + return worksheet( + rows: rows, + columnWidths: [14, 11, 18, 10, 16, 28, 18, 17, 17, 19, 32, 12, 55], + headerRow: true, + autoFilter: true, + drawingRelationshipID: nil, + freezeRow: 1 + ) + } + + private var diaryRows: [[CellValue]] { + entries.map { diaryRow(for: $0) } + } + + private func diaryRow(for entry: MoodEntry) -> [CellValue] { + [ + .text(dateFormatter.string(from: entry.createdAt)), + .text(timeFormatter.string(from: entry.createdAt)), + .text(entry.mood.rawValue), + .number(entry.mood.score), + .text(entry.musicInHead ? "Ja" : "Nein"), + .text(entry.musicInHead ? entry.songName : ""), + .text(entry.musicInHead ? entry.earwormStrengthRaw : ""), + .text(entry.socialContact ? "Ja" : "Nein"), + .text(entry.musicInHead ? entry.distressRaw : ""), + .text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""), + .text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""), + .text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")), + .text(entry.transcript) + ] + } + + private func compactDiaryRow(for entry: MoodEntry) -> [CellValue] { + [ + .text("\(dateFormatter.string(from: entry.createdAt)) \(timeFormatter.string(from: entry.createdAt))"), + .text(entry.mood.rawValue), + .number(entry.mood.score), + .text(entry.socialContact ? "Ja" : "Nein"), + .text(entry.musicInHead ? "Ja" : "Nein"), + .text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")), + .text(shortened(entry.transcript, limit: 90)) + ] + } + + private func diaryDetailRow(for entry: MoodEntry) -> [CellValue] { + [ + .text("\(dateFormatter.string(from: entry.createdAt)) \(timeFormatter.string(from: entry.createdAt))"), + .text(entry.musicInHead ? entry.songName : ""), + .text(entry.musicInHead ? entry.earwormStrengthRaw : ""), + .text(entry.musicInHead ? entry.distressRaw : ""), + .text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""), + .text(entry.transcript) + ] + } + + private var summaryDrawing: String { + """ + + + + 3010 + 70130 + + + + + + + + + + + + + + + + + + + """ + } + + private var moodChart: String { + let categoryRange = isCompactReport ? "'Report'!$A$10:$A$14" : "'Übersicht'!$A$7:$A$11" + let valueRange = isCompactReport ? "'Report'!$B$10:$B$14" : "'Übersicht'!$B$7:$B$11" + + return """ + + + + + + Stimmungsverteilung + + + + + + + + + + + + Anzahl + \(categoryRange) + \(valueRange) + + + + + + + + + + + + + + + + + + + + + + + Anzahl + + + + + + + + + + + """ + } + + private func chartSeries( + title: String, + index: Int, + categories: String, + values: String + ) -> String { + """ + + + + \(xmlEscaped(title)) + \(categories) + \(values) + + + """ + } + + private func worksheet( + rows: [[CellValue]], + columnWidths: [Double], + headerRow: Bool, + autoFilter: Bool, + drawingRelationshipID: String?, + freezeRow: Int? + ) -> String { + let maxColumns = rows.map(\.count).max() ?? 1 + let maxRows = max(rows.count, 1) + let dimension = "A1:\(columnName(maxColumns))\(maxRows)" + let columns = columnWidths.enumerated().map { index, width in + "" + }.joined() + + let sheetRows = rows.enumerated().map { rowIndex, row in + let cells = row.enumerated().map { columnIndex, value in + let reference = "\(columnName(columnIndex + 1))\(rowIndex + 1)" + let style = headerRow && rowIndex == 0 ? " s=\"1\"" : "" + switch value { + case .text(let text): + return "\(xmlEscaped(text))" + case .number(let number): + return "\(number)" + case .decimal(let number): + return "\(number)" + } + }.joined() + let rowAttributes = containsLongText(row) ? " ht=\"54\" customHeight=\"1\"" : "" + return "\(cells)" + }.joined() + + let filter = autoFilter && rows.count > 1 + ? "" + : "" + let drawing = drawingRelationshipID + .map { "" } ?? "" + let pane = freezeRow.map { + """ + + + """ + } ?? "" + + return """ + + + + \(pane) + + \(columns) + \(sheetRows) + \(filter) + \(drawing) + + """ + } + + private func columnName(_ index: Int) -> String { + var value = max(index, 1) + var name = "" + while value > 0 { + value -= 1 + let scalar = UnicodeScalar(65 + (value % 26))! + name.insert(Character(scalar), at: name.startIndex) + value /= 26 + } + return name + } + + private func xmlEscaped(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + private func shortened(_ value: String, limit: Int) -> String { + guard value.count > limit else { return value } + let endIndex = value.index(value.startIndex, offsetBy: limit) + return "\(value[.. Bool { + row.contains { value in + if case .text(let text) = value { + return text.count > 70 || text.contains("\n") + } + return false + } + } + + private enum CellValue { + case text(String) + case number(Double) + case decimal(Double) + } +} + +private enum SimpleZipArchive { + struct Entry { + let path: String + let data: Data + + static func text(_ path: String, _ value: String) -> Entry { + Entry(path: path, data: Data(value.trimmingCharacters(in: .whitespacesAndNewlines).utf8)) + } + } + + private struct CentralRecord { + let filename: Data + let crc32: UInt32 + let size: UInt32 + let localHeaderOffset: UInt32 + } + + enum ArchiveError: LocalizedError { + case archiveTooLarge + case invalidFilename + + var errorDescription: String? { + switch self { + case .archiveTooLarge: + "Der Excel-Export ist zu groß." + case .invalidFilename: + "Der Excel-Export enthält einen ungültigen Dateinamen." + } + } + } + + static func write(entries: [Entry], to url: URL) throws { + var archive = Data() + var records: [CentralRecord] = [] + + for entry in entries { + let filename = Data(entry.path.utf8) + guard !filename.isEmpty, filename.count <= Int(UInt16.max) else { + throw ArchiveError.invalidFilename + } + guard entry.data.count <= Int(UInt32.max), archive.count <= Int(UInt32.max) else { + throw ArchiveError.archiveTooLarge + } + + let checksum = crc32(entry.data) + let size = UInt32(entry.data.count) + let offset = UInt32(archive.count) + + archive.appendUInt32LE(0x04034B50) + archive.appendUInt16LE(20) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0x0021) + archive.appendUInt32LE(checksum) + archive.appendUInt32LE(size) + archive.appendUInt32LE(size) + archive.appendUInt16LE(UInt16(filename.count)) + archive.appendUInt16LE(0) + archive.append(filename) + archive.append(entry.data) + + records.append( + CentralRecord( + filename: filename, + crc32: checksum, + size: size, + localHeaderOffset: offset + ) + ) + } + + guard records.count <= Int(UInt16.max), archive.count <= Int(UInt32.max) else { + throw ArchiveError.archiveTooLarge + } + + let centralOffset = UInt32(archive.count) + + for record in records { + archive.appendUInt32LE(0x02014B50) + archive.appendUInt16LE(20) + archive.appendUInt16LE(20) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0x0021) + archive.appendUInt32LE(record.crc32) + archive.appendUInt32LE(record.size) + archive.appendUInt32LE(record.size) + archive.appendUInt16LE(UInt16(record.filename.count)) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt32LE(0) + archive.appendUInt32LE(record.localHeaderOffset) + archive.append(record.filename) + } + + guard archive.count <= Int(UInt32.max) else { + throw ArchiveError.archiveTooLarge + } + + let centralSize = UInt32(archive.count) - centralOffset + let recordCount = UInt16(records.count) + + archive.appendUInt32LE(0x06054B50) + archive.appendUInt16LE(0) + archive.appendUInt16LE(0) + archive.appendUInt16LE(recordCount) + archive.appendUInt16LE(recordCount) + archive.appendUInt32LE(centralSize) + archive.appendUInt32LE(centralOffset) + archive.appendUInt16LE(0) + + try archive.write(to: url, options: .atomic) + } + + private static func crc32(_ data: Data) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { + if crc & 1 == 1 { + crc = (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >>= 1 + } + } + } + return crc ^ 0xFFFF_FFFF + } +} + +private extension Data { + mutating func appendUInt16LE(_ value: UInt16) { + var littleEndian = value.littleEndian + let bytes = Swift.withUnsafeBytes(of: &littleEndian) { Array($0) } + append(contentsOf: bytes) + } + + mutating func appendUInt32LE(_ value: UInt32) { + var littleEndian = value.littleEndian + let bytes = Swift.withUnsafeBytes(of: &littleEndian) { Array($0) } + append(contentsOf: bytes) + } +} diff --git a/Moment/AppIntent.swift b/Moment/AppIntent.swift new file mode 100644 index 0000000..408baa0 --- /dev/null +++ b/Moment/AppIntent.swift @@ -0,0 +1,92 @@ +import AppIntents +import Foundation +import WidgetKit + +enum MomentRatingValue: String, AppEnum { + case veryGood = "Sehr gut" + case good = "Gut" + case middle = "Mittel" + case bad = "Schlecht" + case veryBad = "Sehr schlecht" + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Stimmung") + + static var caseDisplayRepresentations: [MomentRatingValue: DisplayRepresentation] = [ + .veryGood: "Sehr gut", + .good: "Gut", + .middle: "Mittel", + .bad: "Schlecht", + .veryBad: "Sehr schlecht" + ] + + var emoji: String { + switch self { + case .veryGood: "😄" + case .good: "🙂" + case .middle: "😐" + case .bad: "🙁" + case .veryBad: "😞" + } + } +} + +struct TrackMomentIntent: AppIntent { + static var title: LocalizedStringResource = "Momentaufnahme speichern" + static var description = IntentDescription("Speichert eine schnelle Stimmungsabfrage aus dem Homescreen-Widget.") + static var openAppWhenRun = true + + @Parameter(title: "Stimmung") + var rating: MomentRatingValue + + init() { + rating = .middle + } + + init(rating: MomentRatingValue) { + self.rating = rating + } + + func perform() async throws -> some IntentResult { + MomentWidgetStorage.enqueue(ratingRawValue: rating.rawValue) + WidgetCenter.shared.reloadTimelines(ofKind: Moment.kind) + return .result() + } +} + +enum MomentWidgetStorage { + static let appGroupIdentifier = "group.de.feelaloud" + static let pendingSnapshotsKey = "pendingWidgetSnapshots" + static let lastRatingKey = "lastWidgetSnapshotRating" + static let lastRecordedAtKey = "lastWidgetSnapshotDate" + + static var defaults: UserDefaults { + UserDefaults(suiteName: appGroupIdentifier) ?? .standard + } + + static func enqueue(ratingRawValue: String) { + let defaults = defaults + var snapshots = pendingSnapshots(from: defaults) + snapshots.append(PendingWidgetSnapshot(ratingRawValue: ratingRawValue, createdAt: .now)) + + if let data = try? JSONEncoder().encode(snapshots) { + defaults.set(data, forKey: pendingSnapshotsKey) + } + + defaults.set(ratingRawValue, forKey: lastRatingKey) + defaults.set(Date.now, forKey: lastRecordedAtKey) + } + + private static func pendingSnapshots(from defaults: UserDefaults) -> [PendingWidgetSnapshot] { + guard let data = defaults.data(forKey: pendingSnapshotsKey), + let snapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else { + return [] + } + + return snapshots + } +} + +struct PendingWidgetSnapshot: Codable { + var ratingRawValue: String + var createdAt: Date +} diff --git a/Moment/Assets.xcassets/AccentColor.colorset/Contents.json b/Moment/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Moment/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Moment/Assets.xcassets/AppIcon.appiconset/Contents.json b/Moment/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Moment/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Moment/Assets.xcassets/Contents.json b/Moment/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Moment/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Moment/Assets.xcassets/WidgetBackground.colorset/Contents.json b/Moment/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Moment/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Moment/Info.plist b/Moment/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/Moment/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Moment/Moment.swift b/Moment/Moment.swift new file mode 100644 index 0000000..94d1aab --- /dev/null +++ b/Moment/Moment.swift @@ -0,0 +1,134 @@ +import AppIntents +import Foundation +import SwiftUI +import WidgetKit + +struct MomentProvider: TimelineProvider { + func placeholder(in context: Context) -> MomentEntry { + MomentEntry(date: .now, lastRating: nil, lastRecordedAt: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (MomentEntry) -> Void) { + completion(currentEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [currentEntry()], policy: .never)) + } + + private func currentEntry() -> MomentEntry { + let defaults = MomentWidgetStorage.defaults + let lastRating = defaults.string(forKey: MomentWidgetStorage.lastRatingKey) + .flatMap(MomentRatingValue.init(rawValue:)) + let lastRecordedAt = defaults.object(forKey: MomentWidgetStorage.lastRecordedAtKey) as? Date + + return MomentEntry(date: .now, lastRating: lastRating, lastRecordedAt: lastRecordedAt) + } +} + +struct MomentEntry: TimelineEntry { + let date: Date + let lastRating: MomentRatingValue? + let lastRecordedAt: Date? +} + +struct MomentEntryView: View { + let entry: MomentEntry + + private let ratings: [MomentRatingValue] = [ + .veryGood, + .good, + .middle, + .bad, + .veryBad + ] + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text("Wie geht es dir?") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.82) + .shadow(color: .black.opacity(0.12), radius: 2, y: 1) + + Spacer(minLength: 6) + + if showsSavedState { + Text("Gespeichert") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + + HStack(spacing: 7) { + ForEach(ratings, id: \.self) { rating in + Button(intent: TrackMomentIntent(rating: rating)) { + ZStack(alignment: .topTrailing) { + Text(rating.emoji) + .font(.system(size: entry.lastRating == rating ? 28 : 25, weight: .bold)) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background(buttonBackground(for: rating), in: RoundedRectangle(cornerRadius: 8)) + + if showsSavedState, entry.lastRating == rating { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white, Color.green) + .offset(x: 4, y: -4) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text(rating.rawValue)) + } + } + } + .padding(14) + .containerBackground(for: .widget) { + LinearGradient( + colors: [ + Color(red: 0.82, green: 0.90, blue: 0.76), + Color(red: 0.66, green: 0.78, blue: 0.60) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + private var showsSavedState: Bool { + guard let lastRecordedAt = entry.lastRecordedAt else { return false } + return Date.now.timeIntervalSince(lastRecordedAt) < 90 + } + + private func buttonBackground(for rating: MomentRatingValue) -> Color { + guard showsSavedState, entry.lastRating == rating else { + return .white.opacity(0.68) + } + + return .white.opacity(0.92) + } +} + +struct Moment: Widget { + nonisolated static let kind = "Moment" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: MomentProvider()) { entry in + MomentEntryView(entry: entry) + } + .configurationDisplayName("Momentaufnahme") + .description("Tracke deine Stimmung direkt vom Homescreen.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +#Preview(as: .systemSmall) { + Moment() +} timeline: { + MomentEntry(date: .now, lastRating: .good, lastRecordedAt: .now) +} diff --git a/Moment/MomentBundle.swift b/Moment/MomentBundle.swift new file mode 100644 index 0000000..8d224ce --- /dev/null +++ b/Moment/MomentBundle.swift @@ -0,0 +1,16 @@ +// +// MomentBundle.swift +// Moment +// +// Created by Tom Misch on 22.07.26. +// + +import WidgetKit +import SwiftUI + +@main +struct MomentBundle: WidgetBundle { + var body: some Widget { + Moment() + } +} diff --git a/Moment/MomentControl.swift b/Moment/MomentControl.swift new file mode 100644 index 0000000..f50b4d2 --- /dev/null +++ b/Moment/MomentControl.swift @@ -0,0 +1,77 @@ +// +// MomentControl.swift +// Moment +// +// Created by Tom Misch on 22.07.26. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct MomentControl: ControlWidget { + static let kind: String = "de.feelaloud.Moment" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension MomentControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + MomentControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return MomentControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/Moment/MomentLiveActivity.swift b/Moment/MomentLiveActivity.swift new file mode 100644 index 0000000..9957c5f --- /dev/null +++ b/Moment/MomentLiveActivity.swift @@ -0,0 +1,80 @@ +// +// MomentLiveActivity.swift +// Moment +// +// Created by Tom Misch on 22.07.26. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct MomentAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // Dynamic stateful properties about your activity go here! + var emoji: String + } + + // Fixed non-changing properties about your activity go here! + var name: String +} + +struct MomentLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: MomentAttributes.self) { context in + // Lock screen/banner UI goes here + VStack { + Text("Hello \(context.state.emoji)") + } + .activityBackgroundTint(Color.cyan) + .activitySystemActionForegroundColor(Color.black) + + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here. Compose the expanded UI through + // various regions, like leading/trailing/center/bottom + DynamicIslandExpandedRegion(.leading) { + Text("Leading") + } + DynamicIslandExpandedRegion(.trailing) { + Text("Trailing") + } + DynamicIslandExpandedRegion(.bottom) { + Text("Bottom \(context.state.emoji)") + // more content + } + } compactLeading: { + Text("L") + } compactTrailing: { + Text("T \(context.state.emoji)") + } minimal: { + Text(context.state.emoji) + } + .widgetURL(URL(string: "http://www.apple.com")) + .keylineTint(Color.red) + } + } +} + +extension MomentAttributes { + fileprivate static var preview: MomentAttributes { + MomentAttributes(name: "World") + } +} + +extension MomentAttributes.ContentState { + fileprivate static var smiley: MomentAttributes.ContentState { + MomentAttributes.ContentState(emoji: "😀") + } + + fileprivate static var starEyes: MomentAttributes.ContentState { + MomentAttributes.ContentState(emoji: "🤩") + } +} + +#Preview("Notification", as: .content, using: MomentAttributes.preview) { + MomentLiveActivity() +} contentStates: { + MomentAttributes.ContentState.smiley + MomentAttributes.ContentState.starEyes +} diff --git a/MomentExtension.entitlements b/MomentExtension.entitlements new file mode 100644 index 0000000..fbfa640 --- /dev/null +++ b/MomentExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.feelaloud + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cca9bf1 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# FeelAloud + +**FeelAloud** ist eine native, datensparsame iPhone-App für sprachbasierte +Stimmungseinträge und kurze emotionale Check-ins. Der Name steht für zwei +Gedanken: Gefühle laut aussprechen (*feel aloud*) und Gefühle haben dürfen +(*feel allowed*). + +Die App besitzt kein Benutzerkonto und keinen eigenen Server. Tagebuchdaten, +Momentaufnahmen und Einstellungen verbleiben im App-Container auf dem iPhone. +Spracherkennung und strukturierte Auswertung sind ausdrücklich auf lokale +Apple-Technologien konfiguriert. + +## Funktionsumfang + +- Spracheinträge mit lokaler deutscher Spracherkennung +- Strukturierung des gesprochenen Texts mit Apples Foundation Models +- Manuelle Kontrolle und Korrektur vor dem Speichern +- Schneller Check-in mit fünf Stufen von **Sehr gut** bis **Sehr schlecht** +- Zwei oder drei frei einstellbare lokale Erinnerungen pro Tag +- Kontextfrage beim nächsten normalen Öffnen am selben Tag nach auffälligen + Check-ins (**Sehr gut**, **Schlecht** oder **Sehr schlecht**) +- Verlauf für 7 Tage, 30 Tage, alle Daten oder einen eigenen Zeitraum +- Liniendiagramm und Durchschnitt für den gewählten Zeitraum +- Echter Excel-Export (`.xlsx`) mit Übersicht, Check-ins und Tagebucheinträgen +- CSV-Export im bisherigen Numbers-kompatiblen Format +- App Intent **Eintrag aufnehmen** für Kurzbefehle und den Action Button + +## Technische Voraussetzungen + +- Apple-Intelligence-kompatibles iPhone +- iOS 26 oder neuer +- Apple Intelligence aktiviert und vollständig eingerichtet +- Lokale deutsche Spracherkennung auf dem Gerät verfügbar +- Mac mit Xcode 26 oder neuer +- Für TestFlight: aktive Mitgliedschaft im Apple Developer Program + +Die App ist ausschließlich für das iPhone konfiguriert +(`TARGETED_DEVICE_FAMILY = 1`). Eine iPad-Oberfläche gehört derzeit nicht zum +Projektumfang. + +## Projekt öffnen und starten + +1. Repository auf einen Mac klonen. +2. `FeelAloud.xcodeproj` mit Xcode 26 oder neuer öffnen. +3. Projekt **FeelAloud** und Target **FeelAloud** auswählen. +4. Unter **Signing & Capabilities** das eigene Development Team wählen. +5. Prüfen, ob die Bundle-ID `de.feelaloud` im Team verfügbar ist; + andernfalls eine eigene eindeutige Reverse-DNS-ID eintragen. +6. Ein geeignetes iPhone auswählen und mit **Run** (`⌘R`) installieren. + +Beim ersten Spracheintrag fragt iOS nach Mikrofon- und +Spracherkennungszugriff. Die Mitteilungsfreigabe wird erst angefragt, wenn die +täglichen Erinnerungen aktiviert werden. + +## Build ohne Code Signing + +Auf einem Mac kann ein Simulator-Build beispielsweise so geprüft werden: + +```bash +xcodebuild \ + -project FeelAloud.xcodeproj \ + -scheme FeelAloud \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + build +``` + +Die lokale Foundation-Models-Funktion muss zusätzlich auf einem echten, +kompatiblen iPhone geprüft werden. Simulator-Ergebnisse ersetzen diesen Test +nicht. + +## TestFlight + +Der vollständige Ablauf – einschließlich App Store Connect, Archivierung, +Build-Nummer und Testerfreigabe – steht in +[docs/TESTFLIGHT.md](docs/TESTFLIGHT.md). + +## Datenschutz und sensible Daten + +FeelAloud verarbeitet persönliche Stimmungseinträge. Deshalb gelten im Projekt +folgende Grundsätze: + +- kein Konto, kein eigenes Backend und kein API-Schlüssel; +- lokale SwiftData-Speicherung; +- lokale Spracherkennung durch `requiresOnDeviceRecognition = true`; +- lokale Auswertung durch `SystemLanguageModel`; +- lokale Mitteilungen statt eines Push-Servers; +- Export nur nach ausdrücklicher Aktion der Person über das iOS-Share-Sheet; +- keine Analyse-, Werbe- oder Tracking-SDKs. + +Details, Grenzen und Hinweise für die App-Store-Datenschutzangaben sind in +[docs/PRIVACY.md](docs/PRIVACY.md) dokumentiert. Das Projekt enthält außerdem +ein `PrivacyInfo.xcprivacy`-Manifest. + +## Architektur + +| Bereich | Umsetzung | +| --- | --- | +| Oberfläche | SwiftUI | +| Persistenz | SwiftData | +| Spracheingabe | Speech + AVFoundation, Deutsch, lokal erzwungen | +| Lokale KI | Foundation Models mit typisierter `@Generable`-Antwort | +| Diagramm | Swift Charts | +| Erinnerungen | UserNotifications, täglich wiederholend | +| Export | Eigener XLSX/ZIP-Writer und CSV | +| Systemintegration | App Intents und UIKit Share Sheet | + +Eine ausführliche Beschreibung der Komponenten und Datenflüsse befindet sich +in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Qualitätssicherung + +Die manuelle Abnahmecheckliste steht in +[docs/QA-CHECKLIST.md](docs/QA-CHECKLIST.md). Sie umfasst unter anderem: + +- Erststart und Berechtigungen; +- lokale Spracheingabe und KI-Fallback; +- alle fünf Check-in-Stufen und Kontextfragen; +- Erinnerungen bei Vordergrund-, Hintergrund- und Kaltstart; +- Datumsfilter, Diagramm, XLSX- und CSV-Export; +- Action Button, Dynamic Type, VoiceOver und Dark Mode. + +## Aktuelle Grenzen + +- Die Oberfläche und Auswertungsanweisungen sind derzeit deutschsprachig. +- Foundation Models und lokale Spracherkennung hängen von Geräte-, Sprach- und + Apple-Intelligence-Verfügbarkeit ab; eine manuelle Eingabe bleibt möglich. +- Es existiert noch keine versionierte SwiftData-Migrationsstrategie. +- Automatisierte UI- und Unit-Tests sind noch nicht als eigenes Test-Target + eingerichtet. +- Eine Löschung aller gespeicherten Daten innerhalb der App ist noch nicht + vorhanden; bis dahin entfernt das Löschen der App den lokalen App-Container. + +FeelAloud dient der persönlichen Dokumentation. Die App stellt keine +medizinische Diagnose, Therapie oder Krisenhilfe bereit. + +## Repository-Struktur + +```text +FeelAloud.xcodeproj/ Xcode-Projekt und Build-Konfiguration +FeelAloud/ Swift-Quellcode, Assets und Privacy Manifest +docs/ Architektur-, Datenschutz-, Test- und Release-Doku +AppIcon.svg Bearbeitbare Vektorquelle des App-Icons +CHANGELOG.md Versionshistorie +``` + +## Version + +Aktueller Projektstand: **1.0 (Build 1)**. Änderungen werden in +[CHANGELOG.md](CHANGELOG.md) festgehalten. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cb6680f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,152 @@ +# Architektur + +## Überblick + +FeelAloud ist eine einzelne native iOS-App ohne Backend und ohne externe +Paketabhängigkeiten. Alle Produktdaten werden mit Apple-Frameworks auf dem +Gerät verarbeitet. Das Xcode-Projekt enthält derzeit ein App-Target. + +## Komponenten + +| Datei | Verantwortung | +| --- | --- | +| `FeelAloudApp.swift` | App-Einstieg, SwiftData-Container und globaler Router | +| `RootView.swift` | Tabs, Sheets, App-Intent-Ausführung und Follow-up-Auswahl | +| `HomeView.swift` | Startseite, Schnellaktionen und 7-Tage-Zusammenfassung | +| `EntryFlowView.swift` | Aufnahme-, Analyse-, Prüf- und Speicherablauf | +| `SpeechRecognizer.swift` | Mikrofon, lokale deutsche Spracherkennung und Transkript | +| `MoodAnalysisService.swift` | Typisierte Extraktion über Foundation Models | +| `MoodEntry.swift` | Datenmodell eines ausführlichen Tagebucheintrags | +| `MoodSnapshot.swift` | Datenmodell und Regeln eines kurzen Check-ins | +| `SnapshotView.swift` | Fünf-Stufen-Auswahl und spätere Kontextfrage | +| `SnapshotNotificationScheduler.swift` | Lokale Erinnerungen und Notification-Routing | +| `HistoryView.swift` | Zeitraumfilter, Diagramm, Detailansichten und Exportstart | +| `SettingsView.swift` | Erinnerungszeiten, KI-Status, CSV-Export und Hinweise | +| `XLSXExporter.swift` | OOXML-Arbeitsmappe und minimaler ZIP-Writer | +| `CSVExporter.swift` | Semikolon-CSV mit UTF-8-BOM für Numbers/Excel | +| `StartDiaryEntryIntent.swift` | Kurzbefehl und Action-Button-Integration | +| `ShareSheet.swift` | Übergang zum systemweiten iOS-Share-Sheet | + +## Datenfluss: ausführlicher Eintrag + +1. `EntryFlowView` startet `SpeechRecognizer`. +2. `SpeechRecognizer` fordert Mikrofon- und Speech-Berechtigung an und setzt + `requiresOnDeviceRecognition = true`. +3. Nach dem Stoppen erhält `MoodAnalysisService` ausschließlich das erkannte + Transkript. +4. `LanguageModelSession` erzeugt eine typisierte `GeneratedDiaryEntry`-Antwort. +5. Die Person prüft und korrigiert alle Werte im Formular. +6. Erst die bestätigten Werte werden als `MoodEntry` in SwiftData gespeichert. + +Ist Sprache oder Foundation Models nicht verfügbar, kann die Person zur +manuellen Eingabe wechseln. Die App fällt nicht auf einen eigenen Cloud-Dienst +zurück. + +## Datenfluss: Momentaufnahme + +1. Ein Check-in wird manuell oder über eine lokale Mitteilung geöffnet. +2. Ein Tipp erzeugt einen `MoodSnapshot` mit Wert 1 bis 5 und Quelle. +3. Bei **Sehr gut**, **Schlecht** oder **Sehr schlecht** bleibt + `awaitsFollowUp` aktiv. +4. Beim nächsten normalen Aktivieren der App am selben Kalendertag sucht + `RootView` den jüngsten offenen Snapshot. +5. Eine Antwort setzt `followUpNote`; **Heute nicht mehr fragen** setzt + `followUpDismissed`. + +Das direkte Öffnen aus einer Mitteilung unterdrückt genau eine sofortige +Follow-up-Prüfung, damit zuerst die gewünschte Fünf-Stufen-Auswahl erscheint. + +## Persistenz + +Der `ModelContainer` verwaltet zwei SwiftData-Modelle: + +### MoodEntry + +| Feld | Zweck | +| --- | --- | +| `id`, `createdAt` | Eindeutigkeit und Zeitpunkt | +| `moodRaw` | Dreistufige Bewertung des ausführlichen Eintrags | +| `musicInHead`, `songName`, `earwormStrengthRaw` | Musik-/Ohrwurmangaben | +| `socialContact` | Sozialkontakt vorhanden | +| `distressRaw` | Leidensdruck durch Musik im Kopf | +| `countermeasures`, `countermeasureActivity` | Gegenmaßnahmen | +| `stress` | Stress, wenn keine Musik im Kopf vorhanden ist | +| `transcript` | Bestätigter gesprochener Text | + +### MoodSnapshot + +| Feld | Zweck | +| --- | --- | +| `id`, `createdAt` | Eindeutigkeit und Zeitpunkt | +| `ratingRaw` | Fünfstufige Bewertung | +| `followUpNote` | Später ergänzter Kontext | +| `followUpDismissed` | Kontextfrage für diesen Snapshot verworfen | +| `sourceRaw` | `manual` oder `notification` | + +Für zukünftige Schemaänderungen muss vor Veröffentlichung einer inkompatiblen +Version ein `VersionedSchema` mit `SchemaMigrationPlan` eingeführt werden. + +## Routing und Lebenszyklus + +`AppRouter` ist ein `@MainActor`-gebundener, beobachtbarer Singleton. Er hält +nur flüchtigen UI-Zustand. `RootView` reagiert auf: + +- manuelle Aktionen der Oberfläche; +- `StartDiaryEntryIntent`; +- Antworten auf lokale Mitteilungen; +- Wechsel der `scenePhase` zurück zu `.active`. + +Persistente Produktdaten liegen nicht im Router, sondern ausschließlich in +SwiftData beziehungsweise `@AppStorage` für Erinnerungseinstellungen. + +## Erinnerungen + +`SnapshotNotificationScheduler` verwendet drei stabile Request-IDs. Beim +Ändern der Konfiguration werden bestehende Requests entfernt und neu geplant. +Die Trigger enthalten nur Stunde und Minute und wiederholen sich täglich. + +Es handelt sich um lokale Notifications. APNs, Push-Zertifikate und ein Server +sind nicht erforderlich. + +## Export + +Der XLSX-Exporter erstellt drei Arbeitsblätter: + +1. **Übersicht** – Zeitraum, Anzahl, Durchschnitt und Verteilung; +2. **Momentaufnahmen** – Zeitpunkt, Bewertung, Kontext und Quelle; +3. **Tagebuch** – strukturierte Felder und Transkript. + +Die Arbeitsmappe besteht aus OOXML-Dateien in einem unkomprimierten ZIP-Archiv. +Text wird XML-escaped; ZIP-Einträge erhalten CRC32-Prüfsummen. Der CSV-Export +bleibt als kompatibler Alt-Export für ausführliche Einträge erhalten. + +Temporäre Exportdateien werden im temporären App-Verzeichnis erzeugt und erst +nach einer ausdrücklichen Aktion an das Share-Sheet übergeben. + +## Fehler- und Verfügbarkeitsmodell + +- Fehlende Berechtigungen werden als verständliche, lokale Fehlermeldung + angezeigt. +- Fehlende lokale Spracherkennung verhindert eine versehentliche Cloud- + Transkription. +- Vor jeder KI-Nutzung wird `SystemLanguageModel.default.availability` geprüft. +- Fehler beim Speichern werden aktuell nicht sichtbar eskaliert (`try?`). Vor + einem öffentlichen Release sollte dafür ein zentraler Fehlerpfad ergänzt + werden. +- FeelAloud erteilt keine medizinischen Ratschläge und gibt dem Modell + ausdrücklich nur eine Extraktionsaufgabe. + +## Abhängigkeiten + +Es gibt keine Drittanbieterpakete. Verwendete Frameworks: + +- AppIntents +- AVFoundation +- Charts +- Foundation / FoundationModels +- Observation +- Speech +- SwiftData +- SwiftUI +- UIKit +- UserNotifications diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 0000000..6f1f94e --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,107 @@ +# Datenschutz und Datenverarbeitung + +Dieses Dokument beschreibt den aktuellen technischen Stand von FeelAloud. Es +ist eine Entwicklungsdokumentation und ersetzt keine individuell geprüfte, +öffentlich erreichbare Datenschutzerklärung für den App Store. + +## Kurzfassung + +FeelAloud hat kein Konto, kein eigenes Backend, keine Werbung, kein Tracking +und keine Analyse-SDKs. Die App überträgt nach aktuellem Code keine +Tagebuchdaten an den Entwickler. Produktdaten verbleiben lokal, bis die Person +selbst einen Export über das iOS-Share-Sheet auslöst. + +## Verarbeitete Daten + +| Datenart | Zweck | Speicherort | Automatische Übertragung | +| --- | --- | --- | --- | +| Mikrofon-Audio während einer Aufnahme | Spracherkennung | Nur laufende Audio-Pipeline | Nein; On-Device-Erkennung wird erzwungen | +| Transkript | Tagebucheintrag und lokale Strukturierung | SwiftData nach Bestätigung | Nein | +| Stimmungs- und Ohrwurmfelder | Persönliche Dokumentation | SwiftData | Nein | +| Check-in-Wert und Kontextnotiz | Verlauf und Durchschnitt | SwiftData | Nein | +| Erinnerungszeiten | Lokale Notification-Planung | UserDefaults über `@AppStorage` | Nein | +| XLSX-/CSV-Datei | Freiwilliger Export | Temporäres App-Verzeichnis | Nur über selbst gewähltes Share-Ziel | + +## Berechtigungen + +### Mikrofon + +Wird erst beim Start einer Spracheingabe angefragt. Das Audio wird an +`SFSpeechAudioBufferRecognitionRequest` übergeben und nicht als Audiodatei +gespeichert. + +### Spracherkennung + +Der Request setzt `requiresOnDeviceRecognition = true`. Ist die lokale deutsche +Erkennung nicht verfügbar, bricht FeelAloud ab und bietet eine manuelle Eingabe +an. Es existiert kein eigener Cloud-Fallback. + +### Mitteilungen + +Wird erst beim Aktivieren täglicher Erinnerungen angefragt. Die Erinnerungen +werden mit `UNCalendarNotificationTrigger` lokal geplant. Der Notification- +Inhalt enthält keine gespeicherte Stimmung und keinen Tagebuchtext. + +## Lokale KI + +FeelAloud verwendet `SystemLanguageModel.default` aus Apples Foundation Models. +Vor der Nutzung wird die Verfügbarkeit geprüft. Das Modell erhält das +Transkript und soll ausschließlich strukturierte Felder extrahieren. Die Person +prüft das Ergebnis vor dem Speichern. + +Foundation Models sind kein Diagnosesystem. Der Prompt untersagt medizinische +Einschätzungen, Ratschläge und das Erfinden nicht genannter Inhalte. + +## Speicherung, Aufbewahrung und Löschung + +- Einträge und Check-ins liegen im lokalen SwiftData-Store des App-Containers. +- Einstellungen liegen in den lokalen UserDefaults des App-Containers. +- Es gibt keine automatische Aufbewahrungsfrist und keine Cloud-Synchronisation + durch eigenen App-Code. +- Der aktuelle Stand besitzt noch keine Schaltfläche **Alle Daten löschen**. + Das Entfernen der App löscht den lokalen App-Container. Eine In-App-Löschung + ist vor einer öffentlichen Veröffentlichung empfohlen. +- Falls später iCloud, Backup, Crash Reporting oder ein Backend ergänzt wird, + müssen dieses Dokument, die öffentliche Datenschutzerklärung, das Privacy + Manifest und die App-Store-Angaben neu bewertet werden. + +## Export und Weitergabe + +Ein Export erfolgt nur nach Tippen auf eine Exportfunktion. Anschließend zeigt +iOS das Share-Sheet. Ab diesem Zeitpunkt bestimmt die Person selbst das Ziel, +zum Beispiel Dateien, Mail oder eine Therapie-Praxis. Für den Datenschutz des +gewählten Ziels ist FeelAloud technisch nicht mehr zuständig. + +Exporte können besonders sensible Freitexte enthalten. Die Oberfläche und die +App-Store-Beschreibung sollten darauf deutlich hinweisen. + +## Privacy Manifest + +`FeelAloud/PrivacyInfo.xcprivacy` deklariert: + +- kein Tracking; +- keine vom Entwickler gesammelten Datentypen; +- Zugriff auf UserDefaults mit Required-Reason-Code `CA92.1`, ausschließlich + zum Lesen und Schreiben app-eigener Einstellungen. + +Vor jedem App-Store-Upload sollte Xcodes Privacy Report geprüft werden. Neue +Frameworks oder APIs können weitere Required-Reason-Einträge erforderlich +machen. + +## Erwartete App-Store-Datenschutzangabe + +Auf Basis des derzeitigen Codes ist voraussichtlich **Data Not Collected** die +passende Angabe für die durch den Entwickler erhobenen Daten. Diese Einschätzung +muss unmittelbar vor der Einreichung anhand des finalen Builds und aller +aktivierten Apple-Dienste erneut geprüft werden. + +TestFlight selbst kann im Auftrag von Apple Crash-, Nutzungs- und freiwillige +Feedbackdaten verarbeiten. Diese Verarbeitung ist von der Produktlogik der App +zu unterscheiden. + +## Medizinischer und sicherheitsbezogener Hinweis + +FeelAloud dient nur der persönlichen Dokumentation. Die App erkennt keine +Krisen zuverlässig und ersetzt weder medizinische Beratung noch Therapie oder +Notfallhilfe. Marketingtexte dürfen keine Diagnose- oder Heilversprechen +enthalten. diff --git a/docs/QA-CHECKLIST.md b/docs/QA-CHECKLIST.md new file mode 100644 index 0000000..fc402a2 --- /dev/null +++ b/docs/QA-CHECKLIST.md @@ -0,0 +1,129 @@ +# QA- und Release-Checkliste + +Diese Liste ist für einen Release Candidate auf einem echten iPhone gedacht. +Simulator-Tests allein reichen wegen Foundation Models, lokaler +Spracherkennung, Notifications und Action Button nicht aus. + +## Build und Installation + +- [ ] Clean Build mit der Release-Konfiguration erfolgreich +- [ ] Keine neuen Compilerwarnungen +- [ ] Automatisches Signing verwendet das erwartete Team +- [ ] Bundle-ID entspricht App Store Connect +- [ ] Marketing-Version und Build-Nummer sind korrekt +- [ ] App-Icon erscheint scharf auf Home Screen, Spotlight und Einstellungen +- [ ] Display Name lautet **FeelAloud** +- [ ] App läuft nur als iPhone-Target und nicht versehentlich als iPad-App + +## Erststart und Berechtigungen + +- [ ] Erststart zeigt keine unnötige Berechtigungsabfrage +- [ ] Mikrofonabfrage erscheint erst beim ersten Spracheintrag +- [ ] Speech-Abfrage erscheint nur für die Spracheingabe +- [ ] Ablehnung des Mikrofons erzeugt eine verständliche Meldung +- [ ] Ablehnung der Spracherkennung erzeugt eine verständliche Meldung +- [ ] Mitteilungsabfrage erscheint erst beim Aktivieren der Erinnerungen +- [ ] Bei abgelehnten Mitteilungen wird der Toggle zurückgesetzt + +## Spracheintrag + +- [ ] Aufnahme startet und die rote Stop-Schaltfläche ist erreichbar +- [ ] Teiltranskript erscheint während der Aufnahme +- [ ] Stoppen beendet Mikrofon und Audio Session +- [ ] Deutsche lokale Spracherkennung funktioniert im Flugmodus +- [ ] Nicht verfügbare lokale Spracherkennung fällt nicht auf Cloud zurück +- [ ] Leeres Transkript zeigt eine hilfreiche Meldung +- [ ] Manuelle Eingabe ist jederzeit möglich + +## Lokale KI und Prüfmaske + +- [ ] Foundation-Models-Status wird unter **Mehr** korrekt angezeigt +- [ ] Ein typischer deutscher Text wird sinnvoll strukturiert +- [ ] Nicht erwähnte Songnamen oder Aktivitäten werden nicht erfunden +- [ ] Alle KI-Felder lassen sich vor dem Speichern korrigieren +- [ ] Musik im Kopf blendet Ohrwurmfelder ein +- [ ] Ohne Musik im Kopf wird stattdessen Stress angeboten +- [ ] Nicht verfügbares Modell führt zur manuellen Eingabe +- [ ] Flugmodus verändert den lokalen KI-Pfad nicht + +## Momentaufnahmen und Follow-up + +- [ ] Manuelle Check-in-Schaltfläche öffnet genau fünf Gesichter +- [ ] Jede Stufe speichert exakt einen Snapshot +- [ ] Mehrfaches schnelles Tippen erzeugt keine Duplikate +- [ ] Haptisches Erfolgsfeedback wird ausgelöst +- [ ] **Gut** erzeugt keine Kontextfrage +- [ ] **Mittel** erzeugt keine Kontextfrage +- [ ] **Sehr gut** fragt beim nächsten normalen Öffnen am selben Tag nach +- [ ] **Schlecht** fragt beim nächsten normalen Öffnen am selben Tag nach +- [ ] **Sehr schlecht** fragt beim nächsten normalen Öffnen am selben Tag nach +- [ ] Kontextantwort wird gespeichert und im Verlauf angezeigt +- [ ] **Heute nicht mehr fragen** unterdrückt nur diesen Snapshot +- [ ] Offene Kontextfragen eines Vortags erscheinen nicht am Folgetag + +## Erinnerungen + +- [ ] Auswahl zwischen zwei und drei Erinnerungen funktioniert +- [ ] Alle konfigurierten Uhrzeiten erzeugen lokale Requests +- [ ] Ändern einer Uhrzeit ersetzt alte Requests +- [ ] Deaktivieren entfernt alle drei stabilen Requests +- [ ] Banner erscheint im Vordergrund +- [ ] Tipp auf Banner im Hintergrund öffnet den Check-in +- [ ] Tipp auf Banner nach Kaltstart öffnet den Check-in +- [ ] Notification-Öffnung löst nicht sofort eine alte Kontextfrage darüber aus +- [ ] Zeitzonen- und Sommerzeitwechsel wurden plausibilisiert + +## Persistenz und Verlauf + +- [ ] Gespeicherte Einträge bleiben nach App-Neustart erhalten +- [ ] Gespeicherte Check-ins bleiben nach App-Neustart erhalten +- [ ] 7-Tage-Filter enthält heute und die sechs vorherigen Kalendertage +- [ ] 30-Tage-Filter enthält heute und die 29 vorherigen Kalendertage +- [ ] Gesamtzeitraum beginnt beim ältesten Datensatz +- [ ] Eigener Zeitraum funktioniert auch bei vertauschten Von-/Bis-Daten +- [ ] Durchschnitt stimmt rechnerisch mit den sichtbaren Punkten überein +- [ ] Diagramm zeigt Werte 1 bis 5 korrekt +- [ ] Eintragsdetail zeigt alle bedingten Felder korrekt + +## Export + +- [ ] Export ist ohne Daten deaktiviert +- [ ] XLSX-Dateiname enthält den gewählten Datumsbereich +- [ ] XLSX öffnet ohne Reparaturmeldung in Apple Numbers +- [ ] XLSX öffnet ohne Reparaturmeldung in Microsoft Excel +- [ ] Arbeitsblatt **Übersicht** enthält Anzahl und korrekten Durchschnitt +- [ ] Arbeitsblatt **Momentaufnahmen** enthält Kontext und Quelle +- [ ] Arbeitsblatt **Tagebuch** enthält die Einträge des gewählten Zeitraums +- [ ] Umlaute, Emoji, Zeilenumbrüche, `&`, `<`, `>` und Anführungszeichen bleiben erhalten +- [ ] CSV besitzt UTF-8-BOM und Semikolon-Trennung +- [ ] CSV escaped Semikolon, Anführungszeichen und Zeilenumbrüche korrekt +- [ ] Abbrechen des Share-Sheets verändert keine gespeicherten Daten + +## Systemintegration + +- [ ] Kurzbefehl **Eintrag aufnehmen** erscheint nach Installation +- [ ] Kurzbefehl öffnet die App im Aufnahmefluss +- [ ] Action Button kann dem Kurzbefehl zugewiesen werden +- [ ] Action Button startet die Aufnahme zuverlässig aus gesperrtem/aktivem Zustand + +## Oberfläche und Barrierefreiheit + +- [ ] Light Mode und Dark Mode sind lesbar +- [ ] Größte Dynamic-Type-Größen verdecken keine primären Aktionen +- [ ] VoiceOver liest die fünf Bewertungen eindeutig vor +- [ ] Aufnahme-/Stop-Aktion besitzt ein eindeutiges Accessibility Label +- [ ] Farbige Informationen sind zusätzlich durch Text oder Symbol erkennbar +- [ ] Bedienung funktioniert auf der kleinsten unterstützten iPhone-Größe +- [ ] Deutsche Texte sind frei von abgeschnittenen oder überlappenden Elementen + +## Datenschutz und Release Gate + +- [ ] Netzwerkverkehr des Release Builds zeigt keinen eigenen Datenupload +- [ ] Keine personenbezogenen Testdaten liegen in Git oder Build-Artefakten +- [ ] `PrivacyInfo.xcprivacy` ist im erzeugten App-Bundle enthalten +- [ ] Xcode Privacy Report enthält keine unerklärten Required-Reason-APIs +- [ ] App-Store-Datenschutzangaben entsprechen dem finalen Build +- [ ] Öffentliche Datenschutz- und Support-URLs sind erreichbar +- [ ] Medizinischer Hinweis ist in App und Metadaten vorhanden +- [ ] TestFlight-Testhinweise nennen Geräte- und iOS-Voraussetzungen +- [ ] Bekannte Blocker sind dokumentiert und akzeptiert diff --git a/docs/TESTFLIGHT.md b/docs/TESTFLIGHT.md new file mode 100644 index 0000000..24f67c3 --- /dev/null +++ b/docs/TESTFLIGHT.md @@ -0,0 +1,133 @@ +# TestFlight- und Release-Ablauf + +## Voraussetzungen + +- Mac mit Xcode 26 oder neuer +- Apple Developer Program-Mitgliedschaft +- Berechtigung als Account Holder, Admin oder App Manager in App Store Connect +- Apple-Intelligence-kompatibles Test-iPhone mit iOS 26 oder neuer +- öffentlich erreichbare Support- und Datenschutz-URLs für eine spätere + App-Store-Veröffentlichung + +## 1. Bundle-ID und Signing vorbereiten + +1. `FeelAloud.xcodeproj` öffnen. +2. Target **FeelAloud** auswählen. +3. Unter **Signing & Capabilities** das richtige Team wählen. +4. Die voreingestellte Bundle-ID lautet `de.feelaloud`. +5. Falls sie im gewählten Team nicht verfügbar ist, eine dauerhaft eindeutige + Bundle-ID festlegen. Nach dem ersten Upload sollte sie nicht mehr geändert + werden. +6. **Automatically manage signing** aktiviert lassen, sofern kein eigener + manueller Signing-Prozess verwendet wird. + +Zertifikate, `.p12`-Dateien, Provisioning Profiles und private Schlüssel gehören +nicht ins Git-Repository. + +## 2. App in App Store Connect anlegen + +1. In App Store Connect **Apps → + → New App** wählen. +2. Plattform **iOS** auswählen. +3. Primärsprache festlegen. +4. Als Arbeitsname **FeelAloud** verwenden. +5. Die zuvor registrierte Bundle-ID auswählen. +6. Eine interne SKU vergeben, beispielsweise `feelaloud-ios-001`. + +Mögliche Metadaten für die spätere Produktseite: + +| Feld | Entwurf | +| --- | --- | +| Name | `FeelAloud – Mood & Voice Diary` | +| Untertitel | `Private check-ins and insights` | +| Kategorie | Health & Fitness | +| Altersfreigabe | Im App-Store-Fragebogen bestimmen | + +Die Namensverfügbarkeit wird erst in App Store Connect verbindlich sichtbar. + +## 3. Release-Konfiguration prüfen + +Vor jedem Upload: + +- `MARKETING_VERSION` kontrollieren, aktuell `1.0`; +- `CURRENT_PROJECT_VERSION` erhöhen, aktuell `1`; +- App-Icon und Display Name prüfen; +- Mikrofon- und Speech-Nutzungstexte kontrollieren; +- Privacy Manifest und App Privacy Report prüfen; +- die vollständige Checkliste in `QA-CHECKLIST.md` auf einem echten Gerät + durchführen; +- sicherstellen, dass keine Secrets oder personenbezogenen Testexporte im + Repository liegen. + +Jeder Upload zu App Store Connect benötigt eine neue Build-Nummer. + +## 4. Archiv erstellen und hochladen + +1. In Xcode als Ziel **Any iOS Device (arm64)** oder ein angeschlossenes iPhone + auswählen. +2. **Product → Archive** wählen. +3. Im Organizer **Distribute App** öffnen. +4. **App Store Connect → Upload** auswählen. +5. Automatische Signing- und Symbol-Optionen prüfen. +6. Validierung abschließen und Build hochladen. +7. Die Verarbeitung in App Store Connect abwarten. + +Alternativ kann der Upload über Xcode Cloud automatisiert werden, sobald ein +geeigneter macOS-CI-Prozess eingerichtet ist. + +## 5. Interne Tests + +1. In App Store Connect die App öffnen. +2. Tab **TestFlight** auswählen. +3. Testinformationen und Kontakt-E-Mail hinterlegen. +4. Eine interne Testergruppe anlegen. +5. Den verarbeiteten Build der Gruppe zuweisen. +6. Testende installieren die App über TestFlight. + +Interne Tests sind der schnellste Weg für Teammitglieder. Für Personen außerhalb +des App-Store-Connect-Teams ist ein externer Test erforderlich. + +## 6. Externe Tests + +1. Externe Gruppe anlegen und Build hinzufügen. +2. Beta-Beschreibung, Testschwerpunkte und Kontaktinformationen ausfüllen. +3. Build zur Beta App Review einreichen. +4. Nach Freigabe per E-Mail oder öffentlichem TestFlight-Link einladen. + +Empfohlener Testhinweis: + +> FeelAloud benötigt ein Apple-Intelligence-kompatibles iPhone mit iOS 26. +> Bitte prüfe Spracheingabe, lokale Auswertung, tägliche Check-ins, +> Erinnerungen und Excel-Export. Die App ist kein Medizinprodukt. + +## 7. Empfohlene App-Review-Notizen + +- Die App besitzt kein Login und kein Backend. +- Mikrofon und Speech Recognition werden nur für einen aktiv gestarteten + Spracheintrag verwendet. +- Die Spracherkennung wird auf dem Gerät erzwungen. +- Foundation Models strukturieren Text lokal; alle Werte können vor dem + Speichern korrigiert werden. +- Erinnerungen sind lokale Notifications. +- Ein manueller Eingabepfad ist vorhanden, falls lokale Modelle nicht bereit + sind. +- Testgerät muss Apple Intelligence und die deutsche Spracherkennung + unterstützen. + +## 8. Veröffentlichungsvoraussetzungen außerhalb TestFlight + +Vor einem öffentlichen App-Store-Release zusätzlich erledigen: + +- öffentliche Datenschutz- und Support-Seite bereitstellen; +- App-Store-Datenschutzfragebogen mit dem finalen Build abgleichen; +- Marken- und Namensprüfung abschließen; +- Screenshots für alle verlangten iPhone-Größen erstellen; +- Beschreibung, Keywords, Copyright und Review-Kontakt ergänzen; +- Datenlöschung innerhalb der App prüfen beziehungsweise implementieren; +- medizinische Aussagen und Krisenhinweise rechtlich prüfen; +- Release Candidate auf mehreren echten Geräten testen. + +## Offizielle Referenzen + +- [Apple: TestFlight](https://developer.apple.com/testflight/) +- [Apple: Foundation Models](https://developer.apple.com/documentation/foundationmodels) +- [Apple: Required Reason APIs](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api)