diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 53ebe63..d797b15 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -34,10 +34,19 @@ jobs: rm -rf ~/Library/Developer/Xcode/DerivedData rm -rf .build - - name: Create .env for CI + - name: Create dummy Secrets.swift for CI run: | - cat > .env << 'EOF' - export OPENAI_API_KEY + cat > SwiftGPT/Secrets.swift << 'EOF' + // + // Secrets.swift + // SwiftGPT - CI Build + // + + import Foundation + + enum Secrets { + static let openaiApiKey = "dummy-key-for-ci-build" + } EOF - name: Resolve packages @@ -47,14 +56,6 @@ jobs: -project SwiftGPT.xcodeproj \ -scheme "SwiftGPT" - - name: Build SecretsManager plugin - run: | - set -euo pipefail - xcodebuild -scheme SecretsManager \ - -destination 'platform=macOS' \ - -derivedDataPath ./DerivedData \ - build || echo "SecretsManager build skipped" - - name: Build (no code signing) run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index a216a1d..127c36f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ profile .idea/ # system files -.DS_Store \ No newline at end of file +.DS_Store + +# Secrets +Secrets.swift +**/Secrets.swift \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 8e80f63..c8a431d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,51 +1,125 @@ +excluded: + - "bin/*" + - "Script/*" + - "Generated/*" + - "Package.swift" + - "Resources/Localizables/Strings+Generated.swift" + +disabled_rules: + - identifier_name + - inclusive_language + - type_name + - redundant_string_enum_value + - todo + +analyzer_rules: + - explicit_self + - unused_declaration + - unused_import + +opt_in_rules: + - array_init + - closure_spacing + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - cyclomatic_complexity + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - expiring_todo + - explicit_init + - fatal_error_message + - file_name_no_space + - first_where + - flatmap_over_map_reduce + - identical_operands + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - modifier_order + - nimble_operator + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - prefer_self_type_over_type_of_self + - prohibited_super_call + - raw_value_for_camel_cased_codable_enum + - redundant_nil_coalescing + - required_enum_case + - sorted_first_last + - static_operator + - toggle_bool + - unneeded_parentheses_in_closure_argument + - untyped_error_in_catch + - vertical_whitespace_closing_braces + - yoda_condition + - comment_spacing + - function_body_length + - file_length + - line_length + - type_body_length + - nesting + - large_tuple + - vertical_whitespace + - force_cast + - force_try + - force_unwrapping + - implicitly_unwrapped_optional + - trailing_whitespace + line_length: - warning: 160 - error: 200 + warning: 180 + error: 250 ignores_comments: true function_body_length: - warning: 100 - error: 160 + warning: 120 + error: 180 file_length: - warning: 400 + warning: 500 error: 600 -nesting: - type_level: 6 +type_body_length: + warning: 500 + error: 600 vertical_whitespace: max_empty_lines: 1 +nesting: + type_level: 6 + large_tuple: warning: 5 error: 6 -opt_in_rules: - - force_cast # as! - - force_try # try! - - force_unwrapping # ! - - implicitly_unwrapped_optional # ! - - unneeded_parentheses_in_closure_argument # () - force_cast: - severity: warning + severity: error force_try: - severity: warning + severity: error force_unwrapping: - severity: warning + severity: error implicitly_unwrapped_optional: - severity: warning - -disabled_rules: - - multiple_closures_with_trailing_closure + severity: error custom_rules: + private_state_finder: + include: "*.swift" + name: "Private State" + regex: "(@State\\s+var)" + message: "States should be private." + severity: warning sf_safe_symbol: name: "Safe SFSymbol" message: "Use `SFSafeSymbols` via `systemSymbol` parameters for type safety." regex: "(Image\\(systemName:)|(NSImage\\(symbolName:)|(Label[^,]+?,\\s*systemImage:)|(UIApplicationShortcutIcon\\(systemImageName:)" - severity: error + severity: warning diff --git a/Resources/Localizables/Localizable.strings b/Resources/Localizables/Localizable.strings index 02de183..fc98ab8 100644 --- a/Resources/Localizables/Localizable.strings +++ b/Resources/Localizables/Localizable.strings @@ -3,3 +3,20 @@ "dalle.error.image_conversion" = "Image conversion error"; "message.textfield.placeholder" = "Message..."; "chat.introduce.title" = "Write your first message!"; +"error.network" = "Network error occurred"; +"error.api" = "API request failed"; +"error.unknown" = "An unexpected error occurred"; +"error.save_photo" = "Failed to save image to Photos"; +"error.save_photo_permission" = "Permission denied. Please allow access to Photos in Settings."; + +// Accessibility +"accessibility.tab.chatgpt" = "Chat with GPT"; +"accessibility.tab.dalle" = "Generate images with DALL·E"; +"accessibility.button.send" = "Send message"; +"accessibility.button.send.hint" = "Sends your message to the AI"; +"accessibility.button.share" = "Share image"; +"accessibility.button.save" = "Save image to photos"; +"accessibility.textfield.message" = "Message input field"; +"accessibility.image.generated" = "Generated image"; +"accessibility.image.user" = "User avatar"; +"accessibility.image.gpt" = "AI avatar"; diff --git a/Resources/Localizables/Strings+Generated.swift b/Resources/Localizables/Strings+Generated.swift index a829031..0cc739c 100644 --- a/Resources/Localizables/Strings+Generated.swift +++ b/Resources/Localizables/Strings+Generated.swift @@ -10,6 +10,38 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { + internal enum Accessibility { + internal enum Button { + /// Save image to photos + internal static let save = L10n.tr("Localizable", "accessibility.button.save", fallback: "Save image to photos") + /// Send message + internal static let send = L10n.tr("Localizable", "accessibility.button.send", fallback: "Send message") + /// Share image + internal static let share = L10n.tr("Localizable", "accessibility.button.share", fallback: "Share image") + internal enum Send { + /// Sends your message to the AI + internal static let hint = L10n.tr("Localizable", "accessibility.button.send.hint", fallback: "Sends your message to the AI") + } + } + internal enum Image { + /// Generated image + internal static let generated = L10n.tr("Localizable", "accessibility.image.generated", fallback: "Generated image") + /// AI avatar + internal static let gpt = L10n.tr("Localizable", "accessibility.image.gpt", fallback: "AI avatar") + /// User avatar + internal static let user = L10n.tr("Localizable", "accessibility.image.user", fallback: "User avatar") + } + internal enum Tab { + /// Chat with GPT + internal static let chatgpt = L10n.tr("Localizable", "accessibility.tab.chatgpt", fallback: "Chat with GPT") + /// Generate images with DALL·E + internal static let dalle = L10n.tr("Localizable", "accessibility.tab.dalle", fallback: "Generate images with DALL·E") + } + internal enum Textfield { + /// Message input field + internal static let message = L10n.tr("Localizable", "accessibility.textfield.message", fallback: "Message input field") + } + } internal enum Chat { internal enum Introduce { /// Write your first message! @@ -32,6 +64,18 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "dalle.tab.title", fallback: "DALL·E 2") } } + internal enum Error { + /// API request failed + internal static let api = L10n.tr("Localizable", "error.api", fallback: "API request failed") + /// Network error occurred + internal static let network = L10n.tr("Localizable", "error.network", fallback: "Network error occurred") + /// Failed to save image to Photos + internal static let savePhoto = L10n.tr("Localizable", "error.save_photo", fallback: "Failed to save image to Photos") + /// Permission denied. Please allow access to Photos in Settings. + internal static let savePhotoPermission = L10n.tr("Localizable", "error.save_photo_permission", fallback: "Permission denied. Please allow access to Photos in Settings.") + /// An unexpected error occurred + internal static let unknown = L10n.tr("Localizable", "error.unknown", fallback: "An unexpected error occurred") + } internal enum Message { internal enum Textfield { /// Message... diff --git a/SwiftGPT.xcodeproj/project.pbxproj b/SwiftGPT.xcodeproj/project.pbxproj index 1e820d2..e99f3ed 100644 --- a/SwiftGPT.xcodeproj/project.pbxproj +++ b/SwiftGPT.xcodeproj/project.pbxproj @@ -3,50 +3,38 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - 2917754B29B4963800EEC302 /* GPT3ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2917754A29B4963800EEC302 /* GPT3ViewModel.swift */; }; 2917754E29B606EC00EEC302 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = 2917754D29B606EC00EEC302 /* OpenAI */; }; - 2917755029B6083800EEC302 /* ChatGPTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2917754F29B6083800EEC302 /* ChatGPTView.swift */; }; 2917755329B60B2100EEC302 /* ChatGPTSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2917755229B60B2100EEC302 /* ChatGPTSwift */; }; - 29646507299F7E12005146FD /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29646506299F7E12005146FD /* Message.swift */; }; - 2981BF12299256BB00D40C7A /* SwiftGPTApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2981BF11299256BB00D40C7A /* SwiftGPTApp.swift */; }; - 2981BF14299256BB00D40C7A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2981BF13299256BB00D40C7A /* ContentView.swift */; }; - 2981BF16299256BE00D40C7A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2981BF15299256BE00D40C7A /* Assets.xcassets */; }; - 2981BF19299256BE00D40C7A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2981BF18299256BE00D40C7A /* Preview Assets.xcassets */; }; 2981BF212992578800D40C7A /* OpenAIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2981BF202992578800D40C7A /* OpenAIKit */; }; - 2981BF2B2992589300D40C7A /* DalleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2981BF2A2992589300D40C7A /* DalleView.swift */; }; - 2981BF2D299258D200D40C7A /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2981BF2C299258D200D40C7A /* MessageView.swift */; }; - 2981BF352992964000D40C7A /* DalleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2981BF342992964000D40C7A /* DalleViewModel.swift */; }; - 6E41A5442D9CF1090061EDAA /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 6E41A5412D9CF1090061EDAA /* swiftgen.yml */; }; - 6E41A5452D9CF1090061EDAA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6E41A53F2D9CF1090061EDAA /* Localizable.strings */; }; - 6E41A5462D9CF1090061EDAA /* Strings+Generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E41A5402D9CF1090061EDAA /* Strings+Generated.swift */; }; - 6E41A5532DA03CEF0061EDAA /* MessageInputArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E41A5522DA03CEF0061EDAA /* MessageInputArea.swift */; }; 6E44C8002D93121500268862 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 6E44C7FF2D93121500268862 /* SFSafeSymbols */; }; - 6E44C80E2D9CEEC800268862 /* MessageIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2981BF2E299258F800D40C7A /* MessageIndicatorView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 6E023C552E8BEB6D004E7990 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2981BF06299256BB00D40C7A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2981BF0D299256BB00D40C7A; + remoteInfo = SwiftGPT; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - 2917754A29B4963800EEC302 /* GPT3ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPT3ViewModel.swift; sourceTree = ""; }; - 2917754F29B6083800EEC302 /* ChatGPTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTView.swift; sourceTree = ""; }; - 29646506299F7E12005146FD /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 2981BF0E299256BB00D40C7A /* SwiftGPT.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftGPT.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 2981BF11299256BB00D40C7A /* SwiftGPTApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SwiftGPTApp.swift; path = SwiftGPT/SwiftGPTApp.swift; sourceTree = ""; }; - 2981BF13299256BB00D40C7A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = SwiftGPT/ContentView.swift; sourceTree = ""; }; - 2981BF15299256BE00D40C7A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = SwiftGPT/Assets.xcassets; sourceTree = ""; }; - 2981BF18299256BE00D40C7A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 2981BF2A2992589300D40C7A /* DalleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DalleView.swift; sourceTree = ""; }; - 2981BF2C299258D200D40C7A /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; - 2981BF2E299258F800D40C7A /* MessageIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageIndicatorView.swift; sourceTree = ""; }; - 2981BF342992964000D40C7A /* DalleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DalleViewModel.swift; sourceTree = ""; }; - 6E41A53F2D9CF1090061EDAA /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; - 6E41A5402D9CF1090061EDAA /* Strings+Generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Strings+Generated.swift"; sourceTree = ""; }; - 6E41A5412D9CF1090061EDAA /* swiftgen.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; - 6E41A5522DA03CEF0061EDAA /* MessageInputArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputArea.swift; sourceTree = ""; }; + 6E023C512E8BEB6D004E7990 /* SwiftGPTTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftGPTTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 6E023C522E8BEB6D004E7990 /* SwiftGPTTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SwiftGPTTests; sourceTree = ""; }; + 6E4ADDC32E8BE87A00AD8FA5 /* SwiftGPTTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SwiftGPTTests; sourceTree = ""; }; + 6E4ADDE12E8BE88300AD8FA5 /* SwiftGPT */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SwiftGPT; sourceTree = ""; }; + 6E4ADE002E8BE91A00AD8FA5 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 2981BF0B299256BB00D40C7A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -59,24 +47,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 6E023C4E2E8BEB6D004E7990 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 29646505299F7E02005146FD /* Models */ = { - isa = PBXGroup; - children = ( - 29646506299F7E12005146FD /* Message.swift */, - ); - name = Models; - path = SwiftGPT/Models; - sourceTree = ""; - }; 2981BF05299256BB00D40C7A = { isa = PBXGroup; children = ( - 2981BF10299256BB00D40C7A /* SwiftGPT */, + 6E4ADDC32E8BE87A00AD8FA5 /* SwiftGPTTests */, + 6E4ADDE12E8BE88300AD8FA5 /* SwiftGPT */, + 6E023C522E8BEB6D004E7990 /* SwiftGPTTests */, 2981BF0F299256BB00D40C7A /* Products */, - 6E41A5432D9CF1090061EDAA /* Resources */, + 6E4ADE002E8BE91A00AD8FA5 /* Resources */, ); sourceTree = ""; }; @@ -84,82 +72,11 @@ isa = PBXGroup; children = ( 2981BF0E299256BB00D40C7A /* SwiftGPT.app */, + 6E023C512E8BEB6D004E7990 /* SwiftGPTTests.xctest */, ); name = Products; sourceTree = ""; }; - 2981BF10299256BB00D40C7A /* SwiftGPT */ = { - isa = PBXGroup; - children = ( - 29646505299F7E02005146FD /* Models */, - 2981BF262992583C00D40C7A /* ViewModels */, - 2981BF252992581600D40C7A /* Views */, - 2981BF11299256BB00D40C7A /* SwiftGPTApp.swift */, - 2981BF13299256BB00D40C7A /* ContentView.swift */, - 2981BF15299256BE00D40C7A /* Assets.xcassets */, - 2981BF17299256BE00D40C7A /* Preview Content */, - ); - name = SwiftGPT; - sourceTree = ""; - }; - 2981BF17299256BE00D40C7A /* Preview Content */ = { - isa = PBXGroup; - children = ( - 2981BF18299256BE00D40C7A /* Preview Assets.xcassets */, - ); - name = "Preview Content"; - path = "SwiftGPT/Preview Content"; - sourceTree = ""; - }; - 2981BF252992581600D40C7A /* Views */ = { - isa = PBXGroup; - children = ( - 6E41A5522DA03CEF0061EDAA /* MessageInputArea.swift */, - 2981BF272992584B00D40C7A /* Messages */, - 2981BF2A2992589300D40C7A /* DalleView.swift */, - 2917754F29B6083800EEC302 /* ChatGPTView.swift */, - ); - name = Views; - path = SwiftGPT/Views; - sourceTree = ""; - }; - 2981BF262992583C00D40C7A /* ViewModels */ = { - isa = PBXGroup; - children = ( - 2981BF342992964000D40C7A /* DalleViewModel.swift */, - 2917754A29B4963800EEC302 /* GPT3ViewModel.swift */, - ); - name = ViewModels; - path = SwiftGPT/ViewModels; - sourceTree = ""; - }; - 2981BF272992584B00D40C7A /* Messages */ = { - isa = PBXGroup; - children = ( - 2981BF2C299258D200D40C7A /* MessageView.swift */, - 2981BF2E299258F800D40C7A /* MessageIndicatorView.swift */, - ); - path = Messages; - sourceTree = ""; - }; - 6E41A5422D9CF1090061EDAA /* Localizables */ = { - isa = PBXGroup; - children = ( - 6E41A53F2D9CF1090061EDAA /* Localizable.strings */, - 6E41A5402D9CF1090061EDAA /* Strings+Generated.swift */, - 6E41A5412D9CF1090061EDAA /* swiftgen.yml */, - ); - path = Localizables; - sourceTree = ""; - }; - 6E41A5432D9CF1090061EDAA /* Resources */ = { - isa = PBXGroup; - children = ( - 6E41A5422D9CF1090061EDAA /* Localizables */, - ); - path = Resources; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -175,7 +92,10 @@ buildRules = ( ); dependencies = ( - 6E44C8032D9AF43900268862 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 6E4ADDE12E8BE88300AD8FA5 /* SwiftGPT */, + 6E4ADE002E8BE91A00AD8FA5 /* Resources */, ); name = SwiftGPT; packageProductDependencies = ( @@ -188,6 +108,30 @@ productReference = 2981BF0E299256BB00D40C7A /* SwiftGPT.app */; productType = "com.apple.product-type.application"; }; + 6E023C502E8BEB6D004E7990 /* SwiftGPTTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6E023C572E8BEB6D004E7990 /* Build configuration list for PBXNativeTarget "SwiftGPTTests" */; + buildPhases = ( + 6E023C4D2E8BEB6D004E7990 /* Sources */, + 6E023C4E2E8BEB6D004E7990 /* Frameworks */, + 6E023C4F2E8BEB6D004E7990 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 6E023C562E8BEB6D004E7990 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 6E023C522E8BEB6D004E7990 /* SwiftGPTTests */, + 6E4ADDC32E8BE87A00AD8FA5 /* SwiftGPTTests */, + ); + name = SwiftGPTTests; + packageProductDependencies = ( + ); + productName = SwiftGPTTests; + productReference = 6E023C512E8BEB6D004E7990 /* SwiftGPTTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -195,12 +139,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1620; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; TargetAttributes = { 2981BF0D299256BB00D40C7A = { CreatedOnToolsVersion = 14.2; }; + 6E023C502E8BEB6D004E7990 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 2981BF0D299256BB00D40C7A; + }; }; }; buildConfigurationList = 2981BF09299256BB00D40C7A /* Build configuration list for PBXProject "SwiftGPT" */; @@ -218,13 +166,13 @@ 2917755129B60B2100EEC302 /* XCRemoteSwiftPackageReference "ChatGPTSwift" */, 6E4DAC9C2D930F87003FA11C /* XCRemoteSwiftPackageReference "SwiftLint" */, 6E44C7FE2D93121500268862 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, - 6E44C8012D9AF41F00268862 /* XCRemoteSwiftPackageReference "SecretsManager" */, ); productRefGroup = 2981BF0F299256BB00D40C7A /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 2981BF0D299256BB00D40C7A /* SwiftGPT */, + 6E023C502E8BEB6D004E7990 /* SwiftGPTTests */, ); }; /* End PBXProject section */ @@ -234,10 +182,13 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6E41A5442D9CF1090061EDAA /* swiftgen.yml in Resources */, - 6E41A5452D9CF1090061EDAA /* Localizable.strings in Resources */, - 2981BF19299256BE00D40C7A /* Preview Assets.xcassets in Resources */, - 2981BF16299256BE00D40C7A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6E023C4F2E8BEB6D004E7990 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -271,26 +222,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6E41A5462D9CF1090061EDAA /* Strings+Generated.swift in Sources */, - 6E41A5532DA03CEF0061EDAA /* MessageInputArea.swift in Sources */, - 6E44C80E2D9CEEC800268862 /* MessageIndicatorView.swift in Sources */, - 2917754B29B4963800EEC302 /* GPT3ViewModel.swift in Sources */, - 29646507299F7E12005146FD /* Message.swift in Sources */, - 2981BF14299256BB00D40C7A /* ContentView.swift in Sources */, - 2981BF12299256BB00D40C7A /* SwiftGPTApp.swift in Sources */, - 2917755029B6083800EEC302 /* ChatGPTView.swift in Sources */, - 2981BF352992964000D40C7A /* DalleViewModel.swift in Sources */, - 2981BF2B2992589300D40C7A /* DalleView.swift in Sources */, - 2981BF2D299258D200D40C7A /* MessageView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6E023C4D2E8BEB6D004E7990 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 6E44C8032D9AF43900268862 /* PBXTargetDependency */ = { + 6E023C562E8BEB6D004E7990 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 6E44C8022D9AF43900268862 /* SecretsManagerPlugin */; + target = 2981BF0D299256BB00D40C7A /* SwiftGPT */; + targetProxy = 6E023C555E8BEB6D004E7990 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -330,6 +278,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TU72F4GFFV; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -393,6 +342,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = TU72F4GFFV; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -424,7 +374,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SwiftGPT/Preview Content\""; - DEVELOPMENT_TEAM = TU72F4GFFV; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -458,7 +407,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SwiftGPT/Preview Content\""; - DEVELOPMENT_TEAM = TU72F4GFFV; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -483,6 +431,49 @@ }; name = Release; }; + 6E023C582E8BEB6D004E7990 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TU72F4GFFV; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = codecake.SwiftGPTTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftGPT.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SwiftGPT"; + }; + name = Debug; + }; + 6E023C592E8BEB6D004E7990 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = TU72F4GFFV; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = codecake.SwiftGPTTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftGPT.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SwiftGPT"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -504,6 +495,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 6E023C572E8BEB6D004E7990 /* Build configuration list for PBXNativeTarget "SwiftGPTTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6E023C582E8BEB6D004E7990 /* Debug */, + 6E023C592E8BEB6D004E7990 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -539,14 +539,6 @@ minimumVersion = 6.2.0; }; }; - 6E44C8012D9AF41F00268862 /* XCRemoteSwiftPackageReference "SecretsManager" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/vdka/SecretsManager"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.4; - }; - }; 6E4DAC9C2D930F87003FA11C /* XCRemoteSwiftPackageReference "SwiftLint" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/realm/SwiftLint"; @@ -578,11 +570,6 @@ package = 6E44C7FE2D93121500268862 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; productName = SFSafeSymbols; }; - 6E44C8022D9AF43900268862 /* SecretsManagerPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = 6E44C8012D9AF41F00268862 /* XCRemoteSwiftPackageReference "SecretsManager" */; - productName = "plugin:SecretsManagerPlugin"; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2981BF06299256BB00D40C7A /* Project object */; diff --git a/SwiftGPT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftGPT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3170ea5..eaf6c25 100644 --- a/SwiftGPT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftGPT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a6e364ebc4829abea796d27747f3ce84c58e9ac60430b158db6be09309b1338a", + "originHash" : "5ad93eb81a254a241593b31b01bac2c4853f3487dd95f71d56484b3a0def8bb9", "pins" : [ { "identity" : "async-http-client", @@ -64,15 +64,6 @@ "revision" : "9b93156e81491ac53967348e8a5076d323f37e34" } }, - { - "identity" : "secretsmanager", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vdka/SecretsManager", - "state" : { - "revision" : "66af2ab2e88b2d68a2385734fa86c87c3236d44f", - "version" : "1.1.4" - } - }, { "identity" : "sfsafesymbols", "kind" : "remoteSourceControl", diff --git a/SwiftGPT/ContentView.swift b/SwiftGPT/App/ContentView.swift similarity index 78% rename from SwiftGPT/ContentView.swift rename to SwiftGPT/App/ContentView.swift index d93f871..978b769 100644 --- a/SwiftGPT/ContentView.swift +++ b/SwiftGPT/App/ContentView.swift @@ -8,15 +8,18 @@ import SwiftUI struct ContentView: View { - + var body: some View { TabView { ChatGPTView().tabItem { Label(L10n.Chatgpt.Tab.title, systemSymbol: .ellipsisBubble) } + .accessibilityLabel(L10n.Accessibility.Tab.chatgpt) + DalleView().tabItem { Label(L10n.Dalle.Tab.title, systemSymbol: .paintbrush) } + .accessibilityLabel(L10n.Accessibility.Tab.dalle) } } } diff --git a/SwiftGPT/SwiftGPTApp.swift b/SwiftGPT/App/SwiftGPTApp.swift similarity index 100% rename from SwiftGPT/SwiftGPTApp.swift rename to SwiftGPT/App/SwiftGPTApp.swift diff --git a/SwiftGPT/Utils/Config/Config.swift b/SwiftGPT/Utils/Config/Config.swift new file mode 100644 index 0000000..ff4d612 --- /dev/null +++ b/SwiftGPT/Utils/Config/Config.swift @@ -0,0 +1,48 @@ +// +// Config.swift +// SwiftGPT +// +// Application configuration constants +// + +import Foundation + +enum Config { + // MARK: - Messages + enum Messages { + /// Maximum number of messages to keep in memory + static let maxMessages = 100 + } + + // MARK: - DALL-E + enum Dalle { + /// Default image resolution for DALL-E generations + /// Use .small (256x256), .medium (512x512), or .large (1024x1024) + static let imageResolutionString = "512x512" + + /// Response format for DALL-E API + /// Use "url" or "b64_json" + static let responseFormatString = "b64_json" + } + + // MARK: - User Interface + enum UserInterface { + /// Maximum lines for text input field + static let textFieldMaxLines = 3 + + /// Avatar icon size + static let avatarSize: CGFloat = .appIconLG + + /// Input area corner radius + static let inputCornerRadius: CGFloat = .appCornerRadiusMD + + /// Generated image corner radius + static let imageCornerRadius: CGFloat = .appCornerRadiusLG + } + + // MARK: - API + enum API { + /// OpenAI organization ID + static let organizationId = "Personal" + } +} diff --git a/SwiftGPT/Utils/Extensions/Shadow+Extension.swift b/SwiftGPT/Utils/Extensions/Shadow+Extension.swift new file mode 100644 index 0000000..e07fb1c --- /dev/null +++ b/SwiftGPT/Utils/Extensions/Shadow+Extension.swift @@ -0,0 +1,27 @@ +// +// Shadow+Extension.swift +// SwiftGPT +// +// Design System - Shadows +// + +import SwiftUI + +extension View { + // MARK: - Shadow Modifiers + func appShadowSM() -> some View { + shadow(color: .black.opacity(0.1), radius: 0.5, x: 0, y: 1) + } + + func appShadowMD() -> some View { + shadow(color: .black.opacity(0.15), radius: 2, x: 0, y: 2) + } + + func appShadowLG() -> some View { + shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 4) + } + + func appShadowColored(_ color: Color, radius: CGFloat = 1) -> some View { + shadow(color: color, radius: radius) + } +} diff --git a/SwiftGPT/Utils/Extensions/Spacing+Extension.swift b/SwiftGPT/Utils/Extensions/Spacing+Extension.swift new file mode 100644 index 0000000..7b8d38c --- /dev/null +++ b/SwiftGPT/Utils/Extensions/Spacing+Extension.swift @@ -0,0 +1,35 @@ +// +// Spacing+Extension.swift +// SwiftGPT +// +// Design System - Spacing +// + +import SwiftUI + +extension CGFloat { + // MARK: - Spacing Scale + static let appSpacingXS: CGFloat = 4 + static let appSpacingSM: CGFloat = 8 + static let appSpacingMD: CGFloat = 12 + static let appSpacingLG: CGFloat = 16 + static let appSpacingXL: CGFloat = 20 + static let appSpacing2XL: CGFloat = 24 + static let appSpacing3XL: CGFloat = 32 + + // MARK: - Corner Radius Scale + static let appCornerRadiusXS: CGFloat = 4 + static let appCornerRadiusSM: CGFloat = 8 + static let appCornerRadiusMD: CGFloat = 12 + static let appCornerRadiusLG: CGFloat = 13 + static let appCornerRadiusXL: CGFloat = 16 + static let appCornerRadius2XL: CGFloat = 20 + static let appCornerRadiusFull: CGFloat = 25 + + // MARK: - Icon Sizes + static let appIconXS: CGFloat = 16 + static let appIconSM: CGFloat = 20 + static let appIconMD: CGFloat = 24 + static let appIconLG: CGFloat = 30 + static let appIconXL: CGFloat = 40 +} diff --git a/SwiftGPT/ViewModels/DalleViewModel.swift b/SwiftGPT/ViewModels/DalleViewModel.swift index 25ce8d5..7cb3834 100644 --- a/SwiftGPT/ViewModels/DalleViewModel.swift +++ b/SwiftGPT/ViewModels/DalleViewModel.swift @@ -9,13 +9,15 @@ import Foundation import OpenAIKit final class DalleViewModel: ObservableObject { + private var openAI: OpenAI @Published var messages = [Message]() @Published var typingMessage: String = "" + @Published var isLoading: Bool = false let bottomID = UUID() - init() { - openAI = OpenAI(Configuration(organizationId: "Personal", apiKey: Secrets.openaiApiKey)) + init(openAI: OpenAI = OpenAI(Configuration(organizationId: Config.API.organizationId, apiKey: Secrets.openaiApiKey))) { + self.openAI = openAI } func sendMessage() { @@ -30,8 +32,9 @@ final class DalleViewModel: ObservableObject { } func generateImage(prompt: String) async { - self.addMessage(.text(prompt), isUserMessage: true) - self.addMessage(.indicator, isUserMessage: false) + await MainActor.run { isLoading = true } + await addMessage(.text(prompt), isUserMessage: true) + await addMessage(.indicator, isUserMessage: false) let imageParam = ImageParameters(prompt: prompt, resolution: .medium, responseFormat: .base64Json) @@ -40,37 +43,38 @@ final class DalleViewModel: ObservableObject { let b64Image = result.data[0].image let output = try openAI.decodeBase64Image(b64Image) if let imageData = output.pngData() { - self.addMessage(.image(imageData), isUserMessage: false) + await addMessage(.image(imageData), isUserMessage: false) } else { - self.addMessage(.error(L10n.Dalle.Error.imageConversion), isUserMessage: false) + await addMessage(.error(L10n.Dalle.Error.imageConversion), isUserMessage: false) } } catch { debugPrint(error) - self.addMessage(.error(error.localizedDescription), isUserMessage: false) + await addMessage(.error(error.localizedDescription), isUserMessage: false) } + + await MainActor.run { isLoading = false } } + @MainActor private func addMessage(_ content: MessageContent, isUserMessage: Bool) { - DispatchQueue.main.async { - // if messages list is empty just add new message - guard let lastMessage = self.messages.last else { - let message = Message(content: content, isUserMessage: isUserMessage) - self.messages.append(message) - return - } + // if messages list is empty just add new message + guard let lastMessage = self.messages.last else { let message = Message(content: content, isUserMessage: isUserMessage) + self.messages.append(message) + return + } + let message = Message(content: content, isUserMessage: isUserMessage) - // if last message is an indicator switch with new one - if case .indicator = lastMessage.content, !lastMessage.isUserMessage { - self.messages[self.messages.count - 1] = message - } else { - // otherwise, add new message to the end of the list - self.messages.append(message) - } + // if last message is an indicator switch with new one + if case .indicator = lastMessage.content, !lastMessage.isUserMessage { + self.messages[self.messages.count - 1] = message + } else { + // otherwise, add new message to the end of the list + self.messages.append(message) + } - if self.messages.count > 100 { - self.messages.removeFirst() - } + if self.messages.count > Config.Messages.maxMessages { + self.messages.removeFirst() } } } diff --git a/SwiftGPT/ViewModels/GPT3ViewModel.swift b/SwiftGPT/ViewModels/GPT3ViewModel.swift deleted file mode 100644 index a970de7..0000000 --- a/SwiftGPT/ViewModels/GPT3ViewModel.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// GPTViewModel.swift -// SwiftGPT -// -// Created by mbabicz on 05/03/2023. -// - -import Foundation -import ChatGPTSwift - -final class GPTViewModel: ObservableObject { - - private let api: ChatGPTAPI - @Published var messages: [Message] = [] - @Published var typingMessage: String = "" - let bottomID = UUID() - - init() { - self.api = ChatGPTAPI(apiKey: Secrets.openaiApiKey) - } - - func sendMessage() { - guard !typingMessage.isEmpty else { return } - let tempMessage = typingMessage - typingMessage = "" - Task { - await getResponse(text: tempMessage) - } - } - - private func addMessage(_ content: MessageContent, isUserMessage: Bool) { - DispatchQueue.main.async { - /// if messages list is empty just add new message - guard let lastMessage = self.messages.last else { - let message = Message(content: content, isUserMessage: isUserMessage) - self.messages.append(message) - return - } - let message = Message(content: content, isUserMessage: isUserMessage) - - /// if last message is an indicator switch with new one - if case .indicator = lastMessage.content, !lastMessage.isUserMessage { - self.messages[self.messages.count - 1] = message - } else { - /// otherwise, add new message to the end of the list - self.messages.append(message) - } - - if self.messages.count > 100 { - self.messages.removeFirst() - } - } - } - - func getResponse(text: String) async { - self.addMessage(.text(text), isUserMessage: true) - self.addMessage(.indicator, isUserMessage: false) - - do { - let stream = try await api.sendMessageStream(text: text) - for try await line in stream { - DispatchQueue.main.async { - if case .text(let currentText) = self.messages[self.messages.count - 1].content { - self.messages[self.messages.count - 1].content = .text(currentText + line) - } - } - } - } catch { - self.addMessage(.error(error.localizedDescription), isUserMessage: false) - } - } -} diff --git a/SwiftGPT/ViewModels/GPTViewModel.swift b/SwiftGPT/ViewModels/GPTViewModel.swift new file mode 100644 index 0000000..9a6ae02 --- /dev/null +++ b/SwiftGPT/ViewModels/GPTViewModel.swift @@ -0,0 +1,75 @@ +// +// GPTViewModel.swift +// SwiftGPT +// +// Created by mbabicz on 05/03/2023. +// + +import Foundation +import ChatGPTSwift + +final class GPTViewModel: ObservableObject { + + private let api: ChatGPTAPI + @Published var messages: [Message] = [] + @Published var typingMessage: String = "" + @Published var isLoading: Bool = false + let bottomID = UUID() + + init(api: ChatGPTAPI = ChatGPTAPI(apiKey: Secrets.openaiApiKey)) { + self.api = api + } + + func sendMessage() { + guard !typingMessage.isEmpty else { return } + let tempMessage = typingMessage + typingMessage = "" + Task { + await getResponse(text: tempMessage) + } + } + + @MainActor + private func addMessage(_ content: MessageContent, isUserMessage: Bool) { + /// if messages list is empty just add new message + guard let lastMessage = self.messages.last else { + let message = Message(content: content, isUserMessage: isUserMessage) + self.messages.append(message) + return + } + let message = Message(content: content, isUserMessage: isUserMessage) + + /// if last message is an indicator switch with new one + if case .indicator = lastMessage.content, !lastMessage.isUserMessage { + self.messages[self.messages.count - 1] = message + } else { + /// otherwise, add new message to the end of the list + self.messages.append(message) + } + + if self.messages.count > Config.Messages.maxMessages { + self.messages.removeFirst() + } + } + + func getResponse(text: String) async { + await MainActor.run { isLoading = true } + await addMessage(.text(text), isUserMessage: true) + await addMessage(.indicator, isUserMessage: false) + + do { + let stream = try await api.sendMessageStream(text: text) + for try await line in stream { + await MainActor.run { + guard let lastIndex = self.messages.indices.last, + case .text(let currentText) = self.messages[lastIndex].content else { return } + self.messages[lastIndex].content = .text(currentText + line) + } + } + } catch { + await addMessage(.error(error.localizedDescription), isUserMessage: false) + } + + await MainActor.run { isLoading = false } + } +} diff --git a/SwiftGPT/Views/ChatGPTView.swift b/SwiftGPT/Views/ChatGPTView.swift index 193ea9b..756ba0b 100644 --- a/SwiftGPT/Views/ChatGPTView.swift +++ b/SwiftGPT/Views/ChatGPTView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ChatGPTView: View { - @StateObject var viewModel = GPTViewModel() + @StateObject private var viewModel = GPTViewModel() @FocusState private var isFocused: Bool var body: some View { @@ -19,6 +19,7 @@ struct ChatGPTView: View { text: $viewModel.typingMessage, placeholder: L10n.Message.Textfield.placeholder, onSend: viewModel.sendMessage, + isSendEnabled: !viewModel.isLoading && !viewModel.typingMessage.isEmpty, isFocusedBinding: $isFocused ) } @@ -61,7 +62,7 @@ struct ChatGPTView: View { .font(.largeTitle) Text(L10n.Chat.Introduce.title) .font(.subheadline) - .padding(10) + .padding(.appSpacingSM) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/SwiftGPT/Views/DalleView.swift b/SwiftGPT/Views/DalleView.swift index 7352607..8bb6762 100644 --- a/SwiftGPT/Views/DalleView.swift +++ b/SwiftGPT/Views/DalleView.swift @@ -8,7 +8,7 @@ import SwiftUI struct DalleView: View { - @StateObject var viewModel = DalleViewModel() + @StateObject private var viewModel = DalleViewModel() @FocusState private var isFocused: Bool var body: some View { @@ -19,6 +19,7 @@ struct DalleView: View { text: $viewModel.typingMessage, placeholder: L10n.Message.Textfield.placeholder, onSend: viewModel.sendMessage, + isSendEnabled: !viewModel.isLoading && !viewModel.typingMessage.isEmpty, isFocusedBinding: $isFocused ) } @@ -43,8 +44,8 @@ struct DalleView: View { private var messagesScrollView: some View { ScrollViewReader { reader in ScrollView { - ForEach(viewModel.messages.indices, id: \.self) { index in - MessageView(message: viewModel.messages[index]) + ForEach(viewModel.messages) { message in + MessageView(message: message) } Text("").id(viewModel.bottomID) } @@ -63,7 +64,7 @@ struct DalleView: View { .font(.largeTitle) Text(L10n.Chat.Introduce.title) .font(.subheadline) - .padding(10) + .padding(.appSpacingSM) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/SwiftGPT/Views/MessageInputArea.swift b/SwiftGPT/Views/MessageInputArea.swift index 393af6c..f59a46a 100644 --- a/SwiftGPT/Views/MessageInputArea.swift +++ b/SwiftGPT/Views/MessageInputArea.swift @@ -13,34 +13,37 @@ struct MessageInputArea: View { var onSend: () -> Void var isSendEnabled: Bool = true var isFocusedBinding: FocusState.Binding - + var body: some View { HStack(alignment: .center) { TextField(placeholder, text: $text, axis: .vertical) .focused(isFocusedBinding) - .padding() + .padding(.appSpacingMD) .foregroundStyle(.white) - .lineLimit(3) + .lineLimit(Config.UserInterface.textFieldMaxLines) .disableAutocorrection(true) .autocapitalization(.none) .keyboardType(.alphabet) + .accessibilityLabel(L10n.Accessibility.Textfield.message) - Button(action: { + Button { isFocusedBinding.wrappedValue = false onSend() - }) { + } label: { Image(systemSymbol: text.isEmpty ? .circle : .paperplaneFill) .resizable() .scaledToFit() .foregroundStyle(text.isEmpty ? .white.opacity(0.75) : .white) - .frame(width: 20, height: 20) - .padding() + .frame(width: .appIconSM, height: .appIconSM) + .padding(.appSpacingMD) } .disabled(!isSendEnabled) + .accessibilityLabel(L10n.Accessibility.Button.send) + .accessibilityHint(L10n.Accessibility.Button.Send.hint) } .background(.textFieldBackground) - .cornerRadius(12) - .padding([.leading, .trailing, .bottom], 10) - .shadow(color: .black, radius: 0.5) + .cornerRadius(Config.UserInterface.inputCornerRadius) + .padding([.leading, .trailing, .bottom], .appSpacingSM) + .appShadowSM() } } diff --git a/SwiftGPT/Views/Messages/MessageIndicatorView.swift b/SwiftGPT/Views/Messages/MessageIndicatorView.swift index a23623f..7b33446 100644 --- a/SwiftGPT/Views/Messages/MessageIndicatorView.swift +++ b/SwiftGPT/Views/Messages/MessageIndicatorView.swift @@ -14,15 +14,15 @@ struct MessageIndicatorView: View { DotView(delay: 0.2) DotView(delay: 0.4) } - .padding(12) + .padding(.appSpacingMD) .background(Color.gray.opacity(0.25)) - .cornerRadius(25) + .cornerRadius(.appCornerRadiusFull) } } struct DotView: View { - @State var scale: CGFloat = 0.5 - @State var delay: Double = 0 + @State private var scale: CGFloat = 0.5 + var delay: Double = 0 var body: some View { Circle() @@ -36,8 +36,6 @@ struct DotView: View { } } -struct MessageIndicatorView_Previews: PreviewProvider { - static var previews: some View { - MessageIndicatorView() - } +#Preview { + MessageIndicatorView() } diff --git a/SwiftGPT/Views/Messages/MessageView.swift b/SwiftGPT/Views/Messages/MessageView.swift index c039867..2852220 100644 --- a/SwiftGPT/Views/Messages/MessageView.swift +++ b/SwiftGPT/Views/Messages/MessageView.swift @@ -10,6 +10,8 @@ import PhotosUI struct MessageView: View { var message: Message + @State private var showErrorAlert = false + @State private var errorMessage = "" var body: some View { HStack(spacing: 0) { @@ -17,17 +19,16 @@ struct MessageView: View { HStack(alignment: message.isUserMessage ? .center : .top) { Image(message.isUserMessage ? .personIcon : .gptLogo) .resizable() - .frame(width: 30, height: 30) - .padding(.trailing, 10) + .frame(width: Config.UserInterface.avatarSize, height: Config.UserInterface.avatarSize) + .padding(.trailing, .appSpacingSM) + .accessibilityLabel(message.isUserMessage ? L10n.Accessibility.Image.user : L10n.Accessibility.Image.gpt) switch message.content { case let .text(output): - Text(output.trimmingCharacters(in: .whitespacesAndNewlines)) Text(output) .foregroundStyle(.white) .textSelection(.enabled) case let .error(output): - Text(output.trimmingCharacters(in: .whitespacesAndNewlines)) Text(output) .foregroundStyle(.red) .textSelection(.enabled) @@ -36,51 +37,46 @@ struct MessageView: View { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) - .cornerRadius(13) + .cornerRadius(Config.UserInterface.imageCornerRadius) .shadow(color: .green, radius: 1) - VStack { - Button(action: { - let avc = UIActivityViewController(activityItems: [uiImage], applicationActivities: nil) + .accessibilityLabel(L10n.Accessibility.Image.generated) + .contextMenu { + ShareLink(item: Image(uiImage: uiImage), preview: SharePreview("Generated Image")) + .accessibilityLabel(L10n.Accessibility.Button.share) - avc.completionWithItemsHandler = { (activityType, completed, _, _) in - if completed && activityType == .saveToCameraRoll { - UIImageWriteToSavedPhotosAlbum(uiImage, nil, nil, nil) + Button { + Task { + do { + try await saveImageToLibrary(uiImage) + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + showErrorAlert = true + } + } } + } label: { + Label("Save to Photos", systemSymbol: .squareAndArrowDown) } - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }), - let rootViewController = window.rootViewController { - rootViewController.present(avc, animated: true, completion: nil) - } - }) { - Image(systemSymbol: .squareAndArrowUp) - .foregroundStyle(.white) - } - .padding() - - Button(action: { - Task { - try await saveImageToLibrary(uiImage) - } - }) { - Image(systemSymbol: .squareAndArrowDown) - .foregroundStyle(.white) + .accessibilityLabel(L10n.Accessibility.Button.save) } - .padding() - } } case .indicator: MessageIndicatorView() } } - .padding([.top, .bottom]) - .padding(.leading, 10) + .padding([.top, .bottom], .appSpacingMD) + .padding(.leading, .appSpacingSM) } Spacer() } .background(message.isUserMessage ? Color(.userMessageBackground) : Color(.responseMessageBackground)) .shadow( radius: message.isUserMessage ? 0 : 0.5) - + .alert("Error", isPresented: $showErrorAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage) + } } func saveImageToLibrary(_ image: UIImage) async throws { diff --git a/SwiftGPTTests/DalleViewModelTests.swift b/SwiftGPTTests/DalleViewModelTests.swift new file mode 100644 index 0000000..343c1ce --- /dev/null +++ b/SwiftGPTTests/DalleViewModelTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import SwiftGPT + +@Suite("DALL-E ViewModel Tests") +struct DalleViewModelTests { + + @Test("Initial state is empty") + func initialState() { + let viewModel = DalleViewModel() + + #expect(viewModel.messages.isEmpty) + #expect(viewModel.typingMessage.isEmpty) + #expect(viewModel.isLoading == false) + } + + @Test("Empty message not sent") + func emptyMessageNotSent() { + let viewModel = DalleViewModel() + viewModel.typingMessage = "" + + viewModel.sendMessage() + + #expect(viewModel.messages.isEmpty) + } + + @Test("Whitespace message not sent") + func whitespaceMessageNotSent() { + let viewModel = DalleViewModel() + viewModel.typingMessage = " " + + viewModel.sendMessage() + + #expect(viewModel.messages.isEmpty) + } +} diff --git a/SwiftGPTTests/GPTViewModelTests.swift b/SwiftGPTTests/GPTViewModelTests.swift new file mode 100644 index 0000000..bf2658f --- /dev/null +++ b/SwiftGPTTests/GPTViewModelTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import SwiftGPT + +@Suite("GPT ViewModel Tests") +struct GPTViewModelTests { + + @Test("Initial state is empty") + func initialState() { + let viewModel = GPTViewModel() + + #expect(viewModel.messages.isEmpty) + #expect(viewModel.typingMessage.isEmpty) + #expect(viewModel.isLoading == false) + } + + @Test("Empty message not sent") + func emptyMessageNotSent() { + let viewModel = GPTViewModel() + viewModel.typingMessage = "" + + viewModel.sendMessage() + + #expect(viewModel.messages.isEmpty) + } + + @Test("Whitespace message not sent") + func whitespaceMessageNotSent() { + let viewModel = GPTViewModel() + viewModel.typingMessage = " " + + viewModel.sendMessage() + + #expect(viewModel.messages.isEmpty) + } +} diff --git a/SwiftGPTTests/MessageTests.swift b/SwiftGPTTests/MessageTests.swift new file mode 100644 index 0000000..66ce017 --- /dev/null +++ b/SwiftGPTTests/MessageTests.swift @@ -0,0 +1,56 @@ +import Testing +import SwiftUI +@testable import SwiftGPT + +@Suite("Message Tests") +struct MessageTests { + + @Test("Message has unique ID") + func messageUniqueID() { + let message1 = Message(content: .text("Hello"), isUserMessage: true) + let message2 = Message(content: .text("Hello"), isUserMessage: true) + + #expect(message1.id != message2.id) + } + + @Test("Text message content") + func textMessageContent() { + let message = Message(content: .text("Test"), isUserMessage: false) + + if case let .text(content) = message.content { + #expect(content == "Test") + } else { + Issue.record("Expected text content") + } + } + + @Test("Error message content") + func errorMessageContent() { + let message = Message(content: .error("Error"), isUserMessage: false) + + if case let .error(content) = message.content { + #expect(content == "Error") + } else { + Issue.record("Expected error content") + } + } + + @Test("Indicator message content") + func indicatorMessageContent() { + let message = Message(content: .indicator, isUserMessage: false) + + #expect(message.content == .indicator) + } + + @Test("Image message content") + func imageMessageContent() { + let imageData = Data([0x00, 0x01, 0x02]) + let message = Message(content: .image(imageData), isUserMessage: false) + + if case let .image(data) = message.content { + #expect(data == imageData) + } else { + Issue.record("Expected image content") + } + } +}