Initial FeelAloud project
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
22
AppIcon.svg
Normal 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
41
CHANGELOG.md
Normal 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
|
||||||
609
FeelAloud.xcodeproj/project.pbxproj
Normal file
609
FeelAloud.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
FeelAloud.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
FeelAloud.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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
114
FeelAloud/AppLockView.swift
Normal 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
392
FeelAloud/AppTheme.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
FeelAloud/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
21
FeelAloud/Assets.xcassets/AccentColor.colorset/Contents.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BIN
FeelAloud/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
Normal file
BIN
FeelAloud/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
15
FeelAloud/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
15
FeelAloud/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
FeelAloud/Assets.xcassets/Contents.json
Normal file
7
FeelAloud/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
FeelAloud/CSVExporter.swift
Normal file
54
FeelAloud/CSVExporter.swift
Normal 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: "\"\""))\""
|
||||||
|
}
|
||||||
|
}
|
||||||
296
FeelAloud/EntryFlowView.swift
Normal file
296
FeelAloud/EntryFlowView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
24
FeelAloud/FeelAloud.entitlements
Normal file
24
FeelAloud/FeelAloud.entitlements
Normal 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>
|
||||||
101
FeelAloud/FeelAloudApp.swift
Normal file
101
FeelAloud/FeelAloudApp.swift
Normal 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
1206
FeelAloud/HistoryView.swift
Normal file
File diff suppressed because it is too large
Load Diff
221
FeelAloud/HomeView.swift
Normal file
221
FeelAloud/HomeView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
92
FeelAloud/MoodAnalysisService.swift
Normal file
92
FeelAloud/MoodAnalysisService.swift
Normal 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
195
FeelAloud/MoodEntry.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
144
FeelAloud/MoodSnapshot.swift
Normal file
144
FeelAloud/MoodSnapshot.swift
Normal 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
|
||||||
|
}
|
||||||
23
FeelAloud/PrivacyInfo.xcprivacy
Normal file
23
FeelAloud/PrivacyInfo.xcprivacy
Normal 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
609
FeelAloud/RootView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
737
FeelAloud/SettingsView.swift
Normal file
737
FeelAloud/SettingsView.swift
Normal 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 }
|
||||||
|
}
|
||||||
15
FeelAloud/ShareSheet.swift
Normal file
15
FeelAloud/ShareSheet.swift
Normal 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
|
||||||
|
) { }
|
||||||
|
}
|
||||||
116
FeelAloud/SnapshotNotificationScheduler.swift
Normal file
116
FeelAloud/SnapshotNotificationScheduler.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
155
FeelAloud/SnapshotView.swift
Normal file
155
FeelAloud/SnapshotView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
212
FeelAloud/SpeechRecognizer.swift
Normal file
212
FeelAloud/SpeechRecognizer.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
FeelAloud/StartDiaryEntryIntent.swift
Normal file
31
FeelAloud/StartDiaryEntryIntent.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
157
FeelAloud/TherapySupport.swift
Normal file
157
FeelAloud/TherapySupport.swift
Normal 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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
1215
FeelAloud/TherapySupportView.swift
Normal file
1215
FeelAloud/TherapySupportView.swift
Normal file
File diff suppressed because it is too large
Load Diff
845
FeelAloud/XLSXExporter.swift
Normal file
845
FeelAloud/XLSXExporter.swift
Normal 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 (1–5)"), .decimal(average)])
|
||||||
|
rows.append([.text("Durchschnitt als Bewertung"), .text(SnapshotRating.label(for: average))])
|
||||||
|
} else {
|
||||||
|
rows.append([.text("Durchschnitt (1–5)"), .text("Keine Daten")])
|
||||||
|
rows.append([.text("Durchschnitt als Bewertung"), .text("Keine Daten")])
|
||||||
|
}
|
||||||
|
|
||||||
|
for rating in SnapshotRating.allCases {
|
||||||
|
let snapshotCount = snapshots.filter { $0.rating.rawValue == rating.rawValue }.count
|
||||||
|
let entryCount = entries.filter { $0.mood.rawValue == rating.rawValue }.count
|
||||||
|
let count = snapshotCount + entryCount
|
||||||
|
rows.append([.text(rating.rawValue), .number(Double(count))])
|
||||||
|
}
|
||||||
|
rows.append([.text("Ausführliche Tagebucheinträge"), .number(Double(entries.count))])
|
||||||
|
|
||||||
|
return worksheet(
|
||||||
|
rows: rows,
|
||||||
|
columnWidths: [30, 24],
|
||||||
|
headerRow: true,
|
||||||
|
autoFilter: false,
|
||||||
|
drawingRelationshipID: hasChart ? "rId1" : nil,
|
||||||
|
freezeRow: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactReportSheet: String {
|
||||||
|
var rows = summaryRows
|
||||||
|
rows.append([.text(""), .text("")])
|
||||||
|
rows.append([.text("Stimmungsverteilung"), .text("Anzahl")])
|
||||||
|
rows += ratingDistributionRows
|
||||||
|
rows.append([.text(""), .text("")])
|
||||||
|
rows.append([
|
||||||
|
.text("Momentaufnahmen"),
|
||||||
|
.text("Bewertung"),
|
||||||
|
.text("Wert"),
|
||||||
|
.text("Besonderheit"),
|
||||||
|
.text("Quelle")
|
||||||
|
])
|
||||||
|
rows += snapshots.map { snapshot in
|
||||||
|
[
|
||||||
|
.text("\(dateFormatter.string(from: snapshot.createdAt)) \(timeFormatter.string(from: snapshot.createdAt))"),
|
||||||
|
.text(snapshot.rating.rawValue),
|
||||||
|
.number(snapshot.rating.score),
|
||||||
|
.text(snapshot.followUpNote),
|
||||||
|
.text(snapshot.source == .notification ? "Erinnerung" : "Manuell")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
rows.append([.text(""), .text("")])
|
||||||
|
rows.append([
|
||||||
|
.text("Tagebuch"),
|
||||||
|
.text("Stimmung"),
|
||||||
|
.text("Wert"),
|
||||||
|
.text("Sozialkontakt"),
|
||||||
|
.text("Musik"),
|
||||||
|
.text("Stress"),
|
||||||
|
.text("Text kurz")
|
||||||
|
])
|
||||||
|
rows += entries.map { compactDiaryRow(for: $0) }
|
||||||
|
rows.append([.text(""), .text("")])
|
||||||
|
rows.append([
|
||||||
|
.text("Tagebuch Details"),
|
||||||
|
.text("Song"),
|
||||||
|
.text("Ohrwurm"),
|
||||||
|
.text("Leidensdruck"),
|
||||||
|
.text("Gegenmaßnahme"),
|
||||||
|
.text("Gesprochener Text")
|
||||||
|
])
|
||||||
|
rows += entries.map { diaryDetailRow(for: $0) }
|
||||||
|
|
||||||
|
return worksheet(
|
||||||
|
rows: rows,
|
||||||
|
columnWidths: [20, 16, 10, 18, 16, 42, 14],
|
||||||
|
headerRow: true,
|
||||||
|
autoFilter: false,
|
||||||
|
drawingRelationshipID: hasChart ? "rId1" : nil,
|
||||||
|
freezeRow: 15
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryRows: [[CellValue]] {
|
||||||
|
var rows: [[CellValue]] = [
|
||||||
|
[.text("Stimmungsreport"), .text("Wert")],
|
||||||
|
[.text("Zeitraum von"), .text(dateFormatter.string(from: startDate))],
|
||||||
|
[.text("Zeitraum bis"), .text(dateFormatter.string(from: endDate))],
|
||||||
|
[.text("Momentaufnahmen"), .number(Double(snapshots.count))]
|
||||||
|
]
|
||||||
|
|
||||||
|
if let average {
|
||||||
|
rows.append([.text("Durchschnitt (1-5)"), .decimal(average)])
|
||||||
|
rows.append([.text("Durchschnitt als Bewertung"), .text(SnapshotRating.label(for: average))])
|
||||||
|
} else {
|
||||||
|
rows.append([.text("Durchschnitt (1-5)"), .text("Keine Daten")])
|
||||||
|
rows.append([.text("Durchschnitt als Bewertung"), .text("Keine Daten")])
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.append([.text("Ausführliche Tagebucheinträge"), .number(Double(entries.count))])
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private var ratingDistributionRows: [[CellValue]] {
|
||||||
|
SnapshotRating.allCases.map { rating in
|
||||||
|
let snapshotCount = snapshots.filter { $0.rating.rawValue == rating.rawValue }.count
|
||||||
|
let entryCount = entries.filter { $0.mood.rawValue == rating.rawValue }.count
|
||||||
|
return [.text(rating.rawValue), .number(Double(snapshotCount + entryCount))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var snapshotsSheet: String {
|
||||||
|
var rows: [[CellValue]] = [[
|
||||||
|
.text("Datum"),
|
||||||
|
.text("Uhrzeit"),
|
||||||
|
.text("Bewertung"),
|
||||||
|
.text("Wert"),
|
||||||
|
.text("Besonderheit"),
|
||||||
|
.text("Quelle")
|
||||||
|
]]
|
||||||
|
|
||||||
|
rows += snapshots.map { snapshot in
|
||||||
|
[
|
||||||
|
.text(dateFormatter.string(from: snapshot.createdAt)),
|
||||||
|
.text(timeFormatter.string(from: snapshot.createdAt)),
|
||||||
|
.text(snapshot.rating.rawValue),
|
||||||
|
.number(snapshot.rating.score),
|
||||||
|
.text(snapshot.followUpNote),
|
||||||
|
.text(snapshot.source == .notification ? "Erinnerung" : "Manuell")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return worksheet(
|
||||||
|
rows: rows,
|
||||||
|
columnWidths: [14, 11, 18, 10, 48, 14],
|
||||||
|
headerRow: true,
|
||||||
|
autoFilter: true,
|
||||||
|
drawingRelationshipID: nil,
|
||||||
|
freezeRow: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var diarySheet: String {
|
||||||
|
var rows: [[CellValue]] = [[
|
||||||
|
.text("Datum"),
|
||||||
|
.text("Uhrzeit"),
|
||||||
|
.text("Stimmung"),
|
||||||
|
.text("Wert"),
|
||||||
|
.text("MusikImKopf"),
|
||||||
|
.text("SongName"),
|
||||||
|
.text("OhrwurmStärke"),
|
||||||
|
.text("Sozialkontakt"),
|
||||||
|
.text("Leidensdruck"),
|
||||||
|
.text("Gegenmaßnahmen"),
|
||||||
|
.text("GegenmaßnahmeAktivität"),
|
||||||
|
.text("Stress"),
|
||||||
|
.text("GesprochenerText")
|
||||||
|
]]
|
||||||
|
|
||||||
|
rows += entries.map { entry in
|
||||||
|
diaryRow(for: entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return worksheet(
|
||||||
|
rows: rows,
|
||||||
|
columnWidths: [14, 11, 18, 10, 16, 28, 18, 17, 17, 19, 32, 12, 55],
|
||||||
|
headerRow: true,
|
||||||
|
autoFilter: true,
|
||||||
|
drawingRelationshipID: nil,
|
||||||
|
freezeRow: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var diaryRows: [[CellValue]] {
|
||||||
|
entries.map { diaryRow(for: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func diaryRow(for entry: MoodEntry) -> [CellValue] {
|
||||||
|
[
|
||||||
|
.text(dateFormatter.string(from: entry.createdAt)),
|
||||||
|
.text(timeFormatter.string(from: entry.createdAt)),
|
||||||
|
.text(entry.mood.rawValue),
|
||||||
|
.number(entry.mood.score),
|
||||||
|
.text(entry.musicInHead ? "Ja" : "Nein"),
|
||||||
|
.text(entry.musicInHead ? entry.songName : ""),
|
||||||
|
.text(entry.musicInHead ? entry.earwormStrengthRaw : ""),
|
||||||
|
.text(entry.socialContact ? "Ja" : "Nein"),
|
||||||
|
.text(entry.musicInHead ? entry.distressRaw : ""),
|
||||||
|
.text(entry.musicInHead ? (entry.countermeasures ? "Ja" : "Nein") : ""),
|
||||||
|
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
||||||
|
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
||||||
|
.text(entry.transcript)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compactDiaryRow(for entry: MoodEntry) -> [CellValue] {
|
||||||
|
[
|
||||||
|
.text("\(dateFormatter.string(from: entry.createdAt)) \(timeFormatter.string(from: entry.createdAt))"),
|
||||||
|
.text(entry.mood.rawValue),
|
||||||
|
.number(entry.mood.score),
|
||||||
|
.text(entry.socialContact ? "Ja" : "Nein"),
|
||||||
|
.text(entry.musicInHead ? "Ja" : "Nein"),
|
||||||
|
.text(entry.musicInHead ? "" : (entry.stress ? "Ja" : "Nein")),
|
||||||
|
.text(shortened(entry.transcript, limit: 90))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func diaryDetailRow(for entry: MoodEntry) -> [CellValue] {
|
||||||
|
[
|
||||||
|
.text("\(dateFormatter.string(from: entry.createdAt)) \(timeFormatter.string(from: entry.createdAt))"),
|
||||||
|
.text(entry.musicInHead ? entry.songName : ""),
|
||||||
|
.text(entry.musicInHead ? entry.earwormStrengthRaw : ""),
|
||||||
|
.text(entry.musicInHead ? entry.distressRaw : ""),
|
||||||
|
.text(entry.musicInHead && entry.countermeasures ? entry.countermeasureActivity : ""),
|
||||||
|
.text(entry.transcript)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryDrawing: String {
|
||||||
|
"""
|
||||||
|
<?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: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortened(_ value: String, limit: Int) -> String {
|
||||||
|
guard value.count > limit else { return value }
|
||||||
|
let endIndex = value.index(value.startIndex, offsetBy: limit)
|
||||||
|
return "\(value[..<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
92
Moment/AppIntent.swift
Normal 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
|
||||||
|
}
|
||||||
11
Moment/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
Moment/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Moment/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
35
Moment/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Moment/Assets.xcassets/Contents.json
Normal file
6
Moment/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Moment/Info.plist
Normal file
11
Moment/Info.plist
Normal 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
134
Moment/Moment.swift
Normal 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
16
Moment/MomentBundle.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Moment/MomentControl.swift
Normal file
77
Moment/MomentControl.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
80
Moment/MomentLiveActivity.swift
Normal file
80
Moment/MomentLiveActivity.swift
Normal 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
|
||||||
|
}
|
||||||
10
MomentExtension.entitlements
Normal file
10
MomentExtension.entitlements
Normal 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
151
README.md
Normal 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
152
docs/ARCHITECTURE.md
Normal 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
107
docs/PRIVACY.md
Normal 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
129
docs/QA-CHECKLIST.md
Normal 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
133
docs/TESTFLIGHT.md
Normal 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)
|
||||||
Reference in New Issue
Block a user