Initial FeelAloud project

This commit is contained in:
MrDiderot
2026-07-22 22:18:33 +02:00
commit 10fca0756d
48 changed files with 8942 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -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

22
AppIcon.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="background" x1="120" y1="80" x2="920" y2="960" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#A855F7"/>
<stop offset="0.52" stop-color="#7C3AED"/>
<stop offset="1" stop-color="#4338CA"/>
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#25105C" flood-opacity="0.35"/>
</filter>
</defs>
<rect width="1024" height="1024" rx="224" fill="url(#background)"/>
<circle cx="512" cy="500" r="302" fill="#FFFFFF" fill-opacity="0.13"/>
<path d="M512 771C475 737 330 626 275 532C218 435 265 314 378 292C444 279 488 313 512 353C536 313 580 279 646 292C759 314 806 435 749 532C694 626 549 737 512 771Z" fill="#FFFFFF" filter="url(#shadow)"/>
<g fill="none" stroke="#7C3AED" stroke-width="34" stroke-linecap="round">
<path d="M347 516H404"/>
<path d="M434 450V582"/>
<path d="M512 400V632"/>
<path d="M590 460V572"/>
<path d="M620 516H677"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

41
CHANGELOG.md Normal file
View File

@@ -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

View File

@@ -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 = "<group>"; };
B10000000000000000000002 /* MoodEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodEntry.swift; sourceTree = "<group>"; };
B10000000000000000000003 /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = "<group>"; };
B10000000000000000000004 /* MoodAnalysisService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodAnalysisService.swift; sourceTree = "<group>"; };
B10000000000000000000005 /* CSVExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExporter.swift; sourceTree = "<group>"; };
B10000000000000000000006 /* StartDiaryEntryIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartDiaryEntryIntent.swift; sourceTree = "<group>"; };
B10000000000000000000007 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
B10000000000000000000008 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B10000000000000000000009 /* EntryFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryFlowView.swift; sourceTree = "<group>"; };
B1000000000000000000000A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
B1000000000000000000000B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B1000000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
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 = "<group>"; };
B1000000000000000000000F /* SnapshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotView.swift; sourceTree = "<group>"; };
B10000000000000000000010 /* SnapshotNotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotNotificationScheduler.swift; sourceTree = "<group>"; };
B10000000000000000000011 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
B10000000000000000000012 /* XLSXExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XLSXExporter.swift; sourceTree = "<group>"; };
B10000000000000000000013 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
E296A4323010C0CA003F93AA /* TherapySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySupport.swift; sourceTree = "<group>"; };
E296A4343010C110003F93AA /* TherapySupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySupportView.swift; sourceTree = "<group>"; };
E296A4363010C14D003F93AA /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = "<group>"; };
E296A4383010FDD6003F93AA /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>"; };
/* 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 = "<group>";
};
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 = "<group>";
};
C10000000000000000000003 /* Products */ = {
isa = PBXGroup;
children = (
B1000000000000000000000D /* FeelAloud.app */,
E296A43E30110491003F93AA /* MomentExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
E296A43F30110491003F93AA /* Frameworks */ = {
isa = PBXGroup;
children = (
E296A44030110491003F93AA /* WidgetKit.framework */,
E296A44230110492003F93AA /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E10000000000000000000001"
BuildableName = "FeelAloud.app"
BlueprintName = "FeelAloud"
ReferencedContainer = "container:FeelAloud.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E10000000000000000000001"
BuildableName = "FeelAloud.app"
BlueprintName = "FeelAloud"
ReferencedContainer = "container:FeelAloud.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E10000000000000000000001"
BuildableName = "FeelAloud.app"
BlueprintName = "FeelAloud"
ReferencedContainer = "container:FeelAloud.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

114
FeelAloud/AppLockView.swift Normal file
View File

@@ -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"
}
}

392
FeelAloud/AppTheme.swift Normal file
View File

@@ -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<Void, Never>?
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<StoreKit.Transaction>) 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)
}
}
}

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,7 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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: "\"\""))\""
}
}

View File

@@ -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<Bool> {
Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
}
private func customCategoryBinding(for category: DiaryCategory) -> Binding<Bool> {
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()
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.feelaloud</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.de.feelaloud</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.de.feelaloud</string>
</array>
</dict>
</plist>

View File

@@ -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
}
}

1206
FeelAloud/HistoryView.swift Normal file

