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:
2026-02-15 22:57:41 +01:00
commit dca03214b0
22 changed files with 1766 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Xcode
build/
DerivedData/
*.xcuserdata
*.xcworkspace/xcuserdata/
xcuserdata/
# macOS
.DS_Store
*.swp
*~
# Swift Package Manager
.build/
Packages/
# CocoaPods
Pods/

View 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 */;
}

View File

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

View File

@@ -0,0 +1,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
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>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>

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"
}

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

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

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

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

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

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

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

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

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

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