commit dca03214b0971f556dd77757d9f14aaa6a843293 Author: Felix Förtsch Date: Sun Feb 15 22:57:41 2026 +0100 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63077d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Xcode +build/ +DerivedData/ +*.xcuserdata +*.xcworkspace/xcuserdata/ +xcuserdata/ + +# macOS +.DS_Store +*.swp +*~ + +# Swift Package Manager +.build/ +Packages/ + +# CocoaPods +Pods/ diff --git a/VoiceDiary.xcodeproj/project.pbxproj b/VoiceDiary.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f0f927d --- /dev/null +++ b/VoiceDiary.xcodeproj/project.pbxproj @@ -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 = ""; }; + 19E99B8436E44AEC9EC5DB77 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 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 = ""; }; + 2CAFA3F05939B94F1CFD372C /* VoiceDiaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceDiaryTests.swift; sourceTree = ""; }; + 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 = ""; }; + 5CB4DBB0B43B8080D8A0A42D /* VoiceDiaryApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceDiaryApp.swift; sourceTree = ""; }; + 7DC6214C1C44C4F33F7C0C97 /* DiaryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryEntry.swift; sourceTree = ""; }; + 989E1C6DD70521684A5CB77F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 9CD664C8607AD9DC37A6C8D1 /* TranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionService.swift; sourceTree = ""; }; + BE004D539240AD09CEDA1897 /* SummarizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummarizationService.swift; sourceTree = ""; }; + C644C7A7F19E610C2D413C28 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + D4431B77FD9EDB156C12BB19 /* DiaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryViewModel.swift; sourceTree = ""; }; + D4CCBA3B214071BEDD37CB6E /* DiaryEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryEntryView.swift; sourceTree = ""; }; + E4B15363D4D0CAD8C0DD46D6 /* RecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingView.swift; sourceTree = ""; }; + F18ECC39AB60EF3AFF82EB90 /* VoiceDiary.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceDiary.entitlements; sourceTree = ""; }; + F30F8103F5AEAC26F1412FEC /* AudioRecorderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderService.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0B9B9383707B694E145D3D8F /* App */ = { + isa = PBXGroup; + children = ( + 5CB4DBB0B43B8080D8A0A42D /* VoiceDiaryApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 142BDBF117BAFAC9CA20527E /* Services */ = { + isa = PBXGroup; + children = ( + F30F8103F5AEAC26F1412FEC /* AudioRecorderService.swift */, + BE004D539240AD09CEDA1897 /* SummarizationService.swift */, + 9CD664C8607AD9DC37A6C8D1 /* TranscriptionService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 42EF4FC988F4B2BBF5402B65 = { + isa = PBXGroup; + children = ( + D60D19D2FDD5910699F712E9 /* VoiceDiary */, + 9AD23776FA65C4F8D3F0B0EF /* VoiceDiaryTests */, + DA3C886529BDBC4095712204 /* Products */, + ); + indentWidth = 4; + sourceTree = ""; + tabWidth = 4; + usesTabs = 1; + }; + 43EA53248AFC60081B0E8EE4 /* Resources */ = { + isa = PBXGroup; + children = ( + 19E99B8436E44AEC9EC5DB77 /* Assets.xcassets */, + 0A2739861476DDD2140B3BA6 /* Localizable.xcstrings */, + ); + path = Resources; + sourceTree = ""; + }; + 88B119656D1AB183CCE625F9 /* ViewModels */ = { + isa = PBXGroup; + children = ( + D4431B77FD9EDB156C12BB19 /* DiaryViewModel.swift */, + 39F1A1F4DB96563596246DF1 /* RecordingViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 96673587A895904ADB69A5AA /* Models */ = { + isa = PBXGroup; + children = ( + 7DC6214C1C44C4F33F7C0C97 /* DiaryEntry.swift */, + 1EF2756833D6984B86086F05 /* VoiceMemo.swift */, + ); + path = Models; + sourceTree = ""; + }; + 9AD23776FA65C4F8D3F0B0EF /* VoiceDiaryTests */ = { + isa = PBXGroup; + children = ( + 2CAFA3F05939B94F1CFD372C /* VoiceDiaryTests.swift */, + ); + path = VoiceDiaryTests; + sourceTree = ""; + }; + C12EA9E46E07D0BD5F8F9158 /* Views */ = { + isa = PBXGroup; + children = ( + 989E1C6DD70521684A5CB77F /* ContentView.swift */, + D4CCBA3B214071BEDD37CB6E /* DiaryEntryView.swift */, + E4B15363D4D0CAD8C0DD46D6 /* RecordingView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 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 = ""; + }; + DA3C886529BDBC4095712204 /* Products */ = { + isa = PBXGroup; + children = ( + 30040C9F616FE5225F189FF9 /* VoiceDiary.app */, + 1CA6B754AFDFDAF2C8AF9C01 /* VoiceDiaryTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/VoiceDiary.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/VoiceDiary.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/VoiceDiary.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/VoiceDiary/App/VoiceDiaryApp.swift b/VoiceDiary/App/VoiceDiaryApp.swift new file mode 100644 index 0000000..9999d7e --- /dev/null +++ b/VoiceDiary/App/VoiceDiaryApp.swift @@ -0,0 +1,12 @@ +import SwiftData +import SwiftUI + +@main +struct VoiceDiaryApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: DiaryEntry.self) + } +} diff --git a/VoiceDiary/Info.plist b/VoiceDiary/Info.plist new file mode 100644 index 0000000..c4c0cce --- /dev/null +++ b/VoiceDiary/Info.plist @@ -0,0 +1,10 @@ + + + + + NSMicrophoneUsageDescription + Voice Diary needs microphone access to record your voice memos for diary entries. + NSSpeechRecognitionUsageDescription + Voice Diary uses on-device speech recognition to transcribe your voice memos. + + diff --git a/VoiceDiary/Models/DiaryEntry.swift b/VoiceDiary/Models/DiaryEntry.swift new file mode 100644 index 0000000..3b38d21 --- /dev/null +++ b/VoiceDiary/Models/DiaryEntry.swift @@ -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()) + } +} diff --git a/VoiceDiary/Models/VoiceMemo.swift b/VoiceDiary/Models/VoiceMemo.swift new file mode 100644 index 0000000..37d4591 --- /dev/null +++ b/VoiceDiary/Models/VoiceMemo.swift @@ -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 + } +} diff --git a/VoiceDiary/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/VoiceDiary/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/VoiceDiary/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VoiceDiary/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/VoiceDiary/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/VoiceDiary/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VoiceDiary/Resources/Assets.xcassets/Contents.json b/VoiceDiary/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/VoiceDiary/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VoiceDiary/Resources/Localizable.xcstrings b/VoiceDiary/Resources/Localizable.xcstrings new file mode 100644 index 0000000..b0f56e2 --- /dev/null +++ b/VoiceDiary/Resources/Localizable.xcstrings @@ -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" +} diff --git a/VoiceDiary/Services/AudioRecorderService.swift b/VoiceDiary/Services/AudioRecorderService.swift new file mode 100644 index 0000000..a3d3caa --- /dev/null +++ b/VoiceDiary/Services/AudioRecorderService.swift @@ -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 + } + } +} diff --git a/VoiceDiary/Services/SummarizationService.swift b/VoiceDiary/Services/SummarizationService.swift new file mode 100644 index 0000000..cfb2e71 --- /dev/null +++ b/VoiceDiary/Services/SummarizationService.swift @@ -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 + } +} diff --git a/VoiceDiary/Services/TranscriptionService.swift b/VoiceDiary/Services/TranscriptionService.swift new file mode 100644 index 0000000..5a89ab1 --- /dev/null +++ b/VoiceDiary/Services/TranscriptionService.swift @@ -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) + } + } + } + } +} diff --git a/VoiceDiary/ViewModels/DiaryViewModel.swift b/VoiceDiary/ViewModels/DiaryViewModel.swift new file mode 100644 index 0000000..e667fcf --- /dev/null +++ b/VoiceDiary/ViewModels/DiaryViewModel.swift @@ -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 + } +} diff --git a/VoiceDiary/ViewModels/RecordingViewModel.swift b/VoiceDiary/ViewModels/RecordingViewModel.swift new file mode 100644 index 0000000..57445c3 --- /dev/null +++ b/VoiceDiary/ViewModels/RecordingViewModel.swift @@ -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( + 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 + } + } +} diff --git a/VoiceDiary/Views/ContentView.swift b/VoiceDiary/Views/ContentView.swift new file mode 100644 index 0000000..1b56aaa --- /dev/null +++ b/VoiceDiary/Views/ContentView.swift @@ -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) +} diff --git a/VoiceDiary/Views/DiaryEntryView.swift b/VoiceDiary/Views/DiaryEntryView.swift new file mode 100644 index 0000000..dc5d1a3 --- /dev/null +++ b/VoiceDiary/Views/DiaryEntryView.swift @@ -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) + } +} diff --git a/VoiceDiary/Views/RecordingView.swift b/VoiceDiary/Views/RecordingView.swift new file mode 100644 index 0000000..d5502f7 --- /dev/null +++ b/VoiceDiary/Views/RecordingView.swift @@ -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()) +} diff --git a/VoiceDiary/VoiceDiary.entitlements b/VoiceDiary/VoiceDiary.entitlements new file mode 100644 index 0000000..e2218a4 --- /dev/null +++ b/VoiceDiary/VoiceDiary.entitlements @@ -0,0 +1,16 @@ + + + + + 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 + + diff --git a/VoiceDiaryTests/VoiceDiaryTests.swift b/VoiceDiaryTests/VoiceDiaryTests.swift new file mode 100644 index 0000000..0b12c69 --- /dev/null +++ b/VoiceDiaryTests/VoiceDiaryTests.swift @@ -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")) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..b2eca5b --- /dev/null +++ b/project.yml @@ -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