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