diff --git a/.gitignore b/.gitignore index cda1d4da..677f1625 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ Resources/InfoPlist/Info.plist ### Using tests Tests/**/*.plist + +### Changelog +Changelog/next.md diff --git a/Changelog/1.7.0.md b/Changelog/1.7.0.md new file mode 100644 index 00000000..6962b9f3 --- /dev/null +++ b/Changelog/1.7.0.md @@ -0,0 +1,8 @@ +## New features +- Added to change theme feature. + +## Enhancements +- Prevent adding duplecate word. + +## Fixed +- Fixed memory leak for view controller. diff --git a/Changelog/next.md b/Changelog/next.md deleted file mode 100644 index e666edce..00000000 --- a/Changelog/next.md +++ /dev/null @@ -1,3 +0,0 @@ -## Enhancements - -## Fixed diff --git a/Project.swift b/Project.swift index f51d686f..28c76ca5 100644 --- a/Project.swift +++ b/Project.swift @@ -23,7 +23,6 @@ func targets() -> [Target] { .external(name: ExternalDependencyName.rxUtilityDynamic), .external(name: ExternalDependencyName.swinject), .external(name: ExternalDependencyName.swinjectExtension), - .external(name: ExternalDependencyName.then), ], hasTests: true, additionalTestDependencies: [ @@ -42,11 +41,6 @@ func targets() -> [Target] { ], appendSchemeTo: &disposedSchemes ) - + Target.module( - name: "FoundationExtension", - hasTests: true, - appendSchemeTo: &schemes - ) + Target.module( name: "Utility", scripts: [ @@ -72,7 +66,6 @@ func targets() -> [Target] { .package(product: ExternalDependencyName.googleSignIn), .external(name: ExternalDependencyName.rxSwift), .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.extendedUserDefaults), .external(name: ExternalDependencyName.extendedUserDefaultsRxExtension), .external(name: ExternalDependencyName.swinject), .external(name: ExternalDependencyName.swinjectExtension), @@ -91,37 +84,32 @@ func targets() -> [Target] { appendSchemeTo: &disposedSchemes ) + Target.module( - name: "iOSSupport", + name: "IOSSupport", resourceOptions: [.own], dependencies: [ .target(name: "Domain"), .target(name: "Utility"), + .external(name: ExternalDependencyName.foundationPlus), .external(name: ExternalDependencyName.rxSwift), .external(name: ExternalDependencyName.rxCocoa), .external(name: ExternalDependencyName.rxUtilityDynamic), .external(name: ExternalDependencyName.reactorKit), .external(name: ExternalDependencyName.snapKit), .external(name: ExternalDependencyName.then), + .external(name: ExternalDependencyName.toast), + .external(name: ExternalDependencyName.sfSafeSymbols), + .external(name: ExternalDependencyName.swinject), + .external(name: ExternalDependencyName.swinjectExtension), + .package(product: ExternalDependencyName.swiftCollections), ], - appendSchemeTo: &disposedSchemes + appendSchemeTo: &schemes ) + Target.module( name: "WordChecking", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "Domain"), - .target(name: "iOSSupport"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.snapKit), - .external(name: ExternalDependencyName.then), - .external(name: ExternalDependencyName.toast), - .external(name: ExternalDependencyName.sfSafeSymbols), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -135,7 +123,7 @@ func targets() -> [Target] { name: "WordCheckingExample", product: .app, infoPlist: .file(path: "Resources/InfoPlist/InfoExample.plist"), - sourcesPrefix: "iOSScenes", + sourcesPrefix: "IOSScenes", dependencies: [ .target(name: "WordChecking"), .target(name: "DomainTesting"), @@ -144,22 +132,10 @@ func targets() -> [Target] { ) + Target.module( name: "WordList", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "Domain"), - .target(name: "iOSSupport"), - .target(name: "WordDetail"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.snapKit), - .external(name: ExternalDependencyName.then), - .external(name: ExternalDependencyName.toast), - .external(name: ExternalDependencyName.sfSafeSymbols), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -171,20 +147,10 @@ func targets() -> [Target] { ) + Target.module( name: "WordDetail", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "Domain"), - .target(name: "iOSSupport"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.snapKit), - .external(name: ExternalDependencyName.then), - .external(name: ExternalDependencyName.toast), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -196,21 +162,10 @@ func targets() -> [Target] { ) + Target.module( name: "WordAddition", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "Domain"), - .target(name: "iOSSupport"), - .target(name: "FoundationExtension"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.snapKit), - .external(name: ExternalDependencyName.then), - .external(name: ExternalDependencyName.toast), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -222,20 +177,10 @@ func targets() -> [Target] { ) + Target.module( name: "UserSettings", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "Domain"), - .target(name: "iOSSupport"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.snapKit), - .external(name: ExternalDependencyName.then), - .external(name: ExternalDependencyName.toast), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -250,7 +195,7 @@ func targets() -> [Target] { name: "UserSettingsExample", product: .app, infoPlist: .file(path: "Resources/InfoPlist/InfoExample.plist"), - sourcesPrefix: "iOSScenes", + sourcesPrefix: "IOSScenes", dependencies: [ .target(name: "UserSettings"), .target(name: "DomainTesting"), @@ -259,22 +204,10 @@ func targets() -> [Target] { ) + Target.module( name: "LanguageSetting", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "Domain"), - .target(name: "iOSSupport"), - .target(name: "FoundationExtension"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.snapKit), - .external(name: ExternalDependencyName.then), - .external(name: ExternalDependencyName.toast), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), - .package(product: ExternalDependencyName.swiftCollections), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -284,18 +217,26 @@ func targets() -> [Target] { ], appendSchemeTo: &schemes ) + + Target.module( + name: "ThemeSetting", + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], + dependencies: [ + .target(name: "IOSSupport"), + ], + hasTests: true, + additionalTestDependencies: [ + .target(name: "DomainTesting"), + .external(name: ExternalDependencyName.rxBlocking), + ], + appendSchemeTo: &schemes + ) + Target.module( name: "PushNotificationSettings", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "iOSSupport"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -308,7 +249,7 @@ func targets() -> [Target] { name: "PushNotificationSettingsExample", product: .app, infoPlist: .file(path: "Resources/InfoPlist/InfoExample.plist"), - sourcesPrefix: "iOSScenes", + sourcesPrefix: "IOSScenes", dependencies: [ .target(name: "PushNotificationSettings"), .target(name: "DomainTesting"), @@ -317,16 +258,10 @@ func targets() -> [Target] { ) + Target.module( name: "GeneralSettings", - sourcesPrefix: "iOSScenes", - resourceOptions: [.additional("Resources/iOSSupport/**")], + sourcesPrefix: "IOSScenes", + resourceOptions: [.additional("Resources/IOSSupport/**")], dependencies: [ - .target(name: "iOSSupport"), - .external(name: ExternalDependencyName.rxSwift), - .external(name: ExternalDependencyName.rxCocoa), - .external(name: ExternalDependencyName.rxUtilityDynamic), - .external(name: ExternalDependencyName.reactorKit), - .external(name: ExternalDependencyName.swinject), - .external(name: ExternalDependencyName.swinjectExtension), + .target(name: "IOSSupport"), ], hasTests: true, additionalTestDependencies: [ @@ -336,9 +271,9 @@ func targets() -> [Target] { appendSchemeTo: &schemes ) + Target.module( - name: "iPhoneDriver", + name: "IPhoneDriver", dependencies: [ - .target(name: "iOSSupport"), + .target(name: "IOSSupport"), .target(name: "WordChecking"), .target(name: "WordList"), .target(name: "WordAddition"), @@ -348,11 +283,8 @@ func targets() -> [Target] { .target(name: "PushNotificationSettings"), .target(name: "GeneralSettings"), .target(name: "Infrastructure"), - .external(name: ExternalDependencyName.swinject), + .target(name: "ThemeSetting"), .external(name: ExternalDependencyName.swinjectDIContainer), - .external(name: ExternalDependencyName.sfSafeSymbols), - .external(name: ExternalDependencyName.then), - ], appendSchemeTo: &disposedSchemes ) @@ -366,7 +298,7 @@ func targets() -> [Target] { .additional("Resources/InfoPlist/Product/**"), ], dependencies: [ - .target(name: "iPhoneDriver"), + .target(name: "IPhoneDriver"), ], settings: .settings(), appendSchemeTo: &schemes @@ -381,7 +313,7 @@ func targets() -> [Target] { .additional("Resources/InfoPlist/Dev/**"), ], dependencies: [ - .target(name: "iPhoneDriver"), + .target(name: "IPhoneDriver"), ], settings: .settings(), appendSchemeTo: &schemes @@ -406,7 +338,7 @@ func targets() -> [Target] { sources: "Tests/\(PROJECT_NAME)UITests/**", dependencies: [ .target(name: "\(PROJECT_NAME)Dev"), - .target(name: "iOSSupport"), + .target(name: "IOSSupport"), .target(name: "Utility"), .package(product: "Realm"), ] diff --git a/QA.md b/QA.md index 4e0de536..fe8a9b67 100644 --- a/QA.md +++ b/QA.md @@ -1,10 +1,10 @@ #Version -1.5.0 +1.7.0 ##Common - Localization 적용 확인 -##WordCheking +## WordCheking - 단어 추가 시 리스트에 반영 - 단어 암기 완료 시 리스트에 반영 @@ -12,20 +12,31 @@ - 단어 앞, 뒤 이동 정상 작동 - 마지막 단어 암기 완료 표시 시 '단어 없음' 문구 출력 - 마지막 단어 삭제 시 '단어 없음' 문구 출력 +- 중복 단어 추가 시 중복 단어 Toast 메세지 표시 확인 -##WordList +## WordList - 현재 단어 수정 시 암기 화면에 반영 - 현재 단어 삭제 시 암기 화면에 반영 - 현재 단어 암기 완료 표시 시 암기 화면에 반영 - 현재 단어 없을 때 단어 암기중 표시 시 화면에 반영 -##WordSearch +## WordAddition + +- 단어 추가 시 중복 단어일 때 중복 경고 메세지 표시 확인 +- 단어 추가 시 입력된 단어가 없을 때 완료 버튼 비활성화 확인 + +## WordDetail + +- 단어 편집 시 중복 단어일 때 중복 경고 메세지 표시 확인 +- 수정 사항이 존재할 때 취소 확인 ActionSheet 표시 확인 + +## WordSearch - 단어 검색 이상 여부 확인 - 단어 검색 후 수정 시 리스트&암기 화면에 바로 적용 여부 확인 -##Settings +## Settings - Source language / Translation language 변경시 번역 사이트에 정상 적용 확인 - 구글 드라이브 로그인 여부에 따라 로그아웃 버튼 표시 diff --git a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings index 301a3fc5..a4273419 100644 --- a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings @@ -38,6 +38,9 @@ all = "All"; there_are_no_words = "There are no words."; quick_add_word = "Quick add word"; "%@_added_successfully" = "[ %@ ] added successfully"; +already_added_word = "Already added word."; +"%@_added_failed" = "[ %@ ] added failed."; + settings = "Settings"; translation_language = "Translation language"; @@ -66,9 +69,16 @@ dailyReminderFooter = "Sends a daily push notification at the time you set."; general = "General"; haptics = "Haptics"; hapticsSettingsFooterTextWhenHapticsIsOn = "Enable haptics for interactions."; -hapticsSettingsFooterTextWhenHapticsIsOff = "Disable haptics for interactions"; +hapticsSettingsFooterTextWhenHapticsIsOff = "Disable haptics for interactions."; + +theme = "Theme"; +system_mode = "System mode"; +light_mode = "Light mode"; +dark_mode = "Dark mode"; more_menu = "More menu"; memorize_words = "Memorize words"; next_word = "Next word"; previous_word = "Previous word"; + +duplicate_word = "Duplicate word."; diff --git a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings index 59a2ee5a..b5966b46 100644 --- a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings @@ -38,6 +38,8 @@ all = "전체"; there_are_no_words = "단어가 없습니다."; quick_add_word = "빠른 단어 추가"; "%@_added_successfully" = "[ %@ ] 추가 성공"; +already_added_word = "이미 추가된 단어입니다."; +"%@_added_failed" = "[ %@ ] 단어 추가 실패"; settings = "설정"; languages = "언어"; @@ -68,7 +70,14 @@ haptics = "진동"; hapticsSettingsFooterTextWhenHapticsIsOn = "상호 작용에 대한 진동을 사용합니다."; hapticsSettingsFooterTextWhenHapticsIsOff = "상호 작용에 대한 진동을 사용하지 않습니다."; +theme = "테마"; +system_mode = "시스템 설정 모드"; +light_mode = "라이트 모드"; +dark_mode = "다크 모드"; + more_menu = "메뉴 더보기"; memorize_words = "단어 암기"; next_word = "다음 단어"; previous_word = "이전 단어"; + +duplicate_word = "중복 단어입니다."; diff --git a/Sources/Domain/Interfaces/Services/LocalNotificationService.swift b/Sources/Domain/Interfaces/Services/LocalNotificationService.swift index 35284aed..d2a03c10 100644 --- a/Sources/Domain/Interfaces/Services/LocalNotificationService.swift +++ b/Sources/Domain/Interfaces/Services/LocalNotificationService.swift @@ -29,6 +29,6 @@ public protocol LocalNotificationService { /// 매일 알림을 설정한 마지막 시각을 반환합니다. /// - /// /// - throws: 저장된 시각이 없을 때 Error 를 던집니다. + /// - throws: 저장된 시각이 없을 때 Error 를 던집니다. func getLatestDailyReminderTime() throws -> DateComponents } diff --git a/Sources/Domain/Interfaces/UseCases/ExternalStore/ExternalStoreUseCaseError.swift b/Sources/Domain/Interfaces/UseCases/ExternalStore/ExternalStoreUseCaseError.swift new file mode 100644 index 00000000..e5a6b8b0 --- /dev/null +++ b/Sources/Domain/Interfaces/UseCases/ExternalStore/ExternalStoreUseCaseError.swift @@ -0,0 +1,17 @@ +// +// ExternalStoreUseCaseError.swift +// Domain +// +// Created by Jaewon Yun on 2/12/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import Foundation + +public enum ExternalStoreUseCaseError: Error { + + case noCurrentUser + + case noPresentingConfiguration + +} diff --git a/Sources/Domain/Interfaces/UseCases/ExternalStoreUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/ExternalStore/ExternalStoreUseCaseProtocol.swift similarity index 100% rename from Sources/Domain/Interfaces/UseCases/ExternalStoreUseCaseProtocol.swift rename to Sources/Domain/Interfaces/UseCases/ExternalStore/ExternalStoreUseCaseProtocol.swift diff --git a/Sources/Domain/Interfaces/UseCases/Notification/NotificationsUseCaseError.swift b/Sources/Domain/Interfaces/UseCases/Notification/NotificationsUseCaseError.swift new file mode 100644 index 00000000..1a104b23 --- /dev/null +++ b/Sources/Domain/Interfaces/UseCases/Notification/NotificationsUseCaseError.swift @@ -0,0 +1,13 @@ +// +// NotificationsUseCaseError.swift +// Domain +// +// Created by Jaewon Yun on 2/12/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import Foundation + +public enum NotificationsUseCaseError: Error { + case noWordsToMemorize +} diff --git a/Sources/Domain/Interfaces/UseCases/NotificationsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/Notification/NotificationsUseCaseProtocol.swift similarity index 100% rename from Sources/Domain/Interfaces/UseCases/NotificationsUseCaseProtocol.swift rename to Sources/Domain/Interfaces/UseCases/Notification/NotificationsUseCaseProtocol.swift diff --git a/Sources/Domain/Interfaces/UseCases/UserSettings/UserSettingsUseCaseError.swift b/Sources/Domain/Interfaces/UseCases/UserSettings/UserSettingsUseCaseError.swift new file mode 100644 index 00000000..b51ebcba --- /dev/null +++ b/Sources/Domain/Interfaces/UseCases/UserSettings/UserSettingsUseCaseError.swift @@ -0,0 +1,14 @@ +// +// UserSettingsUseCaseError.swift +// Domain +// +// Created by Jaewon Yun on 2/12/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import Foundation + +public enum UserSettingsUseCaseError: Error { + case noPendingDailyReminder + case noNotificationAuthorization +} diff --git a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/UserSettings/UserSettingsUseCaseProtocol.swift similarity index 90% rename from Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift rename to Sources/Domain/Interfaces/UseCases/UserSettings/UserSettingsUseCaseProtocol.swift index 1051c888..f4e4fb8c 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettings/UserSettingsUseCaseProtocol.swift @@ -21,4 +21,6 @@ public protocol UserSettingsUseCaseProtocol { func offHaptics() -> Single + func updateThemeStyle(_ style: ThemeStyle) -> Single + } diff --git a/Sources/Domain/Interfaces/UseCases/Word/WordUseCaseError.swift b/Sources/Domain/Interfaces/UseCases/Word/WordUseCaseError.swift new file mode 100644 index 00000000..37a1d8ef --- /dev/null +++ b/Sources/Domain/Interfaces/UseCases/Word/WordUseCaseError.swift @@ -0,0 +1,41 @@ +// +// WordUseCaseError.swift +// Domain +// +// Created by Jaewon Yun on 2/12/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import Foundation + +public enum WordUseCaseError: Error { + + /// `saveFailed` 에러가 발생한 이유입니다. + public enum SaveFailureReason { + + /// 저장하려는 단어가 이미 암기 완료 상태입니다. + case wordStateInvalid + + /// 저장하려는 단어가 중복 단어입니다. + case duplicatedWord(word: String) + + } + + /// `retrieveFailed` 에러가 발생한 이유입니다. + public enum RetrieveFailureReason { + + /// 해당 UUID 와 일치하는 단어가 없습니다. + case uuidInvaild(uuid: UUID) + + } + + /// 단어 저장 실패 + case saveFailed(reason: SaveFailureReason) + + /// 단어 검색 실패 + case retrieveFailed(reason: RetrieveFailureReason) + + /// 현재 암기중인 단어가 없음 + case noMemorizingWords + +} diff --git a/Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/Word/WordUseCaseProtocol.swift similarity index 69% rename from Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift rename to Sources/Domain/Interfaces/UseCases/Word/WordUseCaseProtocol.swift index 958fe3e6..c04d121c 100644 --- a/Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/Word/WordUseCaseProtocol.swift @@ -11,6 +11,8 @@ import RxSwift public protocol WordUseCaseProtocol { /// 새 단어를 추가합니다. + /// + /// - Returns: 단어 추가에 성공하면 Next 이벤트를, 어떠한 이유로 인해 실패하면 `WordUseCaseError` 타입의 Error 이벤트를 방출하는 Sequence 를 반환합니다. func addNewWord(_ word: Word) -> Single /// 단어를 삭제합니다. @@ -42,4 +44,9 @@ public protocol WordUseCaseProtocol { func getCurrentUnmemorizedWord() -> Single + /// `word` 파라미터로 전달된 단어가 이미 저장되어 있는 단어인지 검사합니다. + /// + /// - Returns: 반환된 Sequence 는 `ture` or `false` 값을 가진 next 이벤트만 방출됩니다. error 이벤트는 방출되지 않습니다. + func isWordDuplicated(_ word: String) -> Single + } diff --git a/Sources/Domain/UseCases/ExternalStoreUseCase.swift b/Sources/Domain/UseCases/ExternalStoreUseCase.swift index bef269fd..6383662a 100644 --- a/Sources/Domain/UseCases/ExternalStoreUseCase.swift +++ b/Sources/Domain/UseCases/ExternalStoreUseCase.swift @@ -172,11 +172,3 @@ public final class ExternalStoreUseCase: ExternalStoreUseCaseProtocol { } } - -enum ExternalStoreUseCaseError: Error { - - case noCurrentUser - - case noPresentingConfiguration - -} diff --git a/Sources/Domain/UseCases/NotificationsUseCase.swift b/Sources/Domain/UseCases/NotificationsUseCase.swift index de712324..483d476c 100644 --- a/Sources/Domain/UseCases/NotificationsUseCase.swift +++ b/Sources/Domain/UseCases/NotificationsUseCase.swift @@ -7,15 +7,11 @@ // import Foundation +import FoundationPlus import RxSwift import RxUtility -import Then import UserNotifications -enum NotificationsUseCaseError: Error { - case noWordsToMemorize -} - final class NotificationsUseCase: NotificationsUseCaseProtocol { /// Notification request 의 고유 ID @@ -59,10 +55,9 @@ final class NotificationsUseCase: NotificationsUseCaseProtocol { let setDailyReminderSequence: Single = .create { observer in let unmemorizedWordCount = self.wordRepository.getUnmemorizedList().count - var content: UNMutableNotificationContent = .init().then { - $0.title = DomainString.daily_reminder - $0.sound = .default - } + var content: UNMutableNotificationContent = .init() + content.title = DomainString.daily_reminder + content.sound = .default if unmemorizedWordCount == 0 { content.body = DomainString.daily_reminder_body_message_when_no_words_to_memorize diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index 6a130187..1075d24f 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -12,11 +12,6 @@ import RxRelay import RxUtility import Utility -enum UserSettingsUseCaseError: Error { - case noPendingDailyReminder - case noNotificationAuthorization -} - public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { let userSettingsRepository: UserSettingsRepositoryProtocol @@ -100,10 +95,20 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { translationTargetLocale = .english } - let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: translationTargetLocale, hapticsIsOn: true) // FIXME: 처음에 Source Locale 설정 가능하게 (현재 .english 고정) + let initialUserSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: translationTargetLocale, hapticsIsOn: true, themeStyle: .system) // FIXME: 처음에 Source Locale 설정 가능하게 (현재 .english 고정) + + return self.userSettingsRepository.saveUserSettings(initialUserSettings) + } + } - return self.userSettingsRepository.saveUserSettings(userSettings) + public func updateThemeStyle(_ style: ThemeStyle) -> Single { + return userSettingsRepository.getUserSettings() + .map { currentSettings in + var newSettings = currentSettings + newSettings.themeStyle = style + return newSettings } + .flatMap { self.userSettingsRepository.saveUserSettings($0) } } } diff --git a/Sources/Domain/UseCases/WordUseCase.swift b/Sources/Domain/UseCases/WordUseCase.swift index b531b416..9e27a02e 100644 --- a/Sources/Domain/UseCases/WordUseCase.swift +++ b/Sources/Domain/UseCases/WordUseCase.swift @@ -23,7 +23,13 @@ public final class WordUseCase: WordUseCaseProtocol { public func addNewWord(_ word: Word) -> RxSwift.Single { return .create { single in guard word.memorizedState != .memorized else { - single(.failure(WordUseCaseError.canNotSaveWord(reason: "Can only add word with a memorization state of `.memorizing`."))) + single(.failure(WordUseCaseError.saveFailed(reason: .wordStateInvalid))) + return Disposables.create() + } + + let allWords = self.wordRepository.getAllWords() + if allWords.contains(where: { $0.word.lowercased() == word.word.lowercased() }) { + single(.failure(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: word.word)))) return Disposables.create() } @@ -85,7 +91,7 @@ public final class WordUseCase: WordUseCaseProtocol { if let word = self.wordRepository.getWord(by: uuid) { single(.success(word)) } else { - single(.failure(WordUseCaseError.invalidUUID(uuid))) + single(.failure(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid)))) } return Disposables.create() @@ -93,6 +99,15 @@ public final class WordUseCase: WordUseCaseProtocol { } public func updateWord(by uuid: UUID, to newWord: Word) -> RxSwift.Single { + guard let originWord = wordRepository.getWord(by: uuid) else { + return .error(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid))) + } + + let allWords = self.wordRepository.getAllWords() + if (originWord.word != newWord.word) && allWords.contains(where: { $0.word.lowercased() == newWord.word.lowercased() }) { + return .error(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: newWord.word))) + } + return .create { single in let updateTarget: Word = .init( uuid: uuid, @@ -151,7 +166,7 @@ public final class WordUseCase: WordUseCaseProtocol { public func markCurrentWordAsMemorized(uuid: UUID) -> RxSwift.Single { return .create { single in guard let currentWord = self.wordRepository.getWord(by: uuid) else { - single(.failure(WordUseCaseError.invalidUUID(uuid))) + single(.failure(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid)))) return Disposables.create() } @@ -174,17 +189,13 @@ public final class WordUseCase: WordUseCaseProtocol { return .just(currentWord) } -} - -enum WordUseCaseError: Error { - - /// 해당되는 단어가 없는 UUID - case invalidUUID(UUID) - - /// 단어를 저장할 수 없음 - case canNotSaveWord(reason: String) - - /// 현재 암기중인 단어가 없음 - case noMemorizingWords + public func isWordDuplicated(_ word: String) -> Single { + let allWords = self.wordRepository.getAllWords() + if allWords.contains(where: { $0.word.lowercased() == word.lowercased() }) { + return .just(true) + } else { + return .just(false) + } + } } diff --git a/Sources/Domain/ValueObjects/ThemeStyle.swift b/Sources/Domain/ValueObjects/ThemeStyle.swift new file mode 100644 index 00000000..2306231c --- /dev/null +++ b/Sources/Domain/ValueObjects/ThemeStyle.swift @@ -0,0 +1,21 @@ +// +// ThemeStyle.swift +// Domain +// +// Created by Jaewon Yun on 2/7/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +/// 적용 가능한 테마 스타일. +public enum ThemeStyle: Codable { + + /// 시스템 설정을 따르는 스타일 + case system + + /// Light 스타일 + case light + + /// Dark 스타일 + case dark + +} diff --git a/Sources/Domain/ValueObjects/UserSettings.swift b/Sources/Domain/ValueObjects/UserSettings.swift index 206526ee..3db6ed81 100644 --- a/Sources/Domain/ValueObjects/UserSettings.swift +++ b/Sources/Domain/ValueObjects/UserSettings.swift @@ -19,10 +19,14 @@ public struct UserSettings { /// 진동 사용 여부 public var hapticsIsOn: Bool - public init(translationSourceLocale: TranslationLanguage, translationTargetLocale: TranslationLanguage, hapticsIsOn: Bool) { + /// 테마 스타일 + public var themeStyle: ThemeStyle + + public init(translationSourceLocale: TranslationLanguage, translationTargetLocale: TranslationLanguage, hapticsIsOn: Bool, themeStyle: ThemeStyle) { self.translationSourceLocale = translationSourceLocale self.translationTargetLocale = translationTargetLocale self.hapticsIsOn = hapticsIsOn + self.themeStyle = themeStyle } } diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index 01d05eca..7ffbf7ed 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -16,7 +16,8 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { public var currentUserSettings: Domain.UserSettings = .init( translationSourceLocale: .english, translationTargetLocale: .korean, - hapticsIsOn: true + hapticsIsOn: true, + themeStyle: .system ) public init() {} @@ -48,4 +49,9 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { return .just(()) } + public func updateThemeStyle(_ style: ThemeStyle) -> Single { + currentUserSettings.themeStyle = style + return .just(()) + } + } diff --git a/Sources/DomainTesting/WordUseCaseFake.swift b/Sources/DomainTesting/WordUseCaseFake.swift index f4c800c3..858de266 100644 --- a/Sources/DomainTesting/WordUseCaseFake.swift +++ b/Sources/DomainTesting/WordUseCaseFake.swift @@ -15,6 +15,7 @@ import Utility public final class WordUseCaseFake: WordUseCaseProtocol { + /// Fake 객체 구현을 위해 사용한 인메모리 단어 저장소 public var _wordList: [Domain.Word] = [] public var _unmemorizedWordList: UnmemorizedWordListRepositorySpy = .init() @@ -22,6 +23,10 @@ public final class WordUseCaseFake: WordUseCaseProtocol { public init() {} public func addNewWord(_ word: Domain.Word) -> Single { + if _wordList.contains(where: { $0.word.lowercased() == word.word.lowercased() }) { + return .error(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: word.word))) + } + _wordList.append(word) _unmemorizedWordList.addWord(word) return .just(()) @@ -51,16 +56,39 @@ public final class WordUseCaseFake: WordUseCaseProtocol { public func getWord(by uuid: UUID) -> Single { guard let word = _wordList.first(where: { $0.uuid == uuid }) else { - return .error(WordUseCaseError.invalidUUID(uuid)) + return .error(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid))) } return .just(word) } public func updateWord(by uuid: UUID, to newWord: Domain.Word) -> Single { - if let index = _wordList.firstIndex(where: { $0.uuid == uuid }) { - _wordList[index] = newWord + guard let index = _wordList.firstIndex(where: { $0.uuid == uuid }) else { + return .error(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid))) } - _unmemorizedWordList.replaceWord(where: uuid, with: newWord) + + if (newWord.word != _wordList[index].word) && _wordList.contains(where: { $0.word.lowercased() == newWord.word.lowercased() }) { + return .error(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: newWord.word))) + } + + let updateTarget: Word = .init( + uuid: uuid, + word: newWord.word, + memorizedState: newWord.memorizedState + ) + + if _unmemorizedWordList.contains(where: { $0.uuid == updateTarget.uuid }) { + switch updateTarget.memorizedState { + case .memorized: + _unmemorizedWordList.deleteWord(by: uuid) + case .memorizing: + _unmemorizedWordList.replaceWord(where: uuid, with: updateTarget) + } + } else if updateTarget.memorizedState == .memorizing { + _unmemorizedWordList.addWord(updateTarget) + } + + _wordList[index] = updateTarget + return .just(()) } @@ -97,4 +125,12 @@ public final class WordUseCaseFake: WordUseCaseProtocol { return .just(currentWord) } + public func isWordDuplicated(_ word: String) -> Single { + if _wordList.contains(where: { $0.word.lowercased() == word.lowercased() }) { + return .just(true) + } else { + return .just(false) + } + } + } diff --git a/Sources/FoundationExtension/AllCases+index.swift b/Sources/FoundationExtension/AllCases+index.swift deleted file mode 100644 index 9f129706..00000000 --- a/Sources/FoundationExtension/AllCases+index.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// AllCases+index.swift -// iOSSupport -// -// Created by Jaewon Yun on 2/5/24. -// Copyright © 2024 woin2ee. All rights reserved. -// - -import Foundation - -extension Array where Element: CaseIterable & Equatable { - - /// AllCases 배열에서 주어진 열거형 값에 해당하는 index 를 반환합니다. - /// - Parameter element: 열거형 값 - /// - Returns: 주어진 열거형 값에 해당하는 index - public func index(of element: Element) -> Int { - guard let index = self.firstIndex(of: element) else { - fatalError("This method was used where it was not appropriate.") - } - return index - } - -} diff --git a/Sources/FoundationExtension/Collection+isNotEmpty.swift b/Sources/FoundationExtension/Collection+isNotEmpty.swift deleted file mode 100644 index 3f0c91e6..00000000 --- a/Sources/FoundationExtension/Collection+isNotEmpty.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Collection+isNotEmpty.swift -// ItsME -// -// Created by Jaewon Yun on 2023/03/24. -// - -import Foundation - -extension Collection { - - /// A Boolean value indicating whether the collection is not empty. - public var isNotEmpty: Bool { - !isEmpty - } -} diff --git a/Sources/iPhoneDriver/iPhoneAppDelegate.swift b/Sources/IPhoneDriver/IPhoneAppDelegate.swift similarity index 89% rename from Sources/iPhoneDriver/iPhoneAppDelegate.swift rename to Sources/IPhoneDriver/IPhoneAppDelegate.swift index 842e0c59..195835fb 100644 --- a/Sources/iPhoneDriver/iPhoneAppDelegate.swift +++ b/Sources/IPhoneDriver/IPhoneAppDelegate.swift @@ -1,5 +1,5 @@ // -// iPhoneAppDelegate.swift +// IPhoneAppDelegate.swift // iPhoneDriver // // Created by Jaewon Yun on 1/30/24. @@ -7,25 +7,30 @@ // import Domain -import GeneralSettings import GoogleSignIn import Infrastructure -import iOSSupport -import LanguageSetting -import PushNotificationSettings +import IOSSupport import RxSwift -import Swinject -import SwinjectDIContainer import UIKit -import UserSettings import Utility + +// Scenes +import GeneralSettings +import LanguageSetting +import PushNotificationSettings +import ThemeSetting +import UserSettings import WordAddition import WordChecking import WordDetail import WordList +// DI +import Swinject +import SwinjectDIContainer + // swiftlint:disable type_name -open class iPhoneAppDelegate: UIResponder, UIApplicationDelegate { +open class IPhoneAppDelegate: UIResponder, UIApplicationDelegate { public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { initDIContainer() @@ -90,21 +95,23 @@ open class iPhoneAppDelegate: UIResponder, UIApplicationDelegate { LanguageSettingAssembly(), PushNotificationSettingsAssembly(), GeneralSettingsAssembly(), + ThemeSettingAssembly(), ]) } func initGlobalState() { let userSettingsUseCase: UserSettingsUseCaseProtocol = DIContainer.shared.resolver.resolve() _ = userSettingsUseCase.getCurrentUserSettings() - .map(\.hapticsIsOn) - .doOnSuccess(GlobalState.shared.initialize) + .doOnSuccess { + GlobalState.shared.initialize(hapticsIsOn: $0.hapticsIsOn, themeStyle: $0.themeStyle.toUIKit()) + } .subscribe(on: ConcurrentMainScheduler.instance) .subscribe() } } -extension iPhoneAppDelegate: UNUserNotificationCenterDelegate { +extension IPhoneAppDelegate: UNUserNotificationCenterDelegate { public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { RootTabBarController.shared.selectedViewController = RootTabBarController.shared.wordCheckingNC diff --git a/Sources/Infrastructure/DI/UserSettingsRepositoryAssemblyDev.swift b/Sources/Infrastructure/DI/UserSettingsRepositoryAssemblyDev.swift index 8636b424..8f84e304 100644 --- a/Sources/Infrastructure/DI/UserSettingsRepositoryAssemblyDev.swift +++ b/Sources/Infrastructure/DI/UserSettingsRepositoryAssemblyDev.swift @@ -17,8 +17,10 @@ final class UserSettingsRepositoryAssemblyDev: Assembly { func assemble(container: Container) { container.register(UserSettingsRepositoryProtocol.self) { _ in let userDefaults: ExtendedUserDefaults = .init(suiteName: "Dev")! - let arguments = ProcessInfo.processInfo.arguments - if arguments.contains(LaunchArguments.initUserDefaults.rawValue) { + + LaunchArgument.verify() + + if LaunchArgument.contains(.initUserDefaults) { userDefaults.removeAllObject(forKeyType: UserDefaultsKey.self) } diff --git a/Sources/Infrastructure/DI/WordRepositoryAssemblyDev.swift b/Sources/Infrastructure/DI/WordRepositoryAssemblyDev.swift index ae185c13..303a5af9 100644 --- a/Sources/Infrastructure/DI/WordRepositoryAssemblyDev.swift +++ b/Sources/Infrastructure/DI/WordRepositoryAssemblyDev.swift @@ -16,14 +16,16 @@ final class WordRepositoryAssemblyDev: Assembly { func assemble(container: Container) { container.register(WordRepositoryProtocol.self) { _ in - let arguments = ProcessInfo.processInfo.arguments - // TODO: Insert [LaunchArguments 검증 code](상호배타적인것들) - if arguments.contains(LaunchArguments.useInMemoryDatabase.rawValue) { + LaunchArgument.verify() + + if LaunchArgument.contains(.useInMemoryDatabase) { return self.makeInMemoryWordRepository() } - if arguments.contains(LaunchArguments.sampledDatabase.rawValue) { + + if LaunchArgument.contains(.sampledDatabase) { return self.makeSampledWordRepository() } + return self.makePersistenceWordRepository() } .inObjectScope(.container) diff --git a/Sources/Infrastructure/DomainImpl/UserSettingsRepository.swift b/Sources/Infrastructure/DomainImpl/UserSettingsRepository.swift index 42655bff..8c78072c 100644 --- a/Sources/Infrastructure/DomainImpl/UserSettingsRepository.swift +++ b/Sources/Infrastructure/DomainImpl/UserSettingsRepository.swift @@ -31,7 +31,8 @@ final class UserSettingsRepository: UserSettingsRepositoryProtocol { userSettings.translationTargetLocale, forKey: UserDefaultsKey.translationTargetLocale ), - userDefaults.rx.setValue(userSettings.hapticsIsOn, forKey: UserDefaultsKey.hapticsIsOn) + userDefaults.rx.setValue(userSettings.hapticsIsOn, forKey: UserDefaultsKey.hapticsIsOn), + userDefaults.rx.setCodable(userSettings.themeStyle, forKey: UserDefaultsKey.themeStyle) ) .mapToVoid() } @@ -40,13 +41,15 @@ final class UserSettingsRepository: UserSettingsRepositoryProtocol { return Single.zip( userDefaults.rx.object(TranslationLanguage.self, forKey: UserDefaultsKey.translationSourceLocale), userDefaults.rx.object(TranslationLanguage.self, forKey: UserDefaultsKey.translationTargetLocale), - userDefaults.rx.bool(forKey: UserDefaultsKey.hapticsIsOn) + userDefaults.rx.bool(forKey: UserDefaultsKey.hapticsIsOn), + userDefaults.rx.object(ThemeStyle.self, forKey: UserDefaultsKey.themeStyle) ) - .map { sourceLocale, targetLocale, hapticsIsOn -> Domain.UserSettings in + .map { sourceLocale, targetLocale, hapticsIsOn, themeStyle -> Domain.UserSettings in return .init( translationSourceLocale: sourceLocale, translationTargetLocale: targetLocale, - hapticsIsOn: hapticsIsOn + hapticsIsOn: hapticsIsOn, + themeStyle: themeStyle ) } } diff --git a/Sources/Infrastructure/UserDefaults/UserDefaultsKey.swift b/Sources/Infrastructure/UserDefaults/UserDefaultsKey.swift index 322a6ca8..99dfbcc0 100644 --- a/Sources/Infrastructure/UserDefaults/UserDefaultsKey.swift +++ b/Sources/Infrastructure/UserDefaults/UserDefaultsKey.swift @@ -19,6 +19,8 @@ enum UserDefaultsKey: UserDefaultsKeyProtocol, CaseIterable { case hapticsIsOn + case themeStyle + /// 테스트용 Key 입니다. case test diff --git a/Sources/Utility/Concurrency/Sequence+asyncForEach.swift b/Sources/Utility/Concurrency/Sequence+asyncForEach.swift deleted file mode 100644 index 39143c4b..00000000 --- a/Sources/Utility/Concurrency/Sequence+asyncForEach.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Sequence+asyncForEach.swift -// iOSCore -// -// Created by Jaewon Yun on 2023/10/06. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import Foundation - -extension Sequence { - - public func asyncForEach(_ body: (Self.Element) async throws -> Void) async rethrows { - for element in self { - try await body(element) - } - } - -} diff --git a/Sources/Utility/Concurrency/Task+.swift b/Sources/Utility/Concurrency/Task+.swift deleted file mode 100644 index 0bce10bb..00000000 --- a/Sources/Utility/Concurrency/Task+.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Task+.swift -// iOSCore -// -// Created by Jaewon Yun on 2023/10/06. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import Foundation - -extension Task where Success == Void, Failure == Never { - - @discardableResult - public init( - priority: TaskPriority? = nil, - operation: @escaping (() async throws -> Void), - catch: @escaping ((Error) -> Void) - ) { - self.init(priority: priority) { - do { - try await operation() - } catch { - `catch`(error) - } - } - } - -} diff --git a/Sources/Utility/LaunchArgument.swift b/Sources/Utility/LaunchArgument.swift new file mode 100644 index 00000000..ceda59b8 --- /dev/null +++ b/Sources/Utility/LaunchArgument.swift @@ -0,0 +1,45 @@ +// +// LaunchArgument.swift +// WordChecker +// +// Created by Jaewon Yun on 2023/08/27. +// + +import Foundation + +public final class LaunchArgument { + + private init() {} + + /// 사용 가능한 launch argument 목록입니다. + public enum Arguments: String { + + /// 인메모리 데이터베이스를 사용합니다. + case useInMemoryDatabase = "-useInMemoryDatabase" + + /// 샘플 데이터를 적용합니다. + case sampledDatabase = "-sampledDatabase" + + /// UserDefaults 를 초기화합니다. + case initUserDefaults = "-initUserDefaults" + + } + + public static func contains(_ launchArguments: Arguments) -> Bool { + let arguments = ProcessInfo.processInfo.arguments + return arguments.contains(launchArguments.rawValue) + } + + public static func contains(_ launchArguments: String) -> Bool { + let arguments = ProcessInfo.processInfo.arguments + return arguments.contains(launchArguments) + } + + /// Launch arguments 가 올바르게 적용되었는지 검증합니다. + public static func verify() { + if self.contains(.useInMemoryDatabase) && self.contains(.sampledDatabase) { + fatalError("\(Arguments.useInMemoryDatabase) and \(Arguments.sampledDatabase) should not be used together.") + } + } + +} diff --git a/Sources/Utility/LaunchArguments.swift b/Sources/Utility/LaunchArguments.swift deleted file mode 100644 index 0343eea9..00000000 --- a/Sources/Utility/LaunchArguments.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// LaunchArguments.swift -// WordChecker -// -// Created by Jaewon Yun on 2023/08/27. -// - -import Foundation - -public enum LaunchArguments: String { - - case useInMemoryDatabase = "-useInMemoryDatabase" - - case sampledDatabase = "-sampledDatabase" - - case initUserDefaults = "-initUserDefaults" - -} diff --git a/Sources/WordChecker/AppDelegate.swift b/Sources/WordChecker/AppDelegate.swift index d56f43d0..166e3df6 100644 --- a/Sources/WordChecker/AppDelegate.swift +++ b/Sources/WordChecker/AppDelegate.swift @@ -5,8 +5,8 @@ // Created by Jaewon Yun on 2023/08/23. // -import iPhoneDriver +import IPhoneDriver @main -class AppDelegate: iPhoneAppDelegate { +class AppDelegate: IPhoneAppDelegate { } diff --git a/Sources/WordCheckerDev/AppDelegate.swift b/Sources/WordCheckerDev/AppDelegate.swift index e03d50cf..2b532165 100644 --- a/Sources/WordCheckerDev/AppDelegate.swift +++ b/Sources/WordCheckerDev/AppDelegate.swift @@ -6,13 +6,14 @@ // import Domain -import iPhoneDriver +import IPhoneDriver import Infrastructure // Scenes import GeneralSettings import LanguageSetting import PushNotificationSettings +import ThemeSetting import UserSettings import WordAddition import WordChecking @@ -24,7 +25,7 @@ import Swinject import SwinjectDIContainer @main -class AppDelegate: iPhoneAppDelegate { +class AppDelegate: IPhoneAppDelegate { override func restoreGoogleSignInState() { // No restore for dev. @@ -42,6 +43,7 @@ class AppDelegate: iPhoneAppDelegate { LanguageSettingAssembly(), PushNotificationSettingsAssemblyDev(), GeneralSettingsAssembly(), + ThemeSettingAssembly(), ]) } diff --git a/Sources/iOSScenes/GeneralSettings/GeneralSettingsReactor.swift b/Sources/iOSScenes/GeneralSettings/GeneralSettingsReactor.swift index f53e993b..c64273a1 100644 --- a/Sources/iOSScenes/GeneralSettings/GeneralSettingsReactor.swift +++ b/Sources/iOSScenes/GeneralSettings/GeneralSettingsReactor.swift @@ -7,7 +7,7 @@ // import Domain -import iOSSupport +import IOSSupport import ReactorKit final class GeneralSettingsReactor: Reactor { diff --git a/Sources/iOSScenes/GeneralSettings/GeneralSettingsViewController.swift b/Sources/iOSScenes/GeneralSettings/GeneralSettingsViewController.swift index ab030be2..8db35b03 100644 --- a/Sources/iOSScenes/GeneralSettings/GeneralSettingsViewController.swift +++ b/Sources/iOSScenes/GeneralSettings/GeneralSettingsViewController.swift @@ -6,14 +6,14 @@ // Copyright © 2024 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import ReactorKit import RxUtility import Then import UIKit -public protocol GeneralSettingsViewControllerDelegate: AnyObject { - func willPopView() +public protocol GeneralSettingsViewControllerDelegate: AnyObject, ViewControllerDelegate { + func didTapThemeSetting() } public protocol GeneralSettingsViewControllerProtocol: UIViewController { @@ -24,16 +24,19 @@ final class GeneralSettingsViewController: RxBaseViewController, View, GeneralSe enum SectionIdentifier: Int { case hapticsSettings = 0 + case themeSetting } enum ItemIdentifier { case hapticsOnOffSwitch + case themeSetting } weak var delegate: GeneralSettingsViewControllerDelegate? lazy var rootView: UITableView = .init(frame: .zero, style: .insetGrouped).then { $0.registerCell(ManualSwitchCell.self) + $0.registerCell(DisclosureIndicatorCell.self) $0.registerHeaderFooterView(TextFooterView.self) $0.delegate = self } @@ -60,6 +63,11 @@ final class GeneralSettingsViewController: RxBaseViewController, View, GeneralSe .bind(to: reactor.action) .disposed(by: cell.disposeBag) return cell + + case .themeSetting: + let cell = tableView.dequeueReusableCell(DisclosureIndicatorCell.self, for: indexPath) + cell.bind(model: .init(title: WCString.theme)) + return cell } } @@ -78,21 +86,37 @@ final class GeneralSettingsViewController: RxBaseViewController, View, GeneralSe applyInitialSnapshot() } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) if self.isMovingFromParent { - delegate?.willPopView() + delegate?.viewControllerDidPop(self) } } func applyInitialSnapshot() { var snapshot = dataSource.snapshot() - snapshot.appendSections([.hapticsSettings]) + snapshot.appendSections([ + .hapticsSettings, + .themeSetting, + ]) snapshot.appendItems([.hapticsOnOffSwitch], toSection: .hapticsSettings) + snapshot.appendItems([.themeSetting], toSection: .themeSetting) dataSource.applySnapshotUsingReloadData(snapshot) } + override func bindAction() { + let itemSelectedEvent = rootView.rx.itemSelected.asSignal() + .doOnNext { [weak self] in self?.rootView.deselectRow(at: $0, animated: true) } + + itemSelectedEvent + .filter { self.dataSource.itemIdentifier(for: $0) == .themeSetting } + .emit(with: self, onNext: { owner, _ in + owner.delegate?.didTapThemeSetting() + }) + .disposed(by: self.disposeBag) + } + func bind(reactor: GeneralSettingsReactor) { // Action self.rx.sentMessage(#selector(viewDidLoad)) diff --git a/Sources/iOSScenes/LanguageSetting/LanguageSettingReactor.swift b/Sources/iOSScenes/LanguageSetting/LanguageSettingReactor.swift index b750a9b3..93770e5f 100644 --- a/Sources/iOSScenes/LanguageSetting/LanguageSettingReactor.swift +++ b/Sources/iOSScenes/LanguageSetting/LanguageSettingReactor.swift @@ -8,8 +8,8 @@ import Domain import Foundation -import FoundationExtension -import iOSSupport +import FoundationPlus +import IOSSupport import ReactorKit final class LanguageSettingReactor: Reactor { @@ -32,12 +32,12 @@ final class LanguageSettingReactor: Reactor { let userSettingsUseCase: UserSettingsUseCaseProtocol - let globalAction: GlobalReactorAction + let globalAction: GlobalAction init( translationDirection: TranslationDirection, userSettingsUseCase: UserSettingsUseCaseProtocol, - globalAction: GlobalReactorAction + globalAction: GlobalAction ) { self.initialState = .init(translationDirection: translationDirection, selectedCell: .init()) self.userSettingsUseCase = userSettingsUseCase diff --git a/Sources/iOSScenes/LanguageSetting/LanguageSettingViewController.swift b/Sources/iOSScenes/LanguageSetting/LanguageSettingViewController.swift index 4113d44f..ffe4085a 100644 --- a/Sources/iOSScenes/LanguageSetting/LanguageSettingViewController.swift +++ b/Sources/iOSScenes/LanguageSetting/LanguageSettingViewController.swift @@ -7,19 +7,15 @@ // import Domain -import FoundationExtension -import iOSSupport +import FoundationPlus +import IOSSupport import OrderedCollections import ReactorKit import RxSwift import Then import UIKit -public protocol LanguageSettingViewControllerDelegate: AnyObject { - - /// ViewController 가 Pop 해야될때 호출되는 Delegate method 입니다. - func viewMustPop() - +public protocol LanguageSettingViewControllerDelegate: AnyObject, ViewControllerDelegate { } public protocol LanguageSettingViewControllerProtocol: UIViewController { @@ -95,16 +91,16 @@ final class LanguageSettingViewController: RxBaseViewController, LanguageSetting applyInitialSnapshotIfNoSections() } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) if self.isMovingFromParent { - delegate?.viewMustPop() + delegate?.viewControllerDidPop(self) } } - /// 현재 Snapshot 에 추가된 Section 이 없다면 초기 snapshot 을 적용합니다. - /// - Returns: 현재 Snapshot 에 추가된 Section 이 있다면 현재 snapshot 을 반환, 없다면 새로 적용한 snapshot 을 반환합니다. + /// 현재 DataSource 에 적용된 Snapshot 이 없다면 초기 Snapshot 을 적용합니다. + /// - Returns: 현재 DataSource 에 적용된 Snapshot 이 있다면 해당 Snapshot 을 반환, 없다면 새로 적용한 Snapshot 을 반환합니다. @discardableResult func applyInitialSnapshotIfNoSections() -> NSDiffableDataSourceSnapshot { let currenetSnapshot = dataSource.snapshot() @@ -144,13 +140,14 @@ final class LanguageSettingViewController: RxBaseViewController, LanguageSetting var snapshot = owner.dataSource.snapshot() snapshot = owner.applyInitialSnapshotIfNoSections() - guard let sectionIdentifier = SectionIdentifier.init(rawValue: selectedIndexPath.section) else { + guard + let selectedSectionIdentifier = SectionIdentifier.init(rawValue: selectedIndexPath.section), + let selectedItemIdentifier = owner.dataSource.itemIdentifier(for: selectedIndexPath) + else { assertionFailure("Out of selectable cell.") return } - let selectedItemIdentifier = snapshot.itemIdentifiers(inSection: sectionIdentifier)[selectedIndexPath.row] - if case .language = selectedItemIdentifier { owner.itemModels = owner.defaultItemModels var selectedItem = owner.itemModels[selectedItemIdentifier] @@ -158,7 +155,7 @@ final class LanguageSettingViewController: RxBaseViewController, LanguageSetting owner.itemModels[selectedItemIdentifier] = selectedItem } - snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: sectionIdentifier)) + snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: selectedSectionIdentifier)) owner.dataSource.apply(snapshot) } .disposed(by: self.disposeBag) diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift index 6834bea8..0c31dc58 100644 --- a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift @@ -6,7 +6,7 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import UIKit final class DatePickerCell: RxBaseReusableCell { diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift index 75447f1f..9bb9c048 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift @@ -8,7 +8,7 @@ import Domain import Foundation -import iOSSupport +import IOSSupport import ReactorKit final class PushNotificationSettingsReactor: Reactor { @@ -40,9 +40,9 @@ final class PushNotificationSettingsReactor: Reactor { ) let notificationsUseCase: NotificationsUseCaseProtocol - let globalAction: GlobalReactorAction + let globalAction: GlobalAction - init(notificationsUseCase: NotificationsUseCaseProtocol, globalAction: GlobalReactorAction) { + init(notificationsUseCase: NotificationsUseCaseProtocol, globalAction: GlobalAction) { self.notificationsUseCase = notificationsUseCase self.globalAction = globalAction } diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift index fc5580a9..2eafe11e 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift @@ -6,7 +6,7 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import Then import UIKit diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift index 47220865..2e9884a9 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift @@ -1,10 +1,9 @@ -import iOSSupport +import IOSSupport import ReactorKit import Then import UIKit -public protocol PushNotificationSettingsDelegate: AnyObject { - func willPopView() +public protocol PushNotificationSettingsDelegate: AnyObject, ViewControllerDelegate { } public protocol PushNotificationSettingsViewControllerProtocol: UIViewController { @@ -96,11 +95,11 @@ class PushNotificationSettingsViewController: RxBaseViewController, View, PushNo isViewAppeared = true } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) if self.isMovingFromParent { - delegate?.willPopView() + delegate?.viewControllerDidPop(self) } } diff --git a/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift b/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift index c494e4a8..66305b16 100644 --- a/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift +++ b/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift @@ -5,14 +5,13 @@ // Created by Jaewon Yun on 2023/08/23. // -import iOSSupport +import IOSSupport import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - GlobalState.shared.initialize(hapticsIsOn: true) return true } diff --git a/Sources/iOSScenes/ThemeSetting/ThemeSettingAssembly.swift b/Sources/iOSScenes/ThemeSetting/ThemeSettingAssembly.swift new file mode 100644 index 00000000..1a78b6d0 --- /dev/null +++ b/Sources/iOSScenes/ThemeSetting/ThemeSettingAssembly.swift @@ -0,0 +1,24 @@ +import Swinject +import SwinjectExtension +import Then + +public final class ThemeSettingAssembly: Assembly { + + public init() {} + + public func assemble(container: Container) { + container.register(ThemeSettingReactor.self) { resolver in + return ThemeSettingReactor.init( + userSettingsUseCase: resolver.resolve(), + globalState: .shared + ) + } + + container.register(ThemeSettingViewControllerProtocol.self) { resolver in + return ThemeSettingViewController().then { + $0.reactor = resolver.resolve() + } + } + } + +} diff --git a/Sources/iOSScenes/ThemeSetting/ThemeSettingReactor.swift b/Sources/iOSScenes/ThemeSetting/ThemeSettingReactor.swift new file mode 100644 index 00000000..8b15ef38 --- /dev/null +++ b/Sources/iOSScenes/ThemeSetting/ThemeSettingReactor.swift @@ -0,0 +1,61 @@ +import Domain +import IOSSupport +import ReactorKit +import UIKit + +final class ThemeSettingReactor: Reactor { + + enum Action { + case viewDidLoad + case selectStyle(UIUserInterfaceStyle) + } + + enum Mutation { + case setStyle(UIUserInterfaceStyle) + } + + struct State { + var selectedStyle: UIUserInterfaceStyle + } + + var initialState: State = .init(selectedStyle: .unspecified) + + let userSettingsUseCase: UserSettingsUseCaseProtocol + let globalState: GlobalState + + init(userSettingsUseCase: UserSettingsUseCaseProtocol, globalState: GlobalState) { + self.userSettingsUseCase = userSettingsUseCase + self.globalState = globalState + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewDidLoad: + return userSettingsUseCase.getCurrentUserSettings() + .asObservable() + .map(\.themeStyle) + .map { $0.toUIKit() } + .map(Mutation.setStyle) + + case .selectStyle(let selectedStyle): + return userSettingsUseCase.updateThemeStyle(selectedStyle.toDomain()) + .asObservable() + .doOnNext { + self.globalState.themeStyle.accept(selectedStyle) + } + .map { Mutation.setStyle(selectedStyle) } + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var state = state + + switch mutation { + case .setStyle(let selectedStyle): + state.selectedStyle = selectedStyle + } + + return state + } + +} diff --git a/Sources/iOSScenes/ThemeSetting/ThemeSettingViewController.swift b/Sources/iOSScenes/ThemeSetting/ThemeSettingViewController.swift new file mode 100644 index 00000000..6947532f --- /dev/null +++ b/Sources/iOSScenes/ThemeSetting/ThemeSettingViewController.swift @@ -0,0 +1,152 @@ +import FoundationPlus +import IOSSupport +import OrderedCollections +import ReactorKit +import UIKit + +public protocol ThemeSettingViewControllerDelegate: AnyObject, ViewControllerDelegate { +} + +public protocol ThemeSettingViewControllerProtocol: UIViewController { + var delegate: ThemeSettingViewControllerDelegate? { get set } +} + +final class ThemeSettingViewController: RxBaseViewController, View, ThemeSettingViewControllerProtocol { + + enum SectionIdentifier { + case theme + } + + enum ItemIdentifier: Hashable { + case theme(UIUserInterfaceStyle) + } + + /// `ItemIdentifier` 로 인해 구분되는 Item 들을 보여주기 위한 값을 가지고 있는 OrderedDictionary 타입 Model 입니다. + /// + /// `TableView` 에 보여지는 순서대로 값을 가지고 있습니다. + lazy var itemModels: OrderedDictionary = defaultItemModels + + var defaultItemModels: OrderedDictionary { + [ + ItemIdentifier.theme(.unspecified), + ItemIdentifier.theme(.light), + ItemIdentifier.theme(.dark), + ] + .reduce(into: OrderedDictionary()) { partialResult, itemIdentifier in + if case let .theme(style) = itemIdentifier { + partialResult[itemIdentifier] = .init(title: localizedCellTitle(by: style), isChecked: false) + } + } + } + + lazy var dataSource: UITableViewDiffableDataSource = .init(tableView: rootView) { [weak self] tableView, indexPath, itemIdentifier -> UITableViewCell? in + guard let self = self else { return nil } + guard let itemModel = itemModels[itemIdentifier] else { return nil } + + switch itemIdentifier { + case .theme(let style): + let cell = tableView.dequeueReusableCell(CheckmarkCell.self, for: indexPath) + cell.bind(model: .init(title: localizedCellTitle(by: style), isChecked: itemModel.isChecked)) + return cell + } + } + + weak var delegate: ThemeSettingViewControllerDelegate? + + lazy var rootView: UITableView = .init(frame: .zero, style: .insetGrouped).then { + $0.registerCell(CheckmarkCell.self) + } + + override func loadView() { + self.view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .systemGroupedBackground + self.navigationItem.title = WCString.theme + + applyInitialSnapshotIfNoSections() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if self.isMovingFromParent { + delegate?.viewControllerDidPop(self) + } + } + + /// 현재 DataSource 에 적용된 Snapshot 이 없다면 초기 Snapshot 을 적용합니다. + /// - Returns: 현재 DataSource 에 적용된 Snapshot 이 있다면 해당 Snapshot 을 반환, 없다면 새로 적용한 Snapshot 을 반환합니다. + @discardableResult + func applyInitialSnapshotIfNoSections() -> NSDiffableDataSourceSnapshot { + let currenetSnapshot = dataSource.snapshot() + + if currenetSnapshot.sectionIdentifiers.isNotEmpty { + return currenetSnapshot + } + + var snapshot: NSDiffableDataSourceSnapshot = .init() + let itemIdentifiers = Array(itemModels.keys) + + snapshot.appendSections([.theme]) + snapshot.appendItems(itemIdentifiers, toSection: .theme) + dataSource.applySnapshotUsingReloadData(snapshot) + return snapshot + } + + func bind(reactor: ThemeSettingReactor) { + // Action + self.rx.sentMessage(#selector(viewDidLoad)) + .map { _ in Reactor.Action.viewDidLoad } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + rootView.rx.itemSelected + .doOnNext { [weak self] in self?.rootView.deselectRow(at: $0, animated: false) } + .compactMap { [weak self] indexPath in + guard case .theme(let style) = self?.dataSource.itemIdentifier(for: indexPath) else { + assertionFailure("Selected invalid item.") + return nil + } + return style + } + .map(Reactor.Action.selectStyle) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.state + .map(\.selectedStyle) + .skip(1) // skip initialState + .asDriverOnErrorJustComplete() + .drive(with: self) { owner, selectedStyle in + var snapshot = owner.dataSource.snapshot() + snapshot = owner.applyInitialSnapshotIfNoSections() + + owner.itemModels = owner.defaultItemModels + owner.itemModels[.theme(selectedStyle)] = .init(title: owner.localizedCellTitle(by: selectedStyle), isChecked: true) + + snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .theme)) + owner.dataSource.apply(snapshot) + } + .disposed(by: self.disposeBag) + } + + func localizedCellTitle(by style: UIUserInterfaceStyle) -> String { + switch style { + case .unspecified: + return WCString.system_mode + case .light: + return WCString.light_mode + case .dark: + return WCString.dark_mode + @unknown default: + assertionFailure("unkown cases.") + return "" + } + } + +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift index 8c7ce7bf..cee35ab3 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift @@ -6,7 +6,7 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import Foundation +import IOSSupport enum UserSettingsItemModel { case disclosureIndicator(DisclosureIndicatorCell.Model) diff --git a/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift b/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift index ca22cdc9..a65ee0eb 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift @@ -7,7 +7,7 @@ // import Domain -import iOSSupport +import IOSSupport import ReactorKit final class UserSettingsReactor: Reactor { @@ -47,12 +47,12 @@ final class UserSettingsReactor: Reactor { let userSettingsUseCase: UserSettingsUseCaseProtocol let googleDriveUseCase: ExternalStoreUseCaseProtocol - let globalAction: GlobalReactorAction + let globalAction: GlobalAction init( userSettingsUseCase: UserSettingsUseCaseProtocol, googleDriveUseCase: ExternalStoreUseCaseProtocol, - globalAction: GlobalReactorAction + globalAction: GlobalAction ) { self.userSettingsUseCase = userSettingsUseCase self.googleDriveUseCase = googleDriveUseCase diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index 274abdb8..f437f530 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -7,7 +7,7 @@ // import Domain -import iOSSupport +import IOSSupport import ReactorKit import RxSwift import RxUtility @@ -110,10 +110,6 @@ final class UserSettingsViewController: RxBaseViewController, View, UserSettings } override func bindAction() { - guard let reactor = self.reactor else { - preconditionFailure("After initialization, reactor is not assigned.") - } - let itemSelectedEvent = settingsTableView.rx.itemSelected.asSignal() .doOnNext { [weak self] in self?.settingsTableView.deselectRow(at: $0, animated: true) } diff --git a/Sources/iOSScenes/WordAddition/WordAdditionViewController.swift b/Sources/iOSScenes/WordAddition/WordAdditionViewController.swift index 0776ba52..35a665ac 100644 --- a/Sources/iOSScenes/WordAddition/WordAdditionViewController.swift +++ b/Sources/iOSScenes/WordAddition/WordAdditionViewController.swift @@ -5,7 +5,7 @@ // Created by Jaewon Yun on 2023/09/09. // -import iOSSupport +import IOSSupport import RxSwift import RxCocoa import RxUtility @@ -13,10 +13,7 @@ import SnapKit import Then import UIKit -public protocol WordAdditionViewControllerDelegate: AnyObject { - - func didFinishInteration() - +public protocol WordAdditionViewControllerDelegate: AnyObject, ViewControllerDelegate { } public protocol WordAdditionViewControllerProtocol: UIViewController { @@ -34,6 +31,13 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo $0.borderStyle = .roundedRect } + let duplicatedWordAlertLabel: UILabel = .init().then { + $0.text = WCString.duplicate_word + $0.textColor = .systemRed + $0.adjustsFontForContentSizeCategory = true + $0.font = .preferredFont(forTextStyle: .footnote) + } + lazy var cancelBarButton: UIBarButtonItem = .init(systemItem: .cancel) lazy var doneBarButton: UIBarButtonItem = .init(systemItem: .done).then { @@ -58,11 +62,17 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo func setupSubviews() { self.view.addSubview(wordTextField) + self.view.addSubview(duplicatedWordAlertLabel) wordTextField.snp.makeConstraints { make in make.top.equalTo(self.view.safeAreaLayoutGuide).offset(10) make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(20) } + + duplicatedWordAlertLabel.snp.makeConstraints { make in + make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(22) + make.top.equalTo(wordTextField.snp.bottom).offset(10) + } } func setupNavigationBar() { @@ -78,7 +88,7 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo return } let input: WordAdditionViewModel.Input = .init( - wordText: wordTextField.rx.text.orEmpty.asDriver(), + wordText: wordTextField.rx.text.orEmpty.asDriver().distinctUntilChanged(), saveAttempt: doneBarButton.rx.tap.asSignal(), dismissAttempt: Signal.merge( cancelBarButton.rx.tap.asSignal(), @@ -90,20 +100,27 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo [ output.saveComplete .emit(with: self, onNext: { owner, _ in - owner.delegate?.didFinishInteration() + owner.delegate?.viewControllerMustBeDismissed(owner) }), - output.wordTextIsNotEmpty - .drive(doneBarButton.rx.isEnabled), output.reconfirmDismiss .emit(with: self, onNext: { owner, _ in owner.presentDismissActionSheet { - owner.delegate?.didFinishInteration() + owner.delegate?.viewControllerMustBeDismissed(owner) } }), output.dismissComplete .emit(with: self, onNext: { owner, _ in - owner.delegate?.didFinishInteration() + owner.delegate?.viewControllerMustBeDismissed(owner) }), + output.enteredWordIsDuplicated + .distinctUntilChanged() + .map { !$0 } + .drive(duplicatedWordAlertLabel.rx.isHidden), + Driver.zip([ + output.wordTextIsNotEmpty, + output.enteredWordIsDuplicated, + ]).map { $0[0] && !$0[1] } + .drive(doneBarButton.rx.isEnabled), ] .forEach { $0.disposed(by: disposeBag) } } diff --git a/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift b/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift index 0f32cad4..24c6ce2f 100644 --- a/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift +++ b/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift @@ -7,8 +7,8 @@ import Domain import Foundation -import FoundationExtension -import iOSSupport +import FoundationPlus +import IOSSupport import RxSwift import RxCocoa import RxUtility @@ -22,7 +22,7 @@ final class WordAdditionViewModel: ViewModelType { } func transform(input: Input) -> Output { - let initialWordText = "" + let initialWordText = "" // 새 단어를 추가할때는 초기 단어가 없으므로 let hasChanges = input.wordText.map { $0 != initialWordText } @@ -36,7 +36,7 @@ final class WordAdditionViewModel: ViewModelType { .asSignalOnErrorJustComplete() } .doOnNext { _ in - GlobalReactorAction.shared.didAddWord.accept(()) + GlobalAction.shared.didAddWord.accept(()) } let wordTextIsNotEmpty = input.wordText.map(\.isNotEmpty) @@ -53,11 +53,18 @@ final class WordAdditionViewModel: ViewModelType { .mapToVoid() .asSignalOnErrorJustComplete() + let enteredWordIsDuplicated = input.wordText + .flatMapLatest { word in + return self.wordUseCase.isWordDuplicated(word) + .asDriverOnErrorJustComplete() + } + return .init( saveComplete: saveComplete, wordTextIsNotEmpty: wordTextIsNotEmpty, reconfirmDismiss: reconfirmDismiss, - dismissComplete: dismissComplete + dismissComplete: dismissComplete, + enteredWordIsDuplicated: enteredWordIsDuplicated ) } @@ -81,10 +88,13 @@ extension WordAdditionViewModel { let wordTextIsNotEmpty: Driver + /// Dismiss 해야하는지 재확인이 필요할때 next 이벤트가 방출됩니다. let reconfirmDismiss: Signal let dismissComplete: Signal + /// 입력되어 있는 단어가 중복된 단어인지 여부 + var enteredWordIsDuplicated: Driver } } diff --git a/Sources/iOSScenes/WordChecking/Subviews/WordChecking+ChangeWordButton.swift b/Sources/iOSScenes/WordChecking/Subviews/WordChecking+ChangeWordButton.swift index eb4c190b..e0b71be6 100644 --- a/Sources/iOSScenes/WordChecking/Subviews/WordChecking+ChangeWordButton.swift +++ b/Sources/iOSScenes/WordChecking/Subviews/WordChecking+ChangeWordButton.swift @@ -5,7 +5,7 @@ // Created by Jaewon Yun on 2023/09/11. // -import iOSSupport +import IOSSupport import UIKit extension WordCheckingView { diff --git a/Sources/iOSScenes/WordChecking/TranslationWeb/TranslationWebViewController.swift b/Sources/iOSScenes/WordChecking/TranslationWeb/TranslationWebViewController.swift index df633280..293d3fc2 100644 --- a/Sources/iOSScenes/WordChecking/TranslationWeb/TranslationWebViewController.swift +++ b/Sources/iOSScenes/WordChecking/TranslationWeb/TranslationWebViewController.swift @@ -7,7 +7,7 @@ // import Domain -import iOSSupport +import IOSSupport import Then import UIKit import WebKit diff --git a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift index f5f55610..afa66106 100644 --- a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift +++ b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift @@ -8,9 +8,20 @@ import Domain import Foundation -import iOSSupport +import IOSSupport import ReactorKit +enum WordCheckingReactorError: Error { + + enum AddWordFailureReason { + case duplicatedWord(word: String) + case unknown(word: String) + } + + case addWordFailed(reason: AddWordFailureReason) + +} + final class WordCheckingReactor: Reactor { enum Action { @@ -27,12 +38,14 @@ final class WordCheckingReactor: Reactor { case setCurrentWord(Word?) case setSourceLanguage(TranslationLanguage) case setTargetLanguage(TranslationLanguage) + case showAddCompleteToast(Result) } struct State { var currentWord: Word? var translationSourceLanguage: TranslationLanguage var translationTargetLanguage: TranslationLanguage + @Pulse var showAddCompleteToast: Result? } let initialState: State = State( @@ -43,12 +56,12 @@ final class WordCheckingReactor: Reactor { let wordUseCase: WordUseCaseProtocol let userSettingsUseCase: UserSettingsUseCaseProtocol - let globalAction: GlobalReactorAction + let globalAction: GlobalAction init( wordUseCase: WordUseCaseProtocol, userSettingsUseCase: UserSettingsUseCaseProtocol, - globalAction: GlobalReactorAction + globalAction: GlobalAction ) { self.wordUseCase = wordUseCase self.userSettingsUseCase = userSettingsUseCase @@ -82,9 +95,23 @@ final class WordCheckingReactor: Reactor { let newWord: Word = .init(word: newWord) return wordUseCase.addNewWord(newWord) .asObservable() - .flatMap { self.wordUseCase.getCurrentUnmemorizedWord() } - .map(Mutation.setCurrentWord) - .catchAndReturn(.setCurrentWord(nil)) + .flatMap { _ in self.wordUseCase.getCurrentUnmemorizedWord() } + .flatMap { currentWord -> Observable in + return .merge([ + .just(.setCurrentWord(currentWord)), + .just(.showAddCompleteToast(.success(newWord.word))), + ]) + } + .catch { error in + switch error { + case WordUseCaseError.saveFailed(reason: .duplicatedWord): + return .just(.showAddCompleteToast(.failure(.addWordFailed(reason: .duplicatedWord(word: newWord.word))))) + case WordUseCaseError.noMemorizingWords: + return .just(.setCurrentWord(nil)) + default: + return .just(.showAddCompleteToast(.failure(.addWordFailed(reason: .unknown(word: newWord.word))))) + } + } case .updateToNextWord: return wordUseCase.updateToNextWord() @@ -164,6 +191,8 @@ final class WordCheckingReactor: Reactor { state.translationSourceLanguage = translationSourceLanguage case .setTargetLanguage(let translationTargetLanguage): state.translationTargetLanguage = translationTargetLanguage + case .showAddCompleteToast(let result): + state.showAddCompleteToast = result } return state diff --git a/Sources/iOSScenes/WordChecking/WordCheckingView.swift b/Sources/iOSScenes/WordChecking/WordCheckingView.swift index 5166ce0b..cfe85bc8 100644 --- a/Sources/iOSScenes/WordChecking/WordCheckingView.swift +++ b/Sources/iOSScenes/WordChecking/WordCheckingView.swift @@ -6,7 +6,7 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SnapKit import Then import UIKit diff --git a/Sources/iOSScenes/WordChecking/WordCheckingViewController.swift b/Sources/iOSScenes/WordChecking/WordCheckingViewController.swift index a2828ea0..b1052c75 100644 --- a/Sources/iOSScenes/WordChecking/WordCheckingViewController.swift +++ b/Sources/iOSScenes/WordChecking/WordCheckingViewController.swift @@ -5,7 +5,7 @@ // Created by Jaewon Yun on 2023/08/23. // -import iOSSupport +import IOSSupport import ReactorKit import RxSwift import RxCocoa @@ -169,6 +169,27 @@ final class WordCheckingViewController: RxBaseViewController, View, WordChecking owner.setAccessibilityLanguage() } .disposed(by: self.disposeBag) + + reactor.pulse(\.$showAddCompleteToast) + .asSignalOnErrorJustComplete() + .emit(with: self) { owner, showAddCompleteToast in + guard let showAddCompleteToast = showAddCompleteToast else { return } + switch showAddCompleteToast { + case .success(let word): + owner.view.makeToast(WCString.word_added_successfully(word: word), duration: 2.0, position: .top) + case .failure(let error): + switch error { + case .addWordFailed(let reason): + switch reason { + case .duplicatedWord: + owner.view.makeToast(WCString.already_added_word, duration: 2.0, position: .top) + case .unknown(let word): + owner.view.makeToast(WCString.word_added_failed(word: word), duration: 2.0, position: .top) + } + } + } + } + .disposed(by: self.disposeBag) } func setAccessibilityLanguage() { @@ -188,15 +209,13 @@ final class WordCheckingViewController: RxBaseViewController, View, WordChecking } alertController.addAction(cancelAction) - let addAction: UIAlertAction = .init(title: WCString.add, style: .default) { [weak self] _ in + let addAction: UIAlertAction = .init(title: WCString.add, style: .default) { _ in guard let word = alertController.textFields?.first?.text else { assertionFailure("Failed to get word.") return } observer(.success(word)) - - self?.view.makeToast(WCString.word_added_successfully(word: word), duration: 1.2, position: .top) } alertController.addAction(addAction) diff --git a/Sources/iOSScenes/WordDetail/WordDetailReactor.swift b/Sources/iOSScenes/WordDetail/WordDetailReactor.swift index 77d84d86..f387f2ef 100644 --- a/Sources/iOSScenes/WordDetail/WordDetailReactor.swift +++ b/Sources/iOSScenes/WordDetail/WordDetailReactor.swift @@ -8,7 +8,7 @@ import Domain import Foundation -import iOSSupport +import IOSSupport import ReactorKit final class WordDetailReactor: Reactor { @@ -17,21 +17,45 @@ final class WordDetailReactor: Reactor { case viewDidLoad case beginEditing case doneEditing - case editWord(String) + + /// 현재 입력된 단어 + case enteredWord(String) + case changeMemorizedState(MemorizedState) } enum Mutation { case updateWord(Word) case markAsEditing + + /// 현재 입력된 단어를 중복된 단어로 표시할지 결정하는 Mutation + case setDuplicated(Bool) + + /// 현재 입력된 단어의 비어있음 상태를 결정하는 Mutation + case setEmpty(Bool) } struct State { + + /// 현재 입력된 단어 var word: Word + + /// 현재 화면에서 변경사항이 발생했는지 여부를 나타내는 값 var hasChanges: Bool + + /// 입력되어 있는 단어가 중복된 단어인지 여부를 나타내는 값 + var enteredWordIsDuplicated: Bool + + /// 현재 입력된 단어가 비어있는지 여부를 나타내는 값 + var enteredWordIsEmpty: Bool } - var initialState: State = State(word: .empty, hasChanges: false) + var initialState: State = State( + word: .empty, + hasChanges: false, + enteredWordIsDuplicated: false, + enteredWordIsEmpty: false // Detail 화면에서는 항상 초기 단어가 있으므로 + ) /// 현재 보여지고 있는 단어의 UUID 입니다. let uuid: UUID @@ -39,12 +63,12 @@ final class WordDetailReactor: Reactor { /// 편집되기 전 원래 단어입니다. viewDidLoad 가 호출될 때 초기화됩니다. private(set) var originWord: String? - let globalAction: GlobalReactorAction + let globalAction: GlobalAction let wordUseCase: WordUseCaseProtocol init( uuid: UUID, - globalAction: GlobalReactorAction, + globalAction: GlobalAction, wordUseCase: WordUseCaseProtocol ) { self.uuid = uuid @@ -73,10 +97,24 @@ final class WordDetailReactor: Reactor { .asObservable() .flatMap { _ -> Observable in return .empty() } - case .editWord(let word): - self.currentState.word.word = word + case .enteredWord(let enteredWord): + let setDuplicatedMutation = wordUseCase.isWordDuplicated(enteredWord) + .asObservable() + .map { [weak self] isWordDuplicated in + if isWordDuplicated && (enteredWord != self?.originWord) { // 원래 단어와 달라야 중복이므로 + return Mutation.setDuplicated(true) + } else { + return Mutation.setDuplicated(false) + } + } + + self.currentState.word.word = enteredWord - return .just(.updateWord(self.currentState.word)) + return .merge([ + .just(.updateWord(self.currentState.word)), + setDuplicatedMutation, + .just(.setEmpty(enteredWord.isEmpty)), + ]) case .changeMemorizedState(let state): self.currentState.word.memorizedState = state @@ -96,6 +134,10 @@ final class WordDetailReactor: Reactor { state.word = word case .markAsEditing: state.hasChanges = true + case .setDuplicated(let enteredWordIsDuplicated): + state.enteredWordIsDuplicated = enteredWordIsDuplicated + case .setEmpty(let enteredWordIsEmpty): + state.enteredWordIsEmpty = enteredWordIsEmpty } return state diff --git a/Sources/iOSScenes/WordDetail/WordDetailViewController.swift b/Sources/iOSScenes/WordDetail/WordDetailViewController.swift index db0d8476..eedc68e9 100644 --- a/Sources/iOSScenes/WordDetail/WordDetailViewController.swift +++ b/Sources/iOSScenes/WordDetail/WordDetailViewController.swift @@ -6,17 +6,15 @@ // import Domain -import iOSSupport +import IOSSupport import ReactorKit +import RxCocoa import SnapKit import Then import UIKit import Utility -public protocol WordDetailViewControllerDelegate: AnyObject { - - func willFinishInteraction() - +public protocol WordDetailViewControllerDelegate: AnyObject, ViewControllerDelegate { } public protocol WordDetailViewControllerProtocol: UIViewController { @@ -38,6 +36,13 @@ final class WordDetailViewController: RxBaseViewController, WordDetailViewContro $0.accessibilityIdentifier = AccessibilityIdentifier.WordDetail.wordTextField } + let duplicatedWordAlertLabel: UILabel = .init().then { + $0.text = WCString.duplicate_word + $0.textColor = .systemRed + $0.adjustsFontForContentSizeCategory = true + $0.font = .preferredFont(forTextStyle: .footnote) + } + lazy var memorizationStatePopupButton: UIButton = { var config: UIButton.Configuration = .bordered() config.baseBackgroundColor = .systemGray5 @@ -80,6 +85,7 @@ final class WordDetailViewController: RxBaseViewController, WordDetailViewContro private func setupSubviews() { self.view.addSubview(wordTextField) + self.view.addSubview(duplicatedWordAlertLabel) self.view.addSubview(memorizationStatePopupButton) wordTextField.snp.makeConstraints { make in @@ -87,8 +93,13 @@ final class WordDetailViewController: RxBaseViewController, WordDetailViewContro make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(20) } + duplicatedWordAlertLabel.snp.makeConstraints { make in + make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(22) + make.top.equalTo(wordTextField.snp.bottom).offset(10) + } + memorizationStatePopupButton.snp.makeConstraints { make in - make.top.equalTo(wordTextField.snp.bottom).offset(20) + make.top.equalTo(duplicatedWordAlertLabel.snp.bottom).offset(20) make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(20) } } @@ -106,10 +117,10 @@ final class WordDetailViewController: RxBaseViewController, WordDetailViewContro .drive(with: self) { owner, _ in if owner.reactor!.currentState.hasChanges { owner.presentDismissActionSheet { - owner.delegate?.willFinishInteraction() + owner.delegate?.viewControllerMustBeDismissed(owner) } } else { - owner.delegate?.willFinishInteraction() + owner.delegate?.viewControllerMustBeDismissed(owner) } } .disposed(by: self.disposeBag) @@ -122,20 +133,26 @@ final class WordDetailViewController: RxBaseViewController, WordDetailViewContro extension WordDetailViewController: View { func bind(reactor: WordDetailReactor) { - // Action + // MARK: Action + self.rx.sentMessage(#selector(self.viewDidLoad)) .map { _ in Reactor.Action.viewDidLoad } .bind(to: reactor.action) .disposed(by: self.disposeBag) doneBarButton.rx.tap - .doOnNext { [weak self] _ in self?.delegate?.willFinishInteraction() } + .doOnNext { [weak self] _ in + guard let self = self else { return } + self.delegate?.viewControllerMustBeDismissed(self) + } .map { Reactor.Action.doneEditing } .bind(to: reactor.action) .disposed(by: self.disposeBag) wordTextField.rx.text.orEmpty - .map(Reactor.Action.editWord) + .skip(1) // 초깃값("") 무시 + .distinctUntilChanged() + .map(Reactor.Action.enteredWord) .bind(to: reactor.action) .disposed(by: self.disposeBag) @@ -146,7 +163,8 @@ extension WordDetailViewController: View { .bind(to: reactor.action) .disposed(by: self.disposeBag) - // State + // MARK: State + reactor.state .map(\.hasChanges) .distinctUntilChanged() @@ -175,6 +193,28 @@ extension WordDetailViewController: View { } } .disposed(by: self.disposeBag) + + // 완료 버튼 활성화/비활성화 + Driver.zip([ + reactor.state + .map(\.enteredWordIsEmpty) + .asDriverOnErrorJustComplete(), + reactor.state + .map(\.enteredWordIsDuplicated) + .asDriverOnErrorJustComplete(), + ]).map { !$0[0] && !$0[1] } + .drive(doneBarButton.rx.isEnabled) + .disposed(by: self.disposeBag) + + // 중복 단어 경고 레이블 표시/비표시 + reactor.state + .map(\.enteredWordIsDuplicated) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self) { owner, enteredWordIsDuplicated in + owner.duplicatedWordAlertLabel.isHidden = !enteredWordIsDuplicated + } + .disposed(by: self.disposeBag) } } @@ -185,12 +225,12 @@ extension WordDetailViewController: UIAdaptivePresentationControllerDelegate { func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { self.presentDismissActionSheet { - self.delegate?.willFinishInteraction() + self.delegate?.viewControllerMustBeDismissed(self) } } - func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - delegate?.willFinishInteraction() + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + delegate?.viewControllerDidDismiss(self) } } diff --git a/Sources/iOSScenes/WordList/WordListReactor.swift b/Sources/iOSScenes/WordList/WordListReactor.swift index df8245aa..6d0a83e9 100644 --- a/Sources/iOSScenes/WordList/WordListReactor.swift +++ b/Sources/iOSScenes/WordList/WordListReactor.swift @@ -8,7 +8,7 @@ import Domain import Foundation -import iOSSupport +import IOSSupport import ReactorKit final class WordListReactor: Reactor { @@ -33,10 +33,10 @@ final class WordListReactor: Reactor { var initialState: State = State(listType: .all, wordList: []) - let globalAction: GlobalReactorAction + let globalAction: GlobalAction let wordUseCase: WordUseCaseProtocol - init(globalAction: GlobalReactorAction, wordUseCase: WordUseCaseProtocol) { + init(globalAction: GlobalAction, wordUseCase: WordUseCaseProtocol) { self.globalAction = globalAction self.wordUseCase = wordUseCase } diff --git a/Sources/iOSScenes/WordList/WordListViewController.swift b/Sources/iOSScenes/WordList/WordListViewController.swift index 43cc629f..90306825 100644 --- a/Sources/iOSScenes/WordList/WordListViewController.swift +++ b/Sources/iOSScenes/WordList/WordListViewController.swift @@ -5,7 +5,7 @@ // Created by Jaewon Yun on 2023/08/25. // -import iOSSupport +import IOSSupport import ReactorKit import RxUtility import SFSafeSymbols diff --git a/Sources/iOSScenes/WordList/WordSearchResultsController.swift b/Sources/iOSScenes/WordList/WordSearchResultsController.swift index 2e59d6b3..23f776dc 100644 --- a/Sources/iOSScenes/WordList/WordSearchResultsController.swift +++ b/Sources/iOSScenes/WordList/WordSearchResultsController.swift @@ -6,11 +6,10 @@ // import Domain -import iOSSupport +import IOSSupport import ReactorKit import SwinjectExtension import UIKit -import WordDetail public protocol WordSearchResultsControllerDelegate: AnyObject { @@ -152,13 +151,3 @@ extension WordSearchResultsController: UISearchResultsUpdating { } } - -// MARK: - WordDetailViewControllerDelegate - -extension WordSearchResultsController: WordDetailViewControllerDelegate { - - func willFinishInteraction() { - self.presentingViewController?.tabBarController?.dismiss(animated: true) - } - -} diff --git a/Sources/iOSSupport/Common/Domain.ThemeStyle+mapping.swift b/Sources/iOSSupport/Common/Domain.ThemeStyle+mapping.swift new file mode 100644 index 00000000..8e0b0649 --- /dev/null +++ b/Sources/iOSSupport/Common/Domain.ThemeStyle+mapping.swift @@ -0,0 +1,43 @@ +// +// Domain.ThemeStyle+mapping.swift +// iOSSupport +// +// Created by Jaewon Yun on 2/7/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import Domain +import UIKit + +extension Domain.ThemeStyle { + + public func toUIKit() -> UIUserInterfaceStyle { + switch self { + case .system: + return .unspecified + case .light: + return .light + case .dark: + return .dark + } + } + +} + +extension UIUserInterfaceStyle { + + public func toDomain() -> Domain.ThemeStyle { + switch self { + case .unspecified: + return .system + case .light: + return .light + case .dark: + return .dark + @unknown default: + assertionFailure("unkown cases.") + return .system + } + } + +} diff --git a/Sources/iOSSupport/Common/GlobalReactorAction.swift b/Sources/iOSSupport/Common/GlobalAction.swift similarity index 88% rename from Sources/iOSSupport/Common/GlobalReactorAction.swift rename to Sources/iOSSupport/Common/GlobalAction.swift index 7573acd0..8c788be6 100644 --- a/Sources/iOSSupport/Common/GlobalReactorAction.swift +++ b/Sources/iOSSupport/Common/GlobalAction.swift @@ -11,9 +11,9 @@ import Foundation import RxSwift import RxCocoa -public final class GlobalReactorAction { +public final class GlobalAction { - public static let shared: GlobalReactorAction = .init() + public static let shared: GlobalAction = .init() private init() {} diff --git a/Sources/iOSSupport/Common/GlobalState.swift b/Sources/iOSSupport/Common/GlobalState.swift index d355de0a..e4f51054 100644 --- a/Sources/iOSSupport/Common/GlobalState.swift +++ b/Sources/iOSSupport/Common/GlobalState.swift @@ -6,22 +6,35 @@ // Copyright © 2024 woin2ee. All rights reserved. // -import Foundation +import UIKit +import RxSwift +import RxRelay /// 앱의 전역 상태를 가지는 객체입니다. /// -/// - Warning: 앱의 전역 상태를 나타내므로 반드시 Singleton 으로 사용해야 합니다. +/// 전역 상태를 초기화하기 위해 `initialize()` 인스턴스 함수를 호출하는것이 권장됩니다. +/// +/// - Note: 앱의 전역 상태를 나타내므로 공유 객체로 정의되어 있습니다. public final class GlobalState { public static let shared: GlobalState = .init() - private init() {} + /// 고정된 상태 값으로 객체를 초기화합니다. + private init() { + self.hapticsIsOn = true + self.themeStyle = .init(value: .unspecified) + } + + public var hapticsIsOn: Bool - public var hapticsIsOn: Bool! + public var themeStyle: BehaviorRelay - /// 전역 상태를 초기화 합니다. - public func initialize(hapticsIsOn: Bool) { + /// 전역 상태를 초기화합니다. + /// + /// 전달된 파라미터를 이용하여 전역 상태를 초기화합니다. 이 함수를 호출하여 초기화하지 않으면, 내부 생성자에서 고정된 값으로 전역 상태가 초기화됩니다. + public func initialize(hapticsIsOn: Bool, themeStyle: UIUserInterfaceStyle) { self.hapticsIsOn = hapticsIsOn + self.themeStyle = .init(value: themeStyle) } } diff --git a/Sources/iOSSupport/Foundations/ViewControllerDelegate.swift b/Sources/iOSSupport/Foundations/ViewControllerDelegate.swift new file mode 100644 index 00000000..0c6d7805 --- /dev/null +++ b/Sources/iOSSupport/Foundations/ViewControllerDelegate.swift @@ -0,0 +1,25 @@ +// +// ViewControllerDelegate.swift +// iPhoneDriver +// +// Created by Jaewon Yun on 2/7/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import UIKit + +/// `UIViewController` 를 위한 기본적인 Delegate methods 가 정의되어 있는 Protocol 입니다. +public protocol ViewControllerDelegate { + + /// ViewController 가 Pop 되었음을 Delegate 에게 알립니다. + func viewControllerDidPop(_ viewController: UIViewController) + + /// ViewController 가 Dismiss 되어야 함을 Delegate 에게 알립니다. + /// + /// 이 Delegate method 를 구현하는 Subclass 는 직접 ViewController 에 대한 Dismiss 처리를 해야합니다. + func viewControllerMustBeDismissed(_ viewController: UIViewController) + + /// ViewController 가 Dismiss 되었음을 Delegate 에게 알립니다. + func viewControllerDidDismiss(_ viewController: UIViewController) + +} diff --git a/Sources/iOSSupport/Localization/WCString.swift b/Sources/iOSSupport/Localization/WCString.swift index 02b91c99..6c6efab1 100644 --- a/Sources/iOSSupport/Localization/WCString.swift +++ b/Sources/iOSSupport/Localization/WCString.swift @@ -63,6 +63,11 @@ public struct WCString { let localizedString = NSLocalizedString("%@_added_successfully", bundle: Bundle.module, comment: "단어 추가 완료 후 표시되는 메세지") return .init(format: localizedString, arguments: [word]) } + public static let already_added_word = NSLocalizedString("already_added_word", bundle: Bundle.module, comment: "") + public static func word_added_failed(word: String) -> String { + let localizedString = NSLocalizedString("%@_added_failed", bundle: Bundle.module, comment: "알 수 없는 이유로 단어 추가 실패 후 표시되는 메세지") + return .init(format: localizedString, arguments: [word]) + } public static let please_check_your_network_connection = NSLocalizedString("please_check_your_network_connection", bundle: Bundle.module, comment: "") @@ -77,9 +82,15 @@ public struct WCString { public static let hapticsSettingsFooterTextWhenHapticsIsOn = NSLocalizedString("hapticsSettingsFooterTextWhenHapticsIsOn", bundle: Bundle.module, comment: "") public static let hapticsSettingsFooterTextWhenHapticsIsOff = NSLocalizedString("hapticsSettingsFooterTextWhenHapticsIsOff", bundle: Bundle.module, comment: "") + public static let theme = NSLocalizedString("theme", bundle: Bundle.module, comment: "") + public static let system_mode = NSLocalizedString("system_mode", bundle: Bundle.module, comment: "") + public static let light_mode = NSLocalizedString("light_mode", bundle: Bundle.module, comment: "") + public static let dark_mode = NSLocalizedString("dark_mode", bundle: Bundle.module, comment: "") + public static let more_menu = NSLocalizedString("more_menu", bundle: Bundle.module, comment: "") public static let memorize_words = NSLocalizedString("memorize_words", bundle: Bundle.module, comment: "") public static let previous_word = NSLocalizedString("previous_word", bundle: Bundle.module, comment: "") public static let next_word = NSLocalizedString("next_word", bundle: Bundle.module, comment: "") + public static let duplicate_word = NSLocalizedString("duplicate_word", bundle: Bundle.module, comment: "") } diff --git a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift b/Sources/iOSSupport/UIKitExtension/Cells/ButtonCell.swift similarity index 75% rename from Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift rename to Sources/iOSSupport/UIKitExtension/Cells/ButtonCell.swift index 60536acf..5cfb0be2 100644 --- a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift +++ b/Sources/iOSSupport/UIKitExtension/Cells/ButtonCell.swift @@ -6,17 +6,21 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport import UIKit /// 버튼 역할을 하기 위한 Cell 클래스 입니다. /// /// 버튼 가이드라인에 따른 텍스트 색상을 사용하세요. -final class ButtonCell: UITableViewCell, ReusableIdentifying { +public final class ButtonCell: UITableViewCell, ReusableIdentifying { - struct Model { + public struct Model { let title: String let textColor: UIColor + + public init(title: String, textColor: UIColor) { + self.title = title + self.textColor = textColor + } } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -29,7 +33,7 @@ final class ButtonCell: UITableViewCell, ReusableIdentifying { fatalError("init(coder:) has not been implemented") } - func bind(model: Model) { + public func bind(model: Model) { var config: UIListContentConfiguration = .cell() config.text = model.title config.textProperties.color = model.textColor diff --git a/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift b/Sources/iOSSupport/UIKitExtension/Cells/DisclosureIndicatorCell.swift similarity index 72% rename from Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift rename to Sources/iOSSupport/UIKitExtension/Cells/DisclosureIndicatorCell.swift index 5054dd46..fd1717c9 100644 --- a/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift +++ b/Sources/iOSSupport/UIKitExtension/Cells/DisclosureIndicatorCell.swift @@ -6,14 +6,18 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport import UIKit -final class DisclosureIndicatorCell: UITableViewCell, ReusableIdentifying { +public final class DisclosureIndicatorCell: UITableViewCell, ReusableIdentifying { - struct Model { + public struct Model { let title: String let value: String? + + public init(title: String, value: String? = nil) { + self.title = title + self.value = value + } } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -26,7 +30,7 @@ final class DisclosureIndicatorCell: UITableViewCell, ReusableIdentifying { fatalError("init(coder:) has not been implemented") } - func bind(model: Model) { + public func bind(model: Model) { var config: UIListContentConfiguration = .valueCell() config.text = model.title config.secondaryText = model.value diff --git a/Sources/iPhoneDriver/Coordinators/AppCoordinator.swift b/Sources/iPhoneDriver/Coordinators/AppCoordinator.swift index 17fb93c2..51ce5516 100644 --- a/Sources/iPhoneDriver/Coordinators/AppCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/AppCoordinator.swift @@ -6,7 +6,7 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SFSafeSymbols import Then import UIKit diff --git a/Sources/iPhoneDriver/Coordinators/BasicCoordinator.swift b/Sources/iPhoneDriver/Coordinators/BasicCoordinator.swift new file mode 100644 index 00000000..1205b477 --- /dev/null +++ b/Sources/iPhoneDriver/Coordinators/BasicCoordinator.swift @@ -0,0 +1,56 @@ +// +// BasicCoordinator.swift +// iPhoneDriver +// +// Created by Jaewon Yun on 2/7/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +import IOSSupport +import UIKit +import Utility + +/// iOS 환경에서 동작하는 Coordinator 를 위한 Parent coordinator 클래스 +/// +/// - warning: Do not use instance of this class directly. Some methods in this class cause of fatal error. +class BasicCoordinator: Coordinator { + + weak var parentCoordinator: IOSSupport.Coordinator? + var childCoordinators: [IOSSupport.Coordinator] = [] + + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + abstractMethod() + } + + func start(with argument: Arg1) { + abstractMethod() + } + + func start(with arguments: Arg1, _ arg2: Arg2) { + abstractMethod() + } + +} + +extension BasicCoordinator: ViewControllerDelegate { + + func viewControllerDidPop(_ viewController: UIViewController) { + parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) + } + + func viewControllerMustBeDismissed(_ viewController: UIViewController) { + navigationController.dismiss(animated: true) // In this case, `navigationController` property is represented the presented view controller. + parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) + } + + func viewControllerDidDismiss(_ viewController: UIViewController) { + parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) + } + +} diff --git a/Sources/iPhoneDriver/Coordinators/GeneralSettingsCoordinator.swift b/Sources/iPhoneDriver/Coordinators/GeneralSettingsCoordinator.swift index abaedfd3..10abc12d 100644 --- a/Sources/iPhoneDriver/Coordinators/GeneralSettingsCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/GeneralSettingsCoordinator.swift @@ -7,22 +7,13 @@ // import GeneralSettings -import iOSSupport +import IOSSupport import UIKit import SwinjectDIContainer -final class GeneralSettingsCoordinator: Coordinator { +final class GeneralSettingsCoordinator: BasicCoordinator { - weak var parentCoordinator: iOSSupport.Coordinator? - var childCoordinators: [iOSSupport.Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { + override func start() { let viewController: GeneralSettingsViewControllerProtocol = DIContainer.shared.resolver.resolve() viewController.delegate = self navigationController.pushViewController(viewController, animated: true) @@ -32,9 +23,11 @@ final class GeneralSettingsCoordinator: Coordinator { extension GeneralSettingsCoordinator: GeneralSettingsViewControllerDelegate { - func willPopView() { - navigationController.popViewController(animated: true) - parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) + func didTapThemeSetting() { + let coordinator: ThemeSettingCoordinator = .init(navigationController: navigationController) + coordinator.parentCoordinator = self + self.childCoordinators.append(coordinator) + coordinator.start() } } diff --git a/Sources/iPhoneDriver/Coordinators/LanguageSettingCoordinator.swift b/Sources/iPhoneDriver/Coordinators/LanguageSettingCoordinator.swift index a1cded96..48fe5741 100644 --- a/Sources/iPhoneDriver/Coordinators/LanguageSettingCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/LanguageSettingCoordinator.swift @@ -6,24 +6,15 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import LanguageSetting import SwinjectDIContainer import SwinjectExtension import UIKit -final class LanguageSettingCoordinator: Coordinator { +final class LanguageSettingCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start(with argument: Arg1) { + override func start(with argument: Arg1) { let viewController: LanguageSettingViewControllerProtocol = DIContainer.shared.resolver.resolve(argument: argument) viewController.delegate = self navigationController.pushViewController(viewController, animated: true) @@ -32,10 +23,4 @@ final class LanguageSettingCoordinator: Coordinator { } extension LanguageSettingCoordinator: LanguageSettingViewControllerDelegate { - - func viewMustPop() { - navigationController.popViewController(animated: true) - parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) - } - } diff --git a/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift b/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift index 32f966d6..0595fe93 100644 --- a/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift @@ -6,36 +6,20 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport import PushNotificationSettings import SwinjectDIContainer import SwinjectExtension import UIKit -final class PushNotificationSettingsCoordinator: Coordinator { +final class PushNotificationSettingsCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { + override func start() { let viewController: PushNotificationSettingsViewControllerProtocol = DIContainer.shared.resolver.resolve() viewController.delegate = self - navigationController.pushViewController(viewController, animated: true) + self.navigationController.pushViewController(viewController, animated: true) } } extension PushNotificationSettingsCoordinator: PushNotificationSettingsDelegate { - - func willPopView() { - navigationController.popViewController(animated: true) - parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) - } - } diff --git a/Sources/iPhoneDriver/Coordinators/ThemeSettingCoordinator.swift b/Sources/iPhoneDriver/Coordinators/ThemeSettingCoordinator.swift new file mode 100644 index 00000000..d23377d4 --- /dev/null +++ b/Sources/iPhoneDriver/Coordinators/ThemeSettingCoordinator.swift @@ -0,0 +1,18 @@ +import IOSSupport +import SwinjectDIContainer +import SwinjectExtension +import UIKit +import ThemeSetting + +final class ThemeSettingCoordinator: BasicCoordinator { + + override func start() { + let viewController: ThemeSettingViewControllerProtocol = DIContainer.shared.resolver.resolve() + viewController.delegate = self + navigationController.pushViewController(viewController, animated: true) + } + +} + +extension ThemeSettingCoordinator: ThemeSettingViewControllerDelegate { +} diff --git a/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift b/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift index 5a51a2d4..12edd6d8 100644 --- a/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift @@ -6,26 +6,16 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import Domain -import iOSSupport +import IOSSupport import LanguageSetting import SwinjectDIContainer import SwinjectExtension import UIKit import UserSettings -final class UserSettingsCoordinator: Coordinator { +final class UserSettingsCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { + override func start() { let viewController: UserSettingsViewControllerProtocol = DIContainer.shared.resolver.resolve() viewController.delegate = self navigationController.setViewControllers([viewController], animated: false) diff --git a/Sources/iPhoneDriver/Coordinators/WordAdditionCoordinator.swift b/Sources/iPhoneDriver/Coordinators/WordAdditionCoordinator.swift index b74b6472..ee4f613d 100644 --- a/Sources/iPhoneDriver/Coordinators/WordAdditionCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/WordAdditionCoordinator.swift @@ -6,24 +6,15 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SwinjectDIContainer import SwinjectExtension import UIKit import WordAddition -final class WordAdditionCoordinator: Coordinator { +final class WordAdditionCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { + override func start() { let viewController: WordAdditionViewControllerProtocol = DIContainer.shared.resolver.resolve() viewController.delegate = self navigationController.setViewControllers([viewController], animated: true) @@ -32,10 +23,4 @@ final class WordAdditionCoordinator: Coordinator { } extension WordAdditionCoordinator: WordAdditionViewControllerDelegate { - - func didFinishInteration() { - navigationController.dismiss(animated: true) - parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) - } - } diff --git a/Sources/iPhoneDriver/Coordinators/WordCheckingCoordinator.swift b/Sources/iPhoneDriver/Coordinators/WordCheckingCoordinator.swift index cc5e7ab1..3138bfb0 100644 --- a/Sources/iPhoneDriver/Coordinators/WordCheckingCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/WordCheckingCoordinator.swift @@ -6,24 +6,15 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SwinjectDIContainer import SwinjectExtension import UIKit import WordChecking -final class WordCheckingCoordinator: Coordinator { +final class WordCheckingCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { + override func start() { let viewController: WordCheckingViewControllerProtocol = DIContainer.shared.resolver.resolve() navigationController.setViewControllers([viewController], animated: false) } diff --git a/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift b/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift index f815c34c..48c81afa 100644 --- a/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift @@ -6,24 +6,15 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SwinjectDIContainer import SwinjectExtension import UIKit import WordDetail -final class WordDetailCoordinator: Coordinator { +final class WordDetailCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start(with argument: Arg1) { + override func start(with argument: Arg1) { let viewController: WordDetailViewControllerProtocol = DIContainer.shared.resolver.resolve(argument: argument) viewController.delegate = self navigationController.setViewControllers([viewController], animated: false) @@ -32,10 +23,4 @@ final class WordDetailCoordinator: Coordinator { } extension WordDetailCoordinator: WordDetailViewControllerDelegate { - - func willFinishInteraction() { - navigationController.dismiss(animated: true) - parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) - } - } diff --git a/Sources/iPhoneDriver/Coordinators/WordListCoordinator.swift b/Sources/iPhoneDriver/Coordinators/WordListCoordinator.swift index 44427ece..8f4a7f04 100644 --- a/Sources/iPhoneDriver/Coordinators/WordListCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/WordListCoordinator.swift @@ -6,24 +6,15 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SwinjectDIContainer import SwinjectExtension import UIKit import WordList -final class WordListCoordinator: Coordinator { +final class WordListCoordinator: BasicCoordinator { - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { + override func start() { let viewController: WordListViewControllerProtocol = DIContainer.shared.resolver.resolve() viewController.delegate = self navigationController.setViewControllers([viewController], animated: false) diff --git a/Sources/iPhoneDriver/RootTabBarController.swift b/Sources/iPhoneDriver/RootTabBarController.swift index d3ec8f56..68fbba18 100644 --- a/Sources/iPhoneDriver/RootTabBarController.swift +++ b/Sources/iPhoneDriver/RootTabBarController.swift @@ -6,7 +6,7 @@ // Copyright © 2024 woin2ee. All rights reserved. // -import iOSSupport +import IOSSupport import SFSafeSymbols import Then import UIKit diff --git a/Sources/iPhoneDriver/SceneDelegate.swift b/Sources/iPhoneDriver/SceneDelegate.swift index 2bc6638f..978ceaf2 100644 --- a/Sources/iPhoneDriver/SceneDelegate.swift +++ b/Sources/iPhoneDriver/SceneDelegate.swift @@ -5,27 +5,29 @@ // Created by Jaewon Yun on 2023/08/23. // -import iOSSupport +import IOSSupport +import RxSwift import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { + let disposeBag: DisposeBag = .init() + var window: UIWindow? var appCoordinator: AppCoordinator? - let globalAction: GlobalReactorAction = .shared + let globalAction: GlobalAction = .shared + let globalState: GlobalState = .shared func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } window = .init(windowScene: windowScene) window?.makeKeyAndVisible() - let rootTabBarController: RootTabBarController = .shared - window?.rootViewController = rootTabBarController + setRootViewController() - appCoordinator = .init(rootTabBarController: rootTabBarController) - appCoordinator?.start() + subscribeGlobalAction() } func sceneWillEnterForeground(_ scene: UIScene) { @@ -36,4 +38,21 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { globalAction.sceneDidBecomeActive.accept(()) } + func setRootViewController() { + let rootTabBarController: RootTabBarController = .shared + window?.rootViewController = rootTabBarController + + appCoordinator = .init(rootTabBarController: rootTabBarController) + appCoordinator?.start() + } + + func subscribeGlobalAction() { + globalState.themeStyle + .asDriver() + .drive(with: self) { owner, userInterfaceStyle in + owner.window?.overrideUserInterfaceStyle = userInterfaceStyle + } + .disposed(by: disposeBag) + } + } diff --git a/TestPlans/FoundationExtension.xctestplan b/TestPlans/FoundationExtension.xctestplan deleted file mode 100644 index 6c803c28..00000000 --- a/TestPlans/FoundationExtension.xctestplan +++ /dev/null @@ -1,24 +0,0 @@ -{ - "configurations" : [ - { - "id" : "8F663F64-D2ED-466D-B509-5E41EF98731F", - "name" : "Configuration 1", - "options" : { - - } - } - ], - "defaultOptions" : { - "testTimeoutsEnabled" : true - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:WordChecker.xcodeproj", - "identifier" : "0D0A048C4E8A1F30CBB8F2BE", - "name" : "FoundationExtensionTests" - } - } - ], - "version" : 1 -} diff --git a/TestPlans/IntergrationTests.xctestplan b/TestPlans/IntergrationTests.xctestplan index 17d9cfe5..c04a423c 100644 --- a/TestPlans/IntergrationTests.xctestplan +++ b/TestPlans/IntergrationTests.xctestplan @@ -148,13 +148,6 @@ "name" : "WordListTests" } }, - { - "target" : { - "containerPath" : "container:WordChecker.xcodeproj", - "identifier" : "0D0A048C4E8A1F30CBB8F2BE", - "name" : "FoundationExtensionTests" - } - }, { "target" : { "containerPath" : "container:WordChecker.xcodeproj", @@ -175,6 +168,13 @@ "identifier" : "7C78BC2C06BF23C70DEE14AE", "name" : "InfrastructureTests" } + }, + { + "target" : { + "containerPath" : "container:WordChecker.xcodeproj", + "identifier" : "E5AF7A03E6E737AD9E4136ED", + "name" : "ThemeSettingTests" + } } ], "version" : 1 diff --git a/TestPlans/ThemeSetting.xctestplan b/TestPlans/ThemeSetting.xctestplan new file mode 100644 index 00000000..bff262de --- /dev/null +++ b/TestPlans/ThemeSetting.xctestplan @@ -0,0 +1,12 @@ +{ + "configurations" : [ + + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + + ], + "version" : 1 +} diff --git a/Tests/DomainTests/WordUseCaseTests.swift b/Tests/DomainTests/WordUseCaseTests.swift index 13cf1dbe..809be3bd 100644 --- a/Tests/DomainTests/WordUseCaseTests.swift +++ b/Tests/DomainTests/WordUseCaseTests.swift @@ -198,6 +198,50 @@ final class WordUseCaseTests: XCTestCase { XCTAssertNotEqual(try sut.getCurrentUnmemorizedWord().toBlocking().single(), oldCurrentWord) } + func test_addDuplicatedWord() throws { + // Given + let duplicatedWord = unmemorizedWordList[0] + + // When + let addNewWord = sut.addNewWord(duplicatedWord) + .toBlocking() + + // Then + XCTAssertThrowsError(try addNewWord.single()) { error in + switch error { + case WordUseCaseError.saveFailed(reason: .duplicatedWord): + break + default: + XCTFail() + } + } + } + + func test_isWordDuplicated() throws { + // Given + let duplicatedWord = unmemorizedWordList[0] + + // When + let isWordDuplicated = try sut.isWordDuplicated(duplicatedWord.word) + .toBlocking() + .single() + + // Then + XCTAssertTrue(isWordDuplicated) + } + + func test_throwError_whenUpdateToDuplicatedWord() { + // Given + let duplicatedWord: Word = .init(uuid: unmemorizedWordList[0].uuid, word: "J") // 단어 A 를 J(중복) 로 업데이트 + + // When + let updateWord = sut.updateWord(by: duplicatedWord.uuid, to: duplicatedWord) + .toBlocking() + + // Then + XCTAssertThrowsError(try updateWord.single()) + } + } // MARK: - Helpers diff --git a/Tests/FoundationExtensionTests/FoundationExtensionTests.swift b/Tests/FoundationExtensionTests/FoundationExtensionTests.swift deleted file mode 100644 index 7fce248d..00000000 --- a/Tests/FoundationExtensionTests/FoundationExtensionTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -@testable import FoundationExtension - -import XCTest - -final class FoundationExtensionTests: XCTestCase { - - func test_isNotEmpty() { - // Given - let string: String = "" - - // When - let isNotEmpty = string.isNotEmpty - - // Then - XCTAssertEqual(isNotEmpty, false) - } - -} diff --git a/Tests/InfrastructureTests/UserDefaults/UserSettingsRepositoryTests.swift b/Tests/InfrastructureTests/UserDefaults/UserSettingsRepositoryTests.swift index f1402b6d..49d551f8 100644 --- a/Tests/InfrastructureTests/UserDefaults/UserSettingsRepositoryTests.swift +++ b/Tests/InfrastructureTests/UserDefaults/UserSettingsRepositoryTests.swift @@ -35,7 +35,7 @@ final class UserSettingsRepositoryTests: XCTestCase { func testSaveAndGetUserSettings() throws { do { // Given - let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: .korean, hapticsIsOn: true) + let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: .korean, hapticsIsOn: true, themeStyle: .system) // When try sut.saveUserSettings(userSettings) @@ -50,11 +50,12 @@ final class UserSettingsRepositoryTests: XCTestCase { XCTAssertEqual(result.translationSourceLocale, .english) XCTAssertEqual(result.translationTargetLocale, .korean) XCTAssertEqual(result.hapticsIsOn, true) + XCTAssertEqual(result.themeStyle, .system) } do { // Given - let userSettings: UserSettings = .init(translationSourceLocale: .korean, translationTargetLocale: .english, hapticsIsOn: false) + let userSettings: UserSettings = .init(translationSourceLocale: .korean, translationTargetLocale: .english, hapticsIsOn: false, themeStyle: .dark) // When try sut.saveUserSettings(userSettings) @@ -69,6 +70,7 @@ final class UserSettingsRepositoryTests: XCTestCase { XCTAssertEqual(result.translationSourceLocale, .korean) XCTAssertEqual(result.translationTargetLocale, .english) XCTAssertEqual(result.hapticsIsOn, false) + XCTAssertEqual(result.themeStyle, .dark) } } diff --git a/Tests/WordCheckerUITests/Utilities/XCUIApplication+launchEnvironment.swift b/Tests/WordCheckerUITests/Utilities/XCUIApplication+launchEnvironment.swift index 9de20e5e..efa367c0 100644 --- a/Tests/WordCheckerUITests/Utilities/XCUIApplication+launchEnvironment.swift +++ b/Tests/WordCheckerUITests/Utilities/XCUIApplication+launchEnvironment.swift @@ -11,7 +11,7 @@ import XCTest extension XCUIApplication { - func setLaunchArguments(_ arguments: [LaunchArguments]) { + func setLaunchArguments(_ arguments: [LaunchArgument.Arguments]) { self.launchArguments = arguments.map(\.rawValue) } diff --git a/Tests/WordCheckerUITests/WordCheckerUITests.swift b/Tests/WordCheckerUITests/WordCheckerUITests.swift index 9a0b071c..ae0a8d0b 100644 --- a/Tests/WordCheckerUITests/WordCheckerUITests.swift +++ b/Tests/WordCheckerUITests/WordCheckerUITests.swift @@ -7,7 +7,7 @@ @testable import Domain -import iOSSupport +import IOSSupport import XCTest final class WordCheckerUITests: XCTestCase { diff --git a/Tests/iOSScenesTests/ThemeSettingTests/ThemeSettingTests.swift b/Tests/iOSScenesTests/ThemeSettingTests/ThemeSettingTests.swift new file mode 100644 index 00000000..52801d12 --- /dev/null +++ b/Tests/iOSScenesTests/ThemeSettingTests/ThemeSettingTests.swift @@ -0,0 +1,28 @@ +@testable import ThemeSetting + +import XCTest + +final class ThemeSettingTests: XCTestCase { + + var sut: ThemeSettingReactor! + + override func setUpWithError() throws { + try super.setUpWithError() + sut + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + sut = nil + } + + func test_example() { + // Given + + // When + + // Then + XCTAssertEqual("ThemeSettingKit", "ThemeSettingKit") + } + +} diff --git a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift index 4ee5a153..c28c779c 100644 --- a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift +++ b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift @@ -109,7 +109,7 @@ final class UserSettingsReactorTests: RxBaseTestCase { func test_viewDidLoad() { // Given let userSettingsUseCase: UserSettingsUseCaseFake = .init() - userSettingsUseCase.currentUserSettings = .init(translationSourceLocale: .german, translationTargetLocale: .italian, hapticsIsOn: true) + userSettingsUseCase.currentUserSettings = .init(translationSourceLocale: .german, translationTargetLocale: .italian, hapticsIsOn: true, themeStyle: .system) sut = .init( userSettingsUseCase: userSettingsUseCase, googleDriveUseCase: GoogleDriveUseCaseFake(scheduler: testScheduler), diff --git a/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift b/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift index 0f0d0615..8ceaa337 100644 --- a/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift +++ b/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift @@ -64,4 +64,15 @@ final class WordCheckingReactorTests: XCTestCase { XCTAssertNil(sut.currentState.currentWord) } + func test_addDuplicatedWord() { + // Given + sut.action.onNext(.addWord("testWord")) + + // When + sut.action.onNext(.addWord("TESTWORD")) + + // Then + XCTAssertNotNil(sut.currentState.showAddCompleteToast) + } + } diff --git a/Tests/iOSScenesTests/WordDetailTests/WordDetailReactorTests.swift b/Tests/iOSScenesTests/WordDetailTests/WordDetailReactorTests.swift new file mode 100644 index 00000000..25e77149 --- /dev/null +++ b/Tests/iOSScenesTests/WordDetailTests/WordDetailReactorTests.swift @@ -0,0 +1,81 @@ +// +// WordDetailReactorTests.swift +// WordDetailTests +// +// Created by Jaewon Yun on 2/13/24. +// Copyright © 2024 woin2ee. All rights reserved. +// + +@testable import WordDetail + +import Domain +import DomainTesting +import XCTest + +final class WordDetailReactorTests: XCTestCase { + + var sut: WordDetailReactor! + + override func tearDownWithError() throws { + try super.tearDownWithError() + sut = nil + } + + func test_enteredWordIsDuplicated() { + // Given + let uuid1: UUID = .init() + let word1: Word = .init(uuid: uuid1, word: "Word1") + + let uuid2: UUID = .init() + let word2: Word = .init(uuid: uuid2, word: "Word2") + + let wordUseCase = WordUseCaseFake() + wordUseCase._wordList = [word1, word2] + + sut = .init(uuid: word1.uuid, globalAction: .shared, wordUseCase: wordUseCase) + sut.action.onNext(.viewDidLoad) + + // When + sut.action.onNext(.enteredWord("Word2")) + + // Then + XCTAssertTrue(sut.currentState.enteredWordIsDuplicated) + } + + func test_enteredWordIsDuplicated_whenSameOriginWord() { + // Given + let uuid1: UUID = .init() + let word1: Word = .init(uuid: uuid1, word: "Word1") + + let wordUseCase = WordUseCaseFake() + wordUseCase._wordList = [word1] + + sut = .init(uuid: word1.uuid, globalAction: .shared, wordUseCase: wordUseCase) + sut.action.onNext(.viewDidLoad) + + // When + sut.action.onNext(.enteredWord("Word1")) + + // Then + XCTAssertFalse(sut.currentState.enteredWordIsDuplicated) + } + + func test_enteredWordIsEmpty() { + // Given + let uuid1: UUID = .init() + let word1: Word = .init(uuid: uuid1, word: "Word1") + + let wordUseCase = WordUseCaseFake() + wordUseCase._wordList = [word1] + + sut = .init(uuid: word1.uuid, globalAction: .shared, wordUseCase: wordUseCase) + sut.action.onNext(.viewDidLoad) + + // When + sut.action.onNext(.enteredWord("")) + + // Then + XCTAssertTrue(sut.currentState.enteredWordIsEmpty) + } + +} diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift index bc6f36d4..4b574472 100644 --- a/Tuist/Dependencies.swift +++ b/Tuist/Dependencies.swift @@ -43,6 +43,9 @@ let dependencies = Dependencies( // ReactorKit .remote(url: "https://github.com/ReactorKit/ReactorKit.git", requirement: .upToNextMajor(from: "3.0.0")), + // ReactorKit + .remote(url: "https://github.com/Woin2ee-Modules/FoundationPlus.git", + requirement: .upToNextMajor(from: "1.0.0")), ], platforms: [.iOS] ) diff --git a/Tuist/ProjectDescriptionHelpers/ExternalDependencyName.swift b/Tuist/ProjectDescriptionHelpers/ExternalDependencyName.swift index c061ab3b..1607f572 100644 --- a/Tuist/ProjectDescriptionHelpers/ExternalDependencyName.swift +++ b/Tuist/ProjectDescriptionHelpers/ExternalDependencyName.swift @@ -23,5 +23,6 @@ public struct ExternalDependencyName { public static let googleAPIClientForRESTCore = "GoogleAPIClientForRESTCore" public static let reactorKit = "ReactorKit" public static let swiftCollections = "Collections" + public static let foundationPlus = "FoundationPlus" } diff --git a/Tuist/Templates/Assembly.stencil b/Tuist/Templates/Assembly.stencil index 6aec4c96..625e07a0 100644 --- a/Tuist/Templates/Assembly.stencil +++ b/Tuist/Templates/Assembly.stencil @@ -7,6 +7,10 @@ public final class {{ name }}Assembly: Assembly { public init() {} public func assemble(container: Container) { + container.register({{ name }}Reactor.self) { _ in + return {{ name }}Reactor.init() + } + container.register({{ name }}ViewControllerProtocol.self) { resolver in return {{ name }}ViewController().then { $0.reactor = resolver.resolve() diff --git a/Tuist/Templates/iOSScene/iOSScene.swift b/Tuist/Templates/IOSScene/IOSScene.swift similarity index 68% rename from Tuist/Templates/iOSScene/iOSScene.swift rename to Tuist/Templates/IOSScene/IOSScene.swift index c65cce74..5a5609df 100644 --- a/Tuist/Templates/iOSScene/iOSScene.swift +++ b/Tuist/Templates/IOSScene/IOSScene.swift @@ -3,30 +3,30 @@ import ProjectDescription let nameAttribute: Template.Attribute = .required("name") let template = Template( - description: "Create default files for iOSScene.", + description: "Create default files for IOSScene.", attributes: [ nameAttribute, .optional("platform", default: "ios"), ], items: [ .file( - path: "Sources/iOSScenes/\(nameAttribute)/\(nameAttribute)ViewController.swift", + path: "Sources/IOSScenes/\(nameAttribute)/\(nameAttribute)ViewController.swift", templatePath: .relativeToCurrentFile("../ViewController.stencil") ), .file( - path: "Sources/iOSScenes/\(nameAttribute)/\(nameAttribute)Reactor.swift", + path: "Sources/IOSScenes/\(nameAttribute)/\(nameAttribute)Reactor.swift", templatePath: .relativeToCurrentFile("../Reactor.stencil") ), .file( - path: "Sources/iOSScenes/\(nameAttribute)/\(nameAttribute)Assembly.swift", + path: "Sources/IOSScenes/\(nameAttribute)/\(nameAttribute)Assembly.swift", templatePath: .relativeToCurrentFile("../Assembly.stencil") ), .file( - path: "Sources/iPhoneDriver/Coordinators/\(nameAttribute)Coordinator.swift", - templatePath: .relativeToCurrentFile("../iPhoneCoordinator.stencil") + path: "Sources/IPhoneDriver/Coordinators/\(nameAttribute)Coordinator.swift", + templatePath: .relativeToCurrentFile("../IPhoneCoordinator.stencil") ), .file( - path: "Tests/iOSScenesTests/\(nameAttribute)Tests/\(nameAttribute)Tests.swift", + path: "Tests/IOSScenesTests/\(nameAttribute)Tests/\(nameAttribute)Tests.swift", templatePath: .relativeToCurrentFile("../UnitTests.stencil") ), .file( diff --git a/Tuist/Templates/Reactor.stencil b/Tuist/Templates/Reactor.stencil index 26bc13be..4ab37f40 100644 --- a/Tuist/Templates/Reactor.stencil +++ b/Tuist/Templates/Reactor.stencil @@ -18,15 +18,15 @@ final class {{ name }}Reactor: Reactor { func mutate(action: Action) -> Observable { switch action { - + } } func reduce(state: State, mutation: Mutation) -> State { var state = state - + switch mutation { - + } return state diff --git a/Tuist/Templates/UnitTests.stencil b/Tuist/Templates/UnitTests.stencil index be775c69..b369e3b9 100644 --- a/Tuist/Templates/UnitTests.stencil +++ b/Tuist/Templates/UnitTests.stencil @@ -4,7 +4,7 @@ import XCTest final class {{ name }}Tests: XCTestCase { - var sut: <#Code#>! + var sut: {{ name }}Reactor! override func setUpWithError() throws { try super.setUpWithError() @@ -15,16 +15,14 @@ final class {{ name }}Tests: XCTestCase { try super.tearDownWithError() sut = nil } - + func test_example() { // Given - - + // When - - + // Then XCTAssertEqual("{{ name }}Kit", "{{ name }}Kit") } - + } diff --git a/Tuist/Templates/ViewController.stencil b/Tuist/Templates/ViewController.stencil index 81bc5333..7e1ece88 100644 --- a/Tuist/Templates/ViewController.stencil +++ b/Tuist/Templates/ViewController.stencil @@ -1,10 +1,8 @@ -import iOSSupport +import IOSSupport import ReactorKit import UIKit -public protocol {{ name }}ViewControllerDelegate: AnyObject { - // An example if you use navigation stack. - func willPopView() +public protocol {{ name }}ViewControllerDelegate: AnyObject, ViewControllerDelegate { } public protocol {{ name }}ViewControllerProtocol: UIViewController { diff --git a/Tuist/Templates/iPhoneCoordinator.stencil b/Tuist/Templates/iPhoneCoordinator.stencil new file mode 100644 index 00000000..22dbac7b --- /dev/null +++ b/Tuist/Templates/iPhoneCoordinator.stencil @@ -0,0 +1,26 @@ +import IOSSupport +import SwinjectDIContainer +import SwinjectExtension +import UIKit + +import {{ name }} + +final class {{ name }}Coordinator: BasicCoordinator { + + override func start() { + // An example if you push view controller. + let viewController: {{ name }}ViewControllerProtocol = DIContainer.shared.resolver.resolve() + viewController.delegate = self + navigationController.pushViewController(viewController, animated: true) + } + + /* If you want start to coordinator with arguments. + func start(with argument: Arg1) { + + } + */ + +} + +extension {{ name }}Coordinator: {{ name }}ViewControllerDelegate { +} diff --git a/Tuist/Templates/iPhoneCoorinator.stencil b/Tuist/Templates/iPhoneCoorinator.stencil deleted file mode 100644 index e5564921..00000000 --- a/Tuist/Templates/iPhoneCoorinator.stencil +++ /dev/null @@ -1,41 +0,0 @@ -import iOSSupport -import SwinjectDIContainer -import SwinjectExtension -import UIKit -import {{ name }} - -final class {{ name }}Coordinator: Coordinator { - - weak var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - - let navigationController: UINavigationController - - init(navigationController: UINavigationController) { - self.navigationController = navigationController - } - - func start() { - // An example if you use navigation stack. - let viewController: {{ name }}ViewControllerProtocol = DIContainer.shared.resolver.resolve() - viewController.delegate = self - navigationController.pushViewController(viewController, animated: true) - } - - /* If you want start to coordinator with arguments. - func start(with argument: Arg1) { - - } - */ - -} - -extension {{ name }}Coordinator: {{ name }}ViewControllerDelegate { - - // An example if you use navigation stack. - func willPopView() { - navigationController.popViewController(animated: true) - parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) - } - -} diff --git a/graph.png b/graph.png index 08753928..9479a285 100644 Binary files a/graph.png and b/graph.png differ