File diff suppressed because it is too large Load Diff

221
FeelAloud/HomeView.swift Normal file
View File

@@ -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
}
}

View File

@@ -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
)
}
}

195
FeelAloud/MoodEntry.swift Normal file
View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>

609
FeelAloud/RootView.swift Normal file
View File

@@ -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)
}
}

View File

@@ -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..<safeReminderCount, id: \.self) { index in
DatePicker(
"Erinnerung \(index + 1)",
selection: timeBinding(for: index),
displayedComponents: .hourAndMinute
)
}
}
LabeledContent("Mitteilungen", value: notificationStatus)
if let notificationMessage {
Text(notificationMessage)
.font(.footnote)
.foregroundStyle(.orange)
}
Text("Beim Antippen öffnet sich direkt die Auswahl mit fünf Gesichtern. Die Erinnerungen werden lokal vom iPhone geplant.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Momentaufnahmen")
.navigationBarTitleDisplayMode(.inline)
}
private var categoriesSettings: some View {
List {
Section("Kategorien") {
LabeledContent("Voreingestellt", value: "Stimmung, Stress, Sozialkontakte")
if customCategories.isEmpty {
Text("Eigene Kategorien erscheinen bei neuen Einträgen zusätzlich als Schalter.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(customCategories) { category in
Text(category.name)
.swipeActions {
Button(role: .destructive) {
deleteCategory(category)
} label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
HStack {
TextField("Neue Kategorie", text: $newCategoryName)
.submitLabel(.done)
Button("Hinzufügen") {
addCategory()
}
.disabled(newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.navigationTitle("Kategorien")
.navigationBarTitleDisplayMode(.inline)
}
private var privacySettings: some View {
List {
Section("Datenschutz") {
Toggle("App mit Face ID sperren", isOn: appLockToggle)
.disabled(!AppLockAvailability.canAuthenticate || AppLockAvailability.requiresMissingFaceIDDescription)
LabeledContent("Sperre", value: AppLockAvailability.settingsStatus)
if let appLockMessage {
Text(appLockMessage)
.font(.footnote)
.foregroundStyle(.orange)
}
Text("Bei aktivierter Sperre fragt FeelAloud beim Öffnen und nach dem Verlassen der App nach Face ID, Touch ID oder Gerätecode.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Lokale KI") {
LabeledContent("Apple-Modell") {
Label(
aiStatus,
systemImage: aiAvailable
? "checkmark.circle.fill"
: "exclamationmark.triangle.fill"
)
.foregroundStyle(aiAvailable ? .green : .orange)
}
Text("Sprache und Tagebuchtext werden auf diesem iPhone verarbeitet und nicht an einen eigenen Server gesendet.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Datenschutz")
.navigationBarTitleDisplayMode(.inline)
}
private var syncSettings: some View {
List {
Section("iCloud Sync") {
LabeledContent("Account", value: iCloudStatus)
Text("Momentaufnahmen und Tagebucheinträge werden über die private iCloud-Datenbank dieses Apple-Accounts synchronisiert, sobald iCloud Drive und CloudKit für die App aktiviert sind.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("iCloud Sync")
.navigationBarTitleDisplayMode(.inline)
}
private var appearanceSettings: some View {
List {
Section("Darstellung") {
Picker("Farbprofil", selection: $colorProfileRaw) {
ForEach(AppColorProfile.allCases) { profile in
HStack {
Circle()
.fill(profile.accent)
.frame(width: 14, height: 14)
Text(profile.name)
}
.tag(profile.rawValue)
}
}
Toggle("Helle Darstellung erzwingen", isOn: $forceLightAppearance)
Text("Die Profile nutzen ruhige Pastellfarben und ändern die Akzentfarbe der App.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Darstellung")
.navigationBarTitleDisplayMode(.inline)
}
private var exportSettings: some View {
List {
Section("Daten und Export") {
if swiftDataUsedFallback {
Label("Temporärer Datenspeicher aktiv", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Der dauerhafte Datenspeicher konnte beim Start nicht geöffnet werden. Neue Daten werden in dieser Sitzung nur temporär gehalten.")
.font(.footnote)
.foregroundStyle(.orange)
}
Label("Diagramm und Excel-Report im Tab Verlauf", systemImage: "chart.xyaxis.line")
Button {
exportCSV()
} label: {
Label("Altes Numbers-CSV exportieren", systemImage: "square.and.arrow.up")
}
.disabled(entries.isEmpty)
Text("Der Excel-Report enthält den gewählten Zeitraum, den Durchschnitt, alle Momentaufnahmen und die ausführlichen Tagebucheinträge.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Daten und Export")
.navigationBarTitleDisplayMode(.inline)
}
private var premiumSettings: some View {
List {
Section("Unterstützen") {
Toggle("Premium features", isOn: premiumFeaturesToggle)
Label(
premiumFeaturesEnabled ? "Bonusfunktionen aktiv" : "Bonusfunktionen gesperrt",
systemImage: premiumFeaturesEnabled ? "checkmark.seal.fill" : "heart"
)
.foregroundStyle(premiumFeaturesEnabled ? .green : .secondary)
Text("Testphase: Der Schalter simuliert den späteren Unterstützen-Kauf. Projektmappen und der Therapeuten-Excel-Export sind als Bonusfunktionen vorgesehen.")
.font(.footnote)
.foregroundStyle(.secondary)
Button {
Task {
await premiumStore.restorePurchases()
premiumFeaturesEnabled = premiumStore.premiumFeaturesEnabled
}
} label: {
Label("Käufe wiederherstellen", systemImage: "arrow.clockwise")
}
}
}
.navigationTitle("Unterstützen")
.navigationBarTitleDisplayMode(.inline)
}
private var shortcutsSettings: some View {
List {
Section("Action Button") {
ShortcutsLink()
.shortcutsLinkStyle(.automaticOutline)
Label("Kurzbefehle → FeelAloud → Eintrag aufnehmen", systemImage: "button.programmable")
Text("Öffne FeelAloud einmal über den Button oben in der Kurzbefehle-App. Danach kann dieser Kurzbefehl in Einstellungen → Aktionstaste → Kurzbefehl ausgewählt werden.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Kurzbefehle")
.navigationBarTitleDisplayMode(.inline)
}
private var appInfoSettings: some View {
List {
Section("Hinweis") {
Text("Die App dient der persönlichen Dokumentation und stellt keine medizinische Diagnose oder Behandlung dar.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("App-Info")
.navigationBarTitleDisplayMode(.inline)
}
private func settingsNavigationRow(title: String, subtitle: String, systemImage: String) -> 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<Bool> {
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<Bool> {
Binding(
get: { premiumFeaturesEnabled },
set: { enabled in
premiumStore.setPremiumEnabled(enabled)
premiumFeaturesEnabled = enabled
}
)
}
private var reminderToggle: Binding<Bool> {
Binding(
get: { remindersEnabled },
set: { enabled in
remindersEnabled = enabled
Task {
await configureReminders(requestAuthorization: enabled)
}
}
)
}
private var reminderCountBinding: Binding<Int> {
Binding(
get: { reminderCount },
set: { count in
reminderCount = min(max(count, 2), 3)
Task { await rescheduleIfEnabled() }
}
)
}
private func timeBinding(for index: Int) -> Binding<Date> {
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<Bool> {
Binding(
get: { exportError != nil },
set: { if !$0 { exportError = nil } }
)
}
private var profileMessageBinding: Binding<Bool> {
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 }
}

View File

@@ -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
) { }
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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<String, Error>) {
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
}
}
}

View File

@@ -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"
)
}
}

View File

@@ -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 = ""
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
? """
<Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>
<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>
"""
: ""
let extraSheets = isCompactReport
? ""
: """
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
"""
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
\(extraSheets)
\(chartTypes)
</Types>
"""
}
private var rootRelationships: String {
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>
"""
}
private var summarySheetRelationships: String {
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>
</Relationships>
"""
}
private var summaryDrawingRelationships: String {
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/>
</Relationships>
"""
}
private var appProperties: String {
let sheetCount = isCompactReport ? 1 : 3
let sheetTitles = isCompactReport
? "<vt:lpstr>Report</vt:lpstr>"
: "<vt:lpstr>Übersicht</vt:lpstr><vt:lpstr>Momentaufnahmen</vt:lpstr><vt:lpstr>Tagebuch</vt:lpstr>"
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>FeelAloud</Application>
<DocSecurity>0</DocSecurity>
<ScaleCrop>false</ScaleCrop>
<HeadingPairs><vt:vector size="2" baseType="variant"><vt:variant><vt:lpstr>Arbeitsblätter</vt:lpstr></vt:variant><vt:variant><vt:i4>\(sheetCount)</vt:i4></vt:variant></vt:vector></HeadingPairs>
<TitlesOfParts><vt:vector size="\(sheetCount)" baseType="lpstr">\(sheetTitles)</vt:vector></TitlesOfParts>
</Properties>
"""
}
private var legacyAppProperties: String {
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>FeelAloud</Application>
<DocSecurity>0</DocSecurity>
<ScaleCrop>false</ScaleCrop>
<HeadingPairs><vt:vector size="2" baseType="variant"><vt:variant><vt:lpstr>Arbeitsblätter</vt:lpstr></vt:variant><vt:variant><vt:i4>3</vt:i4></vt:variant></vt:vector></HeadingPairs>
<TitlesOfParts><vt:vector size="3" baseType="lpstr"><vt:lpstr>Übersicht</vt:lpstr><vt:lpstr>Momentaufnahmen</vt:lpstr><vt:lpstr>Tagebuch</vt:lpstr></vt:vector></TitlesOfParts>
</Properties>
"""
}
private var coreProperties: String {
let isoDate = ISO8601DateFormatter().string(from: .now)
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>Stimmungsreport</dc:title>
<dc:creator>FeelAloud</dc:creator>
<dcterms:created xsi:type="dcterms:W3CDTF">\(isoDate)</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">\(isoDate)</dcterms:modified>
</cp:coreProperties>
"""
}
private var workbook: String {
let sheets = isCompactReport
? "<sheet name=\"Report\" sheetId=\"1\" r:id=\"rId1\"/>"
: """
<sheet name="Übersicht" sheetId="1" r:id="rId1"/>
<sheet name="Momentaufnahmen" sheetId="2" r:id="rId2"/>
<sheet name="Tagebuch" sheetId="3" r:id="rId3"/>
"""
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<bookViews><workbookView xWindow="0" yWindow="0" windowWidth="24000" windowHeight="12000"/></bookViews>
<sheets>
\(sheets)
</sheets>
<calcPr calcId="191029"/>
</workbook>
"""
}
private var workbookRelationships: String {
let extraSheets = isCompactReport
? ""
: """
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/>
"""
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
\(extraSheets)
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>
"""
}
private var styles: String {
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2">
<font><sz val="11"/><name val="Aptos"/><family val="2"/></font>
<font><b/><sz val="11"/><color rgb="FFFFFFFF"/><name val="Aptos"/><family val="2"/></font>
</fonts>
<fills count="3">
<fill><patternFill patternType="none"/></fill>
<fill><patternFill patternType="gray125"/></fill>
<fill><patternFill patternType="solid"><fgColor rgb="FF7657E8"/><bgColor indexed="64"/></patternFill></fill>
</fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="3">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf>
<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf>
<xf numFmtId="2" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf>
</cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>
"""
}
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 (15)"), .decimal(average)])
rows.append([.text("Durchschnitt als Bewertung"), .text(SnapshotRating.label(for: average))])
} else {
rows.append([.text("Durchschnitt (15)"), .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 {
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<xdr:twoCellAnchor>
<xdr:from><xdr:col>3</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>
<xdr:to><xdr:col>7</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>13</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
<xdr:graphicFrame macro="">
<xdr:nvGraphicFramePr>
<xdr:cNvPr id="2" name="Stimmungsverlauf"/>
<xdr:cNvGraphicFramePr/>
</xdr:nvGraphicFramePr>
<xdr:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
</xdr:xfrm>
<a:graphic>
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">
<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>
</a:graphicData>
</a:graphic>
</xdr:graphicFrame>
<xdr:clientData/>
</xdr:twoCellAnchor>
</xdr:wsDr>
"""
}
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 """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<c:date1904 val="0"/>
<c:chart>
<c:title>
<c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Stimmungsverteilung</a:t></a:r></a:p></c:rich></c:tx>
<c:layout/>
<c:overlay val="0"/>
</c:title>
<c:plotArea>
<c:layout/>
<c:barChart>
<c:barDir val="col"/>
<c:grouping val="clustered"/>
<c:ser>
<c:idx val="0"/>
<c:order val="0"/>
<c:tx><c:v>Anzahl</c:v></c:tx>
<c:cat><c:strRef><c:f>\(categoryRange)</c:f></c:strRef></c:cat>
<c:val><c:numRef><c:f>\(valueRange)</c:f></c:numRef></c:val>
</c:ser>
<c:axId val="10"/>
<c:axId val="20"/>
</c:barChart>
<c:catAx>
<c:axId val="10"/>
<c:scaling><c:orientation val="minMax"/></c:scaling>
<c:delete val="0"/>
<c:axPos val="b"/>
<c:tickLblPos val="nextTo"/>
<c:crossAx val="20"/>
<c:crosses val="autoZero"/>
<c:auto val="1"/>
<c:lblAlgn val="ctr"/>
<c:lblOffset val="100"/>
</c:catAx>
<c:valAx>
<c:axId val="20"/>
<c:scaling><c:orientation val="minMax"/><c:min val="0"/></c:scaling>
<c:delete val="0"/>
<c:axPos val="l"/>
<c:majorGridlines/>
<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Anzahl</a:t></a:r></a:p></c:rich></c:tx><c:layout/><c:overlay val="0"/></c:title>
<c:numFmt formatCode="0" sourceLinked="0"/>
<c:tickLblPos val="nextTo"/>
<c:crossAx val="10"/>
<c:crosses val="autoZero"/>
</c:valAx>
</c:plotArea>
<c:legend><c:legendPos val="b"/><c:layout/><c:overlay val="0"/></c:legend>
<c:plotVisOnly val="1"/>
</c:chart>
</c:chartSpace>
"""
}
private func chartSeries(
title: String,
index: Int,
categories: String,
values: String
) -> String {
"""
<c:ser>
<c:idx val="\(index)"/>
<c:order val="\(index)"/>
<c:tx><c:v>\(xmlEscaped(title))</c:v></c:tx>
<c:cat><c:strRef><c:f>\(categories)</c:f></c:strRef></c:cat>
<c:val><c:numRef><c:f>\(values)</c:f></c:numRef></c:val>
<c:smooth val="0"/>
</c:ser>
"""
}
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
"<col min=\"\(index + 1)\" max=\"\(index + 1)\" width=\"\(width)\" customWidth=\"1\"/>"
}.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 "<c r=\"\(reference)\" t=\"inlineStr\"\(style)><is><t xml:space=\"preserve\">\(xmlEscaped(text))</t></is></c>"
case .number(let number):
return "<c r=\"\(reference)\"\(style)><v>\(number)</v></c>"
case .decimal(let number):
return "<c r=\"\(reference)\" s=\"2\"><v>\(number)</v></c>"
}
}.joined()
let rowAttributes = containsLongText(row) ? " ht=\"54\" customHeight=\"1\"" : ""
return "<row r=\"\(rowIndex + 1)\"\(rowAttributes)>\(cells)</row>"
}.joined()
let filter = autoFilter && rows.count > 1
? "<autoFilter ref=\"\(dimension)\"/>"
: ""
let drawing = drawingRelationshipID
.map { "<drawing r:id=\"\($0)\"/>" } ?? ""
let pane = freezeRow.map {
"""
<pane ySplit="\($0)" topLeftCell="A\($0 + 1)" activePane="bottomLeft" state="frozen"/>
<selection pane="bottomLeft" activeCell="A\($0 + 1)" sqref="A\($0 + 1)"/>
"""
} ?? "<selection activeCell=\"A1\" sqref=\"A1\"/>"
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<dimension ref="\(dimension)"/>
<sheetViews><sheetView workbookViewId="0">\(pane)</sheetView></sheetViews>
<sheetFormatPr defaultRowHeight="15"/>
<cols>\(columns)</cols>
<sheetData>\(sheetRows)</sheetData>
\(filter)
\(drawing)
</worksheet>
"""
}
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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
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[..<endIndex])..."
}
private func containsLongText(_ row: [CellValue]) -> 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)
}
}

92
Moment/AppIntent.swift Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

11
Moment/Info.plist Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

134
Moment/Moment.swift Normal file
View File

@@ -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<MomentEntry>) -> 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)
}

16
Moment/MomentBundle.swift Normal file
View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.feelaloud</string>
</array>
</dict>
</plist>

151
README.md Normal file
View File

@@ -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.

152
docs/ARCHITECTURE.md Normal file
View File

@@ -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

107
docs/PRIVACY.md Normal file
View File

@@ -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.

129
docs/QA-CHECKLIST.md Normal file
View File

@@ -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

133
docs/TESTFLIGHT.md Normal file
View File

@@ -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)