initial VoiceDiary iOS app setup
SwiftUI + SwiftData + iCloud, Apple Speech transcription (German), audio recording, summarization service protocol (LLM-ready), localization scaffolding (EN/DE/ES/FR), basic tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserdata
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
Packages/
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
514
VoiceDiary.xcodeproj/project.pbxproj
Normal file
514
VoiceDiary.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
247387A3DB126648ABFA452E /* SummarizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE004D539240AD09CEDA1897 /* SummarizationService.swift */; };
|
||||||
|
2A9849FE6DC9BD95CDFA8645 /* AudioRecorderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30F8103F5AEAC26F1412FEC /* AudioRecorderService.swift */; };
|
||||||
|
324C41B93467262653ACE371 /* VoiceMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF2756833D6984B86086F05 /* VoiceMemo.swift */; };
|
||||||
|
355E113D1253B9F311AFFFA4 /* DiaryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC6214C1C44C4F33F7C0C97 /* DiaryEntry.swift */; };
|
||||||
|
3A82D0F1FD29A6F8DA911209 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 19E99B8436E44AEC9EC5DB77 /* Assets.xcassets */; };
|
||||||
|
5BC279CA41E44E646F2DB639 /* VoiceDiaryApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB4DBB0B43B8080D8A0A42D /* VoiceDiaryApp.swift */; };
|
||||||
|
978F1E53817BC842580C9C67 /* RecordingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39F1A1F4DB96563596246DF1 /* RecordingViewModel.swift */; };
|
||||||
|
A791D92368773881E7ECE4F6 /* RecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B15363D4D0CAD8C0DD46D6 /* RecordingView.swift */; };
|
||||||
|
ADCF65A4376ADA04B73DF1B3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 0A2739861476DDD2140B3BA6 /* Localizable.xcstrings */; };
|
||||||
|
AFE8973BC11AC7137979D5C7 /* DiaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4431B77FD9EDB156C12BB19 /* DiaryViewModel.swift */; };
|
||||||
|
BE246B75520F6505EFE3015E /* VoiceDiaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAFA3F05939B94F1CFD372C /* VoiceDiaryTests.swift */; };
|
||||||
|
D5E3C103027212BE7607EF81 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989E1C6DD70521684A5CB77F /* ContentView.swift */; };
|
||||||
|
D737FC41C749185F28943A87 /* DiaryEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CCBA3B214071BEDD37CB6E /* DiaryEntryView.swift */; };
|
||||||
|
EAE61B25765D355723F9EC66 /* TranscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD664C8607AD9DC37A6C8D1 /* TranscriptionService.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
A1BDE0820FAF42BAEF234E5F /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 22863D36D5C5A4F9C2F670EE /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = B9F15BDABC47A8060577BF9F;
|
||||||
|
remoteInfo = VoiceDiary;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
0A2739861476DDD2140B3BA6 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
|
19E99B8436E44AEC9EC5DB77 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
1CA6B754AFDFDAF2C8AF9C01 /* VoiceDiaryTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceDiaryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1EF2756833D6984B86086F05 /* VoiceMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMemo.swift; sourceTree = "<group>"; };
|
||||||
|
2CAFA3F05939B94F1CFD372C /* VoiceDiaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceDiaryTests.swift; sourceTree = "<group>"; };
|
||||||
|
30040C9F616FE5225F189FF9 /* VoiceDiary.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VoiceDiary.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
39F1A1F4DB96563596246DF1 /* RecordingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
5CB4DBB0B43B8080D8A0A42D /* VoiceDiaryApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceDiaryApp.swift; sourceTree = "<group>"; };
|
||||||
|
7DC6214C1C44C4F33F7C0C97 /* DiaryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryEntry.swift; sourceTree = "<group>"; };
|
||||||
|
989E1C6DD70521684A5CB77F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
9CD664C8607AD9DC37A6C8D1 /* TranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionService.swift; sourceTree = "<group>"; };
|
||||||
|
BE004D539240AD09CEDA1897 /* SummarizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummarizationService.swift; sourceTree = "<group>"; };
|
||||||
|
C644C7A7F19E610C2D413C28 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
D4431B77FD9EDB156C12BB19 /* DiaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
D4CCBA3B214071BEDD37CB6E /* DiaryEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryEntryView.swift; sourceTree = "<group>"; };
|
||||||
|
E4B15363D4D0CAD8C0DD46D6 /* RecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingView.swift; sourceTree = "<group>"; };
|
||||||
|
F18ECC39AB60EF3AFF82EB90 /* VoiceDiary.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceDiary.entitlements; sourceTree = "<group>"; };
|
||||||
|
F30F8103F5AEAC26F1412FEC /* AudioRecorderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderService.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
0B9B9383707B694E145D3D8F /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5CB4DBB0B43B8080D8A0A42D /* VoiceDiaryApp.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
142BDBF117BAFAC9CA20527E /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F30F8103F5AEAC26F1412FEC /* AudioRecorderService.swift */,
|
||||||
|
BE004D539240AD09CEDA1897 /* SummarizationService.swift */,
|
||||||
|
9CD664C8607AD9DC37A6C8D1 /* TranscriptionService.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
42EF4FC988F4B2BBF5402B65 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D60D19D2FDD5910699F712E9 /* VoiceDiary */,
|
||||||
|
9AD23776FA65C4F8D3F0B0EF /* VoiceDiaryTests */,
|
||||||
|
DA3C886529BDBC4095712204 /* Products */,
|
||||||
|
);
|
||||||
|
indentWidth = 4;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
tabWidth = 4;
|
||||||
|
usesTabs = 1;
|
||||||
|
};
|
||||||
|
43EA53248AFC60081B0E8EE4 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
19E99B8436E44AEC9EC5DB77 /* Assets.xcassets */,
|
||||||
|
0A2739861476DDD2140B3BA6 /* Localizable.xcstrings */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
88B119656D1AB183CCE625F9 /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D4431B77FD9EDB156C12BB19 /* DiaryViewModel.swift */,
|
||||||
|
39F1A1F4DB96563596246DF1 /* RecordingViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
96673587A895904ADB69A5AA /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7DC6214C1C44C4F33F7C0C97 /* DiaryEntry.swift */,
|
||||||
|
1EF2756833D6984B86086F05 /* VoiceMemo.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9AD23776FA65C4F8D3F0B0EF /* VoiceDiaryTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2CAFA3F05939B94F1CFD372C /* VoiceDiaryTests.swift */,
|
||||||
|
);
|
||||||
|
path = VoiceDiaryTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C12EA9E46E07D0BD5F8F9158 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
989E1C6DD70521684A5CB77F /* ContentView.swift */,
|
||||||
|
D4CCBA3B214071BEDD37CB6E /* DiaryEntryView.swift */,
|
||||||
|
E4B15363D4D0CAD8C0DD46D6 /* RecordingView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D60D19D2FDD5910699F712E9 /* VoiceDiary */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0B9B9383707B694E145D3D8F /* App */,
|
||||||
|
96673587A895904ADB69A5AA /* Models */,
|
||||||
|
43EA53248AFC60081B0E8EE4 /* Resources */,
|
||||||
|
142BDBF117BAFAC9CA20527E /* Services */,
|
||||||
|
88B119656D1AB183CCE625F9 /* ViewModels */,
|
||||||
|
C12EA9E46E07D0BD5F8F9158 /* Views */,
|
||||||
|
C644C7A7F19E610C2D413C28 /* Info.plist */,
|
||||||
|
F18ECC39AB60EF3AFF82EB90 /* VoiceDiary.entitlements */,
|
||||||
|
);
|
||||||
|
path = VoiceDiary;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DA3C886529BDBC4095712204 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
30040C9F616FE5225F189FF9 /* VoiceDiary.app */,
|
||||||
|
1CA6B754AFDFDAF2C8AF9C01 /* VoiceDiaryTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
42D1F68A1E0CFDBC21EE3EF6 /* VoiceDiaryTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = A74E7649EDCC020A2C70B4BB /* Build configuration list for PBXNativeTarget "VoiceDiaryTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
36FE10DFE466EE14F92645FB /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
6B3BFC15D609178E9760BAA6 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = VoiceDiaryTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = VoiceDiaryTests;
|
||||||
|
productReference = 1CA6B754AFDFDAF2C8AF9C01 /* VoiceDiaryTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
B9F15BDABC47A8060577BF9F /* VoiceDiary */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 3EA87AECE5B1013152420141 /* Build configuration list for PBXNativeTarget "VoiceDiary" */;
|
||||||
|
buildPhases = (
|
||||||
|
9C33F875F18F2D6E4E1E1D5B /* Sources */,
|
||||||
|
45A5ED0D52DE258523254F95 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VoiceDiary;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = VoiceDiary;
|
||||||
|
productReference = 30040C9F616FE5225F189FF9 /* VoiceDiary.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
22863D36D5C5A4F9C2F670EE /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
42D1F68A1E0CFDBC21EE3EF6 = {
|
||||||
|
DevelopmentTeam = NG5W75WE8U;
|
||||||
|
};
|
||||||
|
B9F15BDABC47A8060577BF9F = {
|
||||||
|
DevelopmentTeam = NG5W75WE8U;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 5145C1F13AA9C9A2C9F730FD /* Build configuration list for PBXProject "VoiceDiary" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
de,
|
||||||
|
en,
|
||||||
|
es,
|
||||||
|
fr,
|
||||||
|
);
|
||||||
|
mainGroup = 42EF4FC988F4B2BBF5402B65;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
B9F15BDABC47A8060577BF9F /* VoiceDiary */,
|
||||||
|
42D1F68A1E0CFDBC21EE3EF6 /* VoiceDiaryTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
45A5ED0D52DE258523254F95 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
3A82D0F1FD29A6F8DA911209 /* Assets.xcassets in Resources */,
|
||||||
|
ADCF65A4376ADA04B73DF1B3 /* Localizable.xcstrings in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
36FE10DFE466EE14F92645FB /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BE246B75520F6505EFE3015E /* VoiceDiaryTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
9C33F875F18F2D6E4E1E1D5B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
2A9849FE6DC9BD95CDFA8645 /* AudioRecorderService.swift in Sources */,
|
||||||
|
D5E3C103027212BE7607EF81 /* ContentView.swift in Sources */,
|
||||||
|
355E113D1253B9F311AFFFA4 /* DiaryEntry.swift in Sources */,
|
||||||
|
D737FC41C749185F28943A87 /* DiaryEntryView.swift in Sources */,
|
||||||
|
AFE8973BC11AC7137979D5C7 /* DiaryViewModel.swift in Sources */,
|
||||||
|
A791D92368773881E7ECE4F6 /* RecordingView.swift in Sources */,
|
||||||
|
978F1E53817BC842580C9C67 /* RecordingViewModel.swift in Sources */,
|
||||||
|
247387A3DB126648ABFA452E /* SummarizationService.swift in Sources */,
|
||||||
|
EAE61B25765D355723F9EC66 /* TranscriptionService.swift in Sources */,
|
||||||
|
5BC279CA41E44E646F2DB639 /* VoiceDiaryApp.swift in Sources */,
|
||||||
|
324C41B93467262653ACE371 /* VoiceMemo.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
6B3BFC15D609178E9760BAA6 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = B9F15BDABC47A8060577BF9F /* VoiceDiary */;
|
||||||
|
targetProxy = A1BDE0820FAF42BAEF234E5F /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
0F05F1F199B158DC43A14573 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceDiary/VoiceDiary.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
INFOPLIST_FILE = VoiceDiary/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.felixfoertsch.VoiceDiary;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1C57C2237D732FAB6027B517 /* 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++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
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;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = NG5W75WE8U;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
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;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MARKETING_VERSION = 2026.02.15;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
4FE85EF4ED605BF375B04302 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceDiary/VoiceDiary.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
INFOPLIST_FILE = VoiceDiary/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.felixfoertsch.VoiceDiary;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
D369A6D620AB094BADFEFEB0 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.felixfoertsch.VoiceDiaryTests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceDiary.app/VoiceDiary";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
FD097F9D49C3B4DF82D885D0 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.felixfoertsch.VoiceDiaryTests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceDiary.app/VoiceDiary";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
FFEE92F048D8B25AC2A43339 /* 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++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
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;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = NG5W75WE8U;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MARKETING_VERSION = 2026.02.15;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
3EA87AECE5B1013152420141 /* Build configuration list for PBXNativeTarget "VoiceDiary" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
0F05F1F199B158DC43A14573 /* Debug */,
|
||||||
|
4FE85EF4ED605BF375B04302 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
5145C1F13AA9C9A2C9F730FD /* Build configuration list for PBXProject "VoiceDiary" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
FFEE92F048D8B25AC2A43339 /* Debug */,
|
||||||
|
1C57C2237D732FAB6027B517 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
A74E7649EDCC020A2C70B4BB /* Build configuration list for PBXNativeTarget "VoiceDiaryTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D369A6D620AB094BADFEFEB0 /* Debug */,
|
||||||
|
FD097F9D49C3B4DF82D885D0 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 22863D36D5C5A4F9C2F670EE /* Project object */;
|
||||||
|
}
|
||||||
7
VoiceDiary.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
VoiceDiary.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>
|
||||||
12
VoiceDiary/App/VoiceDiaryApp.swift
Normal file
12
VoiceDiary/App/VoiceDiaryApp.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct VoiceDiaryApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: DiaryEntry.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
VoiceDiary/Info.plist
Normal file
10
VoiceDiary/Info.plist
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>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Voice Diary needs microphone access to record your voice memos for diary entries.</string>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>Voice Diary uses on-device speech recognition to transcribe your voice memos.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
41
VoiceDiary/Models/DiaryEntry.swift
Normal file
41
VoiceDiary/Models/DiaryEntry.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class DiaryEntry {
|
||||||
|
var date: Date
|
||||||
|
var summary: String?
|
||||||
|
var isSummaryGenerated: Bool
|
||||||
|
@Relationship(deleteRule: .cascade, inverse: \VoiceMemo.entry)
|
||||||
|
var memos: [VoiceMemo]
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
|
||||||
|
init(date: Date) {
|
||||||
|
self.date = Calendar.current.startOfDay(for: date)
|
||||||
|
self.summary = nil
|
||||||
|
self.isSummaryGenerated = false
|
||||||
|
self.memos = []
|
||||||
|
self.createdAt = .now
|
||||||
|
self.updatedAt = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinedTranscript: String {
|
||||||
|
memos
|
||||||
|
.sorted { $0.recordedAt < $1.recordedAt }
|
||||||
|
.compactMap(\.transcript)
|
||||||
|
.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasMemos: Bool {
|
||||||
|
!memos.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasTranscripts: Bool {
|
||||||
|
memos.contains { $0.transcript != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
date.formatted(.dateTime.day().month(.wide).year())
|
||||||
|
}
|
||||||
|
}
|
||||||
37
VoiceDiary/Models/VoiceMemo.swift
Normal file
37
VoiceDiary/Models/VoiceMemo.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class VoiceMemo {
|
||||||
|
var audioFileName: String
|
||||||
|
var transcript: String?
|
||||||
|
var isTranscribing: Bool
|
||||||
|
var duration: TimeInterval
|
||||||
|
var recordedAt: Date
|
||||||
|
var entry: DiaryEntry?
|
||||||
|
|
||||||
|
init(audioFileName: String, duration: TimeInterval) {
|
||||||
|
self.audioFileName = audioFileName
|
||||||
|
self.transcript = nil
|
||||||
|
self.isTranscribing = false
|
||||||
|
self.duration = duration
|
||||||
|
self.recordedAt = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioURL: URL {
|
||||||
|
Self.audioDirectory.appendingPathComponent(audioFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String {
|
||||||
|
let minutes = Int(duration) / 60
|
||||||
|
let seconds = Int(duration) % 60
|
||||||
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var audioDirectory: URL {
|
||||||
|
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("VoiceMemos", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
VoiceDiary/Resources/Assets.xcassets/Contents.json
Normal file
6
VoiceDiary/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
276
VoiceDiary/Resources/Localizable.xcstrings
Normal file
276
VoiceDiary/Resources/Localizable.xcstrings
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"diary.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Voice Diary" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Sprachtagebuch" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Diario de Voz" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Journal Vocal" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diary.empty.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No Entries Yet" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Noch keine Einträge" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Sin entradas aún" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucune entrée" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diary.empty.description" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Record your first voice memo to start your diary." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nimm dein erstes Sprachmemo auf, um dein Tagebuch zu starten." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Graba tu primera nota de voz para comenzar tu diario." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrez votre premier mémo vocal pour commencer votre journal." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diary.empty.action" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Record Now" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Jetzt aufnehmen" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Grabar ahora" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrer maintenant" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diary.memoCount %lld" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "%lld memos" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "%lld Memos" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "%lld notas" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "%lld mémos" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diary.summarized" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Summarized" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Zusammengefasst" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Resumido" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Résumé" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Record" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Aufnahme" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Grabar" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrer" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording.start" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Start Recording" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Aufnahme starten" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Iniciar grabación" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Démarrer l'enregistrement" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording.stop" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Stop Recording" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Aufnahme stoppen" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Detener grabación" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Arrêter l'enregistrement" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording.duration" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Recording duration" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Aufnahmedauer" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Duración de la grabación" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Durée de l'enregistrement" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"general.done" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Done" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Fertig" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Listo" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Terminé" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry.viewMode" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "View Mode" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Ansicht" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Modo de vista" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Mode d'affichage" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry.tab.summary" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Summary" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Zusammenfassung" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Resumen" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Résumé" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry.tab.transcripts" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Transcripts" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Transkripte" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Transcripciones" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Transcriptions" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry.tab.memos" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Memos" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Memos" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Notas" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Mémos" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary.generating" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Generating summary…" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Zusammenfassung wird erstellt…" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Generando resumen…" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Génération du résumé…" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary.empty.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No Summary" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Zusammenfassung" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Sin resumen" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucun résumé" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary.empty.description" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Generate a summary from your transcribed voice memos." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Erstelle eine Zusammenfassung aus deinen transkribierten Sprachmemos." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Genera un resumen de tus notas de voz transcritas." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Générez un résumé de vos mémos vocaux transcrits." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary.generate" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Generate Summary" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Zusammenfassung erstellen" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Generar resumen" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Générer le résumé" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary.noTranscripts.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No Transcripts" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Transkripte" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Sin transcripciones" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucune transcription" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary.noTranscripts.description" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Voice memos need to be transcribed before a summary can be generated." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Sprachmemos müssen transkribiert werden, bevor eine Zusammenfassung erstellt werden kann." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Las notas de voz deben transcribirse antes de generar un resumen." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Les mémos vocaux doivent être transcrits avant de générer un résumé." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transcripts.empty.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No Transcripts" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Transkripte" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Sin transcripciones" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucune transcription" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transcripts.empty.description" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Your voice memos will appear here once transcribed." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Deine Sprachmemos erscheinen hier, sobald sie transkribiert wurden." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Tus notas de voz aparecerán aquí una vez transcritas." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Vos mémos vocaux apparaîtront ici une fois transcrits." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"memos.empty.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No Memos" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Memos" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Sin notas" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucun mémo" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"memos.empty.description" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Record a voice memo to get started." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nimm ein Sprachmemo auf, um loszulegen." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Graba una nota de voz para comenzar." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrez un mémo vocal pour commencer." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"memo.transcribing" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Transcribing…" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Wird transkribiert…" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "Transcribiendo…" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Transcription en cours…" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transcription.error.unavailable" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Speech recognition is not available on this device." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Spracherkennung ist auf diesem Gerät nicht verfügbar." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "El reconocimiento de voz no está disponible en este dispositivo." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "La reconnaissance vocale n'est pas disponible sur cet appareil." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transcription.error.noResult" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No transcription result was returned." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Transkriptionsergebnis erhalten." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "No se obtuvo resultado de transcripción." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucun résultat de transcription n'a été retourné." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summarization.error.noTranscript" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No transcript available for summarization." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Transkript für die Zusammenfassung verfügbar." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "No hay transcripción disponible para resumir." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucune transcription disponible pour le résumé." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summarization.error.failed" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Failed to generate summary." } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Zusammenfassung konnte nicht erstellt werden." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "translated", "value" : "No se pudo generar el resumen." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de générer le résumé." } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version" : "1.0"
|
||||||
|
}
|
||||||
72
VoiceDiary/Services/AudioRecorderService.swift
Normal file
72
VoiceDiary/Services/AudioRecorderService.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class AudioRecorderService: NSObject {
|
||||||
|
var isRecording = false
|
||||||
|
var recordingDuration: TimeInterval = 0
|
||||||
|
|
||||||
|
private var audioRecorder: AVAudioRecorder?
|
||||||
|
private var timer: Timer?
|
||||||
|
private var startTime: Date?
|
||||||
|
|
||||||
|
func startRecording() throws -> URL {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.record, mode: .default)
|
||||||
|
try session.setActive(true)
|
||||||
|
|
||||||
|
let fileName = "memo-\(Date.now.ISO8601Format()).m4a"
|
||||||
|
.replacingOccurrences(of: ":", with: "-")
|
||||||
|
let url = VoiceMemo.audioDirectory.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||||
|
AVSampleRateKey: 44100.0,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
||||||
|
]
|
||||||
|
|
||||||
|
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||||
|
audioRecorder?.delegate = self
|
||||||
|
audioRecorder?.record()
|
||||||
|
|
||||||
|
isRecording = true
|
||||||
|
recordingDuration = 0
|
||||||
|
startTime = .now
|
||||||
|
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||||
|
guard let self, let startTime = self.startTime else { return }
|
||||||
|
self.recordingDuration = Date.now.timeIntervalSince(startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording() -> (url: URL, duration: TimeInterval)? {
|
||||||
|
guard let recorder = audioRecorder else { return nil }
|
||||||
|
|
||||||
|
let url = recorder.url
|
||||||
|
let duration = recorder.currentTime
|
||||||
|
|
||||||
|
recorder.stop()
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
audioRecorder = nil
|
||||||
|
isRecording = false
|
||||||
|
startTime = nil
|
||||||
|
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
|
||||||
|
return (url, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioRecorderService: AVAudioRecorderDelegate {
|
||||||
|
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
|
||||||
|
if !flag {
|
||||||
|
isRecording = false
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
VoiceDiary/Services/SummarizationService.swift
Normal file
62
VoiceDiary/Services/SummarizationService.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol SummarizationProvider {
|
||||||
|
func summarize(transcript: String, date: Date) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class SummarizationService {
|
||||||
|
var isSummarizing = false
|
||||||
|
|
||||||
|
private let provider: SummarizationProvider
|
||||||
|
|
||||||
|
init(provider: SummarizationProvider = LocalSummarizationProvider()) {
|
||||||
|
self.provider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSummary(for entry: DiaryEntry) async throws -> String {
|
||||||
|
guard !entry.combinedTranscript.isEmpty else {
|
||||||
|
throw SummarizationError.noTranscript
|
||||||
|
}
|
||||||
|
|
||||||
|
isSummarizing = true
|
||||||
|
defer { isSummarizing = false }
|
||||||
|
|
||||||
|
return try await provider.summarize(transcript: entry.combinedTranscript, date: entry.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SummarizationError: LocalizedError {
|
||||||
|
case noTranscript
|
||||||
|
case generationFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noTranscript:
|
||||||
|
String(localized: "summarization.error.noTranscript")
|
||||||
|
case .generationFailed:
|
||||||
|
String(localized: "summarization.error.failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder provider that structures transcripts into a basic diary format.
|
||||||
|
/// Replace with llama.cpp or other on-device LLM integration.
|
||||||
|
struct LocalSummarizationProvider: SummarizationProvider {
|
||||||
|
func summarize(transcript: String, date: Date) async throws -> String {
|
||||||
|
let dateString = date.formatted(.dateTime.day().month(.wide).year().locale(Locale(identifier: "de-DE")))
|
||||||
|
|
||||||
|
// For now, structure the transcript into a basic diary format.
|
||||||
|
// This will be replaced with actual LLM inference.
|
||||||
|
let lines = transcript.components(separatedBy: .newlines)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
var result = "# \(dateString)\n\n"
|
||||||
|
result += "## Erlebnisse des Tages\n\n"
|
||||||
|
result += lines.joined(separator: "\n\n")
|
||||||
|
result += "\n"
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
59
VoiceDiary/Services/TranscriptionService.swift
Normal file
59
VoiceDiary/Services/TranscriptionService.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class TranscriptionService {
|
||||||
|
var authorizationStatus: SFSpeechRecognizerAuthorizationStatus = .notDetermined
|
||||||
|
|
||||||
|
private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "de-DE"))
|
||||||
|
|
||||||
|
func requestAuthorization() async {
|
||||||
|
authorizationStatus = await withCheckedContinuation { continuation in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
continuation.resume(returning: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transcribe(audioURL: URL) async throws -> String {
|
||||||
|
guard let speechRecognizer, speechRecognizer.isAvailable else {
|
||||||
|
throw TranscriptionError.recognizerUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = SFSpeechURLRecognitionRequest(url: audioURL)
|
||||||
|
request.requiresOnDeviceRecognition = true
|
||||||
|
request.shouldReportPartialResults = false
|
||||||
|
request.addsPunctuation = true
|
||||||
|
|
||||||
|
let result = try await speechRecognizer.recognitionTask(with: request)
|
||||||
|
return result.bestTranscription.formattedString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranscriptionError: LocalizedError {
|
||||||
|
case recognizerUnavailable
|
||||||
|
case noResult
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .recognizerUnavailable:
|
||||||
|
String(localized: "transcription.error.unavailable")
|
||||||
|
case .noResult:
|
||||||
|
String(localized: "transcription.error.noResult")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension SFSpeechRecognizer {
|
||||||
|
func recognitionTask(with request: SFSpeechRecognitionRequest) async throws -> SFSpeechRecognitionResult {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
recognitionTask(with: request) { result, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
} else if let result, result.isFinal {
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
VoiceDiary/ViewModels/DiaryViewModel.swift
Normal file
40
VoiceDiary/ViewModels/DiaryViewModel.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class DiaryViewModel {
|
||||||
|
var error: Error?
|
||||||
|
|
||||||
|
private let summarizationService = SummarizationService()
|
||||||
|
|
||||||
|
var isSummarizing: Bool {
|
||||||
|
summarizationService.isSummarizing
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSummary(for entry: DiaryEntry) async {
|
||||||
|
do {
|
||||||
|
let summary = try await summarizationService.generateSummary(for: entry)
|
||||||
|
entry.summary = summary
|
||||||
|
entry.isSummaryGenerated = true
|
||||||
|
entry.updatedAt = .now
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(_ entry: DiaryEntry, context: ModelContext) {
|
||||||
|
// Delete audio files
|
||||||
|
for memo in entry.memos {
|
||||||
|
try? FileManager.default.removeItem(at: memo.audioURL)
|
||||||
|
}
|
||||||
|
context.delete(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMemo(_ memo: VoiceMemo, from entry: DiaryEntry, context: ModelContext) {
|
||||||
|
try? FileManager.default.removeItem(at: memo.audioURL)
|
||||||
|
entry.memos.removeAll { $0.persistentModelID == memo.persistentModelID }
|
||||||
|
context.delete(memo)
|
||||||
|
entry.updatedAt = .now
|
||||||
|
}
|
||||||
|
}
|
||||||
89
VoiceDiary/ViewModels/RecordingViewModel.swift
Normal file
89
VoiceDiary/ViewModels/RecordingViewModel.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class RecordingViewModel {
|
||||||
|
var isRecording = false
|
||||||
|
var recordingDuration: TimeInterval = 0
|
||||||
|
var error: Error?
|
||||||
|
|
||||||
|
private let recorder = AudioRecorderService()
|
||||||
|
private let transcriptionService = TranscriptionService()
|
||||||
|
private var currentRecordingURL: URL?
|
||||||
|
|
||||||
|
var formattedDuration: String {
|
||||||
|
let minutes = Int(recorder.recordingDuration) / 60
|
||||||
|
let seconds = Int(recorder.recordingDuration) % 60
|
||||||
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRecording() {
|
||||||
|
do {
|
||||||
|
currentRecordingURL = try recorder.startRecording()
|
||||||
|
isRecording = true
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording(context: ModelContext) {
|
||||||
|
guard let result = recorder.stopRecording() else { return }
|
||||||
|
isRecording = false
|
||||||
|
|
||||||
|
let today = Calendar.current.startOfDay(for: .now)
|
||||||
|
let entry = fetchOrCreateEntry(for: today, context: context)
|
||||||
|
|
||||||
|
let memo = VoiceMemo(
|
||||||
|
audioFileName: result.url.lastPathComponent,
|
||||||
|
duration: result.duration
|
||||||
|
)
|
||||||
|
entry.memos.append(memo)
|
||||||
|
entry.updatedAt = .now
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await transcribeMemo(memo, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchOrCreateEntry(for date: Date, context: ModelContext) -> DiaryEntry {
|
||||||
|
let startOfDay = Calendar.current.startOfDay(for: date)
|
||||||
|
let endOfDay = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var descriptor = FetchDescriptor<DiaryEntry>(
|
||||||
|
predicate: #Predicate { $0.date >= startOfDay && $0.date < endOfDay }
|
||||||
|
)
|
||||||
|
descriptor.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existing = try? context.fetch(descriptor).first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = DiaryEntry(date: date)
|
||||||
|
context.insert(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transcribeMemo(_ memo: VoiceMemo, context: ModelContext) async {
|
||||||
|
if transcriptionService.authorizationStatus == .notDetermined {
|
||||||
|
await transcriptionService.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard transcriptionService.authorizationStatus == .authorized else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
memo.isTranscribing = true
|
||||||
|
defer { memo.isTranscribing = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let transcript = try await transcriptionService.transcribe(audioURL: memo.audioURL)
|
||||||
|
memo.transcript = transcript
|
||||||
|
memo.entry?.updatedAt = .now
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
VoiceDiary/Views/ContentView.swift
Normal file
113
VoiceDiary/Views/ContentView.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query(sort: \DiaryEntry.date, order: .reverse) private var entries: [DiaryEntry]
|
||||||
|
@State private var recordingViewModel = RecordingViewModel()
|
||||||
|
@State private var diaryViewModel = DiaryViewModel()
|
||||||
|
@State private var showingRecording = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if entries.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
diaryList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "diary.title"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showingRecording = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "mic.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "recording.start"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingRecording) {
|
||||||
|
RecordingView(viewModel: recordingViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(diaryViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(String(localized: "diary.empty.title"), systemImage: "book.closed")
|
||||||
|
} description: {
|
||||||
|
Text(String(localized: "diary.empty.description"))
|
||||||
|
} actions: {
|
||||||
|
Button {
|
||||||
|
showingRecording = true
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "diary.empty.action"))
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var diaryList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(entries) { entry in
|
||||||
|
NavigationLink(value: entry) {
|
||||||
|
DiaryListRow(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
for index in indexSet {
|
||||||
|
diaryViewModel.deleteEntry(entries[index], context: modelContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(for: DiaryEntry.self) { entry in
|
||||||
|
DiaryEntryView(entry: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiaryListRow: View {
|
||||||
|
let entry: DiaryEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(entry.formattedDate)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label(
|
||||||
|
String(localized: "diary.memoCount \(entry.memos.count)"),
|
||||||
|
systemImage: "waveform"
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.isSummaryGenerated {
|
||||||
|
Label(
|
||||||
|
String(localized: "diary.summarized"),
|
||||||
|
systemImage: "checkmark.circle"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let summary = entry.summary {
|
||||||
|
Text(summary.prefix(120))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.modelContainer(for: DiaryEntry.self, inMemory: true)
|
||||||
|
}
|
||||||
156
VoiceDiary/Views/DiaryEntryView.swift
Normal file
156
VoiceDiary/Views/DiaryEntryView.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DiaryEntryView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(DiaryViewModel.self) private var viewModel
|
||||||
|
let entry: DiaryEntry
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Picker(String(localized: "entry.viewMode"), selection: $selectedTab) {
|
||||||
|
Text(String(localized: "entry.tab.summary")).tag(0)
|
||||||
|
Text(String(localized: "entry.tab.transcripts")).tag(1)
|
||||||
|
Text(String(localized: "entry.tab.memos")).tag(2)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
summaryTab
|
||||||
|
.tag(0)
|
||||||
|
transcriptsTab
|
||||||
|
.tag(1)
|
||||||
|
memosTab
|
||||||
|
.tag(2)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
}
|
||||||
|
.navigationTitle(entry.formattedDate)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary Tab
|
||||||
|
|
||||||
|
private var summaryTab: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let summary = entry.summary {
|
||||||
|
Text(LocalizedStringKey(summary))
|
||||||
|
.font(.body)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
} else if viewModel.isSummarizing {
|
||||||
|
ProgressView(String(localized: "summary.generating"))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.top, 40)
|
||||||
|
} else if entry.hasTranscripts {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(
|
||||||
|
String(localized: "summary.empty.title"),
|
||||||
|
systemImage: "text.document"
|
||||||
|
)
|
||||||
|
} description: {
|
||||||
|
Text(String(localized: "summary.empty.description"))
|
||||||
|
} actions: {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.generateSummary(for: entry) }
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "summary.generate"))
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
String(localized: "summary.noTranscripts.title"),
|
||||||
|
systemImage: "waveform.slash",
|
||||||
|
description: Text(String(localized: "summary.noTranscripts.description"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transcripts Tab
|
||||||
|
|
||||||
|
private var transcriptsTab: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if entry.combinedTranscript.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
String(localized: "transcripts.empty.title"),
|
||||||
|
systemImage: "text.bubble",
|
||||||
|
description: Text(String(localized: "transcripts.empty.description"))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(entry.combinedTranscript)
|
||||||
|
.font(.body)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memos Tab
|
||||||
|
|
||||||
|
private var memosTab: some View {
|
||||||
|
List {
|
||||||
|
ForEach(entry.memos.sorted(by: { $0.recordedAt < $1.recordedAt })) { memo in
|
||||||
|
MemoRow(memo: memo)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
let sorted = entry.memos.sorted { $0.recordedAt < $1.recordedAt }
|
||||||
|
for index in indexSet {
|
||||||
|
viewModel.deleteMemo(sorted[index], from: entry, context: modelContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.overlay {
|
||||||
|
if entry.memos.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
String(localized: "memos.empty.title"),
|
||||||
|
systemImage: "mic.slash",
|
||||||
|
description: Text(String(localized: "memos.empty.description"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MemoRow: View {
|
||||||
|
let memo: VoiceMemo
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Label(memo.formattedDuration, systemImage: "waveform")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(memo.recordedAt, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if memo.isTranscribing {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
Text(String(localized: "memo.transcribing"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if let transcript = memo.transcript {
|
||||||
|
Text(transcript.prefix(100))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
VoiceDiary/Views/RecordingView.swift
Normal file
87
VoiceDiary/Views/RecordingView.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RecordingView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Bindable var viewModel: RecordingViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 40) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
timerDisplay
|
||||||
|
|
||||||
|
recordButton
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let error = viewModel.error {
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle(String(localized: "recording.title"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "general.done")) {
|
||||||
|
if viewModel.isRecording {
|
||||||
|
viewModel.stopRecording(context: modelContext)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerDisplay: some View {
|
||||||
|
Text(viewModel.formattedDuration)
|
||||||
|
.font(.system(size: 64, weight: .light, design: .monospaced))
|
||||||
|
.foregroundStyle(viewModel.isRecording ? .primary : .secondary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.default, value: viewModel.formattedDuration)
|
||||||
|
.accessibilityLabel(String(localized: "recording.duration"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recordButton: some View {
|
||||||
|
Button {
|
||||||
|
if viewModel.isRecording {
|
||||||
|
viewModel.stopRecording(context: modelContext)
|
||||||
|
} else {
|
||||||
|
viewModel.startRecording()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(viewModel.isRecording ? .red : .red.opacity(0.15))
|
||||||
|
.frame(width: 88, height: 88)
|
||||||
|
|
||||||
|
if viewModel.isRecording {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(
|
||||||
|
viewModel.isRecording
|
||||||
|
? String(localized: "recording.stop")
|
||||||
|
: String(localized: "recording.start")
|
||||||
|
)
|
||||||
|
.sensoryFeedback(.impact, trigger: viewModel.isRecording)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RecordingView(viewModel: RecordingViewModel())
|
||||||
|
}
|
||||||
16
VoiceDiary/VoiceDiary.entitlements
Normal file
16
VoiceDiary/VoiceDiary.entitlements
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?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.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.com.felixfoertsch.VoiceDiary</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)com.felixfoertsch.VoiceDiary</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
75
VoiceDiaryTests/VoiceDiaryTests.swift
Normal file
75
VoiceDiaryTests/VoiceDiaryTests.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import VoiceDiary
|
||||||
|
|
||||||
|
@Suite("DiaryEntry Tests")
|
||||||
|
struct DiaryEntryTests {
|
||||||
|
@Test("Entry date is normalized to start of day")
|
||||||
|
func dateNormalization() {
|
||||||
|
let now = Date.now
|
||||||
|
let entry = DiaryEntry(date: now)
|
||||||
|
let startOfDay = Calendar.current.startOfDay(for: now)
|
||||||
|
#expect(entry.date == startOfDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Combined transcript joins memo transcripts")
|
||||||
|
func combinedTranscript() {
|
||||||
|
let entry = DiaryEntry(date: .now)
|
||||||
|
|
||||||
|
let memo1 = VoiceMemo(audioFileName: "test1.m4a", duration: 10)
|
||||||
|
memo1.transcript = "First memo."
|
||||||
|
|
||||||
|
let memo2 = VoiceMemo(audioFileName: "test2.m4a", duration: 20)
|
||||||
|
memo2.transcript = "Second memo."
|
||||||
|
|
||||||
|
entry.memos = [memo1, memo2]
|
||||||
|
|
||||||
|
#expect(entry.combinedTranscript.contains("First memo."))
|
||||||
|
#expect(entry.combinedTranscript.contains("Second memo."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Entry without memos has empty transcript")
|
||||||
|
func emptyTranscript() {
|
||||||
|
let entry = DiaryEntry(date: .now)
|
||||||
|
#expect(entry.combinedTranscript.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("hasMemos returns correct state")
|
||||||
|
func hasMemos() {
|
||||||
|
let entry = DiaryEntry(date: .now)
|
||||||
|
#expect(!entry.hasMemos)
|
||||||
|
|
||||||
|
entry.memos.append(VoiceMemo(audioFileName: "test.m4a", duration: 5))
|
||||||
|
#expect(entry.hasMemos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("VoiceMemo Tests")
|
||||||
|
struct VoiceMemoTests {
|
||||||
|
@Test("Formatted duration displays correctly")
|
||||||
|
func formattedDuration() {
|
||||||
|
let memo = VoiceMemo(audioFileName: "test.m4a", duration: 125)
|
||||||
|
#expect(memo.formattedDuration == "2:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Audio URL is constructed correctly")
|
||||||
|
func audioURL() {
|
||||||
|
let memo = VoiceMemo(audioFileName: "test-memo.m4a", duration: 10)
|
||||||
|
#expect(memo.audioURL.lastPathComponent == "test-memo.m4a")
|
||||||
|
#expect(memo.audioURL.pathComponents.contains("VoiceMemos"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("LocalSummarizationProvider Tests")
|
||||||
|
struct SummarizationTests {
|
||||||
|
@Test("Placeholder summarization produces markdown")
|
||||||
|
func basicSummarization() async throws {
|
||||||
|
let provider = LocalSummarizationProvider()
|
||||||
|
let result = try await provider.summarize(
|
||||||
|
transcript: "Heute war ein guter Tag. Ich habe viel geschafft.",
|
||||||
|
date: Date.now
|
||||||
|
)
|
||||||
|
#expect(result.contains("# "))
|
||||||
|
#expect(result.contains("Erlebnisse des Tages"))
|
||||||
|
#expect(result.contains("Heute war ein guter Tag"))
|
||||||
|
}
|
||||||
|
}
|
||||||
52
project.yml
Normal file
52
project.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: VoiceDiary
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.felixfoertsch
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "26.0"
|
||||||
|
xcodeVersion: "26.2"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
defaultConfig: Release
|
||||||
|
groupSortPosition: top
|
||||||
|
indentWidth: 4
|
||||||
|
tabWidth: 4
|
||||||
|
usesTabs: true
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
DEVELOPMENT_TEAM: NG5W75WE8U
|
||||||
|
MARKETING_VERSION: "2026.02.15"
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
VoiceDiary:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- VoiceDiary
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
INFOPLIST_FILE: VoiceDiary/Info.plist
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.felixfoertsch.VoiceDiary
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
ENABLE_PREVIEWS: true
|
||||||
|
SWIFT_EMIT_LOC_STRINGS: true
|
||||||
|
entitlements:
|
||||||
|
path: VoiceDiary/VoiceDiary.entitlements
|
||||||
|
properties:
|
||||||
|
com.apple.developer.icloud-container-identifiers:
|
||||||
|
- iCloud.com.felixfoertsch.VoiceDiary
|
||||||
|
com.apple.developer.icloud-services:
|
||||||
|
- CloudKit
|
||||||
|
com.apple.developer.ubiquity-kvstore-identifier: $(TeamIdentifierPrefix)com.felixfoertsch.VoiceDiary
|
||||||
|
|
||||||
|
VoiceDiaryTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- VoiceDiaryTests
|
||||||
|
dependencies:
|
||||||
|
- target: VoiceDiary
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.felixfoertsch.VoiceDiaryTests
|
||||||
Reference in New Issue
Block a user