diff --git a/.github/workflows/create_new_release.yml b/.github/workflows/create_new_release.yml index 47b32c82..9766e32f 100644 --- a/.github/workflows/create_new_release.yml +++ b/.github/workflows/create_new_release.yml @@ -1,7 +1,6 @@ name: Create new release on: - workflow_dispatch push: # branches: # - main @@ -12,13 +11,12 @@ jobs: create_new_release: name: Create new release runs-on: ubuntu-latest - env: - CURRENT_TAG: ${{ github.ref_name }} permissions: contents: write steps: + - run: echo "Release for tag ${CURRENT_TAG}" - uses: actions/checkout@v3 - name: Create Release uses: ncipollo/release-action@v1.13.0 with: - bodyFile: "Changelog/${CURRENT_TAG}.md" + bodyFile: "Changelog/${{ github.ref_name }}.md" diff --git a/Changelog/1.3.0.md b/Changelog/1.3.0.md new file mode 100644 index 00000000..e22c2ae5 --- /dev/null +++ b/Changelog/1.3.0.md @@ -0,0 +1,5 @@ +## Features +- Add local push notification for daily reminder + +## Enhancement +- Enhance app stability diff --git a/Project.swift b/Project.swift index 75d9a045..8f2297a2 100644 --- a/Project.swift +++ b/Project.swift @@ -23,10 +23,12 @@ func targets() -> [Target] { .external(name: ExternalDependencyName.rxUtilityDynamic), .external(name: ExternalDependencyName.swinject), .external(name: ExternalDependencyName.swinjectExtension), + .external(name: ExternalDependencyName.then), ], hasTests: true, additionalTestDependencies: [ .target(name: "DataDriverTesting"), + .target(name: "TestsSupport"), .external(name: ExternalDependencyName.rxBlocking), ], appendSchemeTo: &schemes @@ -39,6 +41,11 @@ func targets() -> [Target] { ], appendSchemeTo: &disposedSchemes ) + + Target.module( + name: "FoundationExtension", + hasTests: true, + appendSchemeTo: &schemes + ) + Target.module( name: "Utility", scripts: [ @@ -193,6 +200,7 @@ func targets() -> [Target] { dependencies: [ .target(name: "Domain"), .target(name: "iOSSupport"), + .target(name: "FoundationExtension"), .external(name: ExternalDependencyName.rxSwift), .external(name: ExternalDependencyName.rxCocoa), .external(name: ExternalDependencyName.rxUtilityDynamic), @@ -237,6 +245,17 @@ func targets() -> [Target] { ], appendSchemeTo: &schemes ) + + Target.module( + name: "UserSettingsExample", + product: .app, + infoPlist: .file(path: "Resources/InfoPlist/InfoExample.plist"), + sourcesPrefix: "iOSScenes", + dependencies: [ + .target(name: "UserSettings"), + .target(name: "DomainTesting"), + ], + appendSchemeTo: &schemes + ) + Target.module( name: "LanguageSetting", sourcesPrefix: "iOSScenes", @@ -262,6 +281,37 @@ func targets() -> [Target] { ], appendSchemeTo: &schemes ) + + Target.module( + name: "PushNotificationSettings", + 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), + ], + hasTests: true, + additionalTestDependencies: [ + .target(name: "DomainTesting"), + .external(name: ExternalDependencyName.rxBlocking), + ], + appendSchemeTo: &schemes + ) + + Target.module( + name: "PushNotificationSettingsExample", + product: .app, + infoPlist: .file(path: "Resources/InfoPlist/InfoExample.plist"), + sourcesPrefix: "iOSScenes", + dependencies: [ + .target(name: "PushNotificationSettings"), + .target(name: "DomainTesting"), + ], + appendSchemeTo: &schemes + ) + Target.module( name: "iPhoneDriver", dependencies: [ @@ -272,6 +322,7 @@ func targets() -> [Target] { .target(name: "WordDetail"), .target(name: "UserSettings"), .target(name: "LanguageSetting"), + .target(name: "PushNotificationSettings"), .external(name: ExternalDependencyName.swinject), .external(name: ExternalDependencyName.swinjectDIContainer), .external(name: ExternalDependencyName.sfSafeSymbols), @@ -315,6 +366,7 @@ func targets() -> [Target] { + Target.module( name: "TestsSupport", dependencies: [ + .target(name: "Domain"), .external(name: ExternalDependencyName.rxSwift), .external(name: ExternalDependencyName.rxTest), ], @@ -398,6 +450,7 @@ let project: Project = .init( "TestPlans/", "Scripts/", ".gitignore", + "Project.swift", ], resourceSynthesizers: [] ) diff --git a/QA.md b/QA.md index c0ce8fbb..489df685 100644 --- a/QA.md +++ b/QA.md @@ -1,5 +1,8 @@ #Version -1.0.0 (build version: 13) +1.3.0 + +##Common +- Localization 적용 확인 ##WordCheking @@ -26,3 +29,4 @@ - Source language / Translation language 변경 정상 적용 - 구글 드라이브 로그인 여부에 따라 로그아웃 버튼 표시 +- 매일 알림 켜고 시간 설정한 뒤 홈화면에서 알림 제대로 오는지 확인 diff --git a/Resources/Domain/Localization/en.lproj/Localizable.strings b/Resources/Domain/Localization/en.lproj/Localizable.strings index 6ce45d2c..a8c3ef48 100644 --- a/Resources/Domain/Localization/en.lproj/Localizable.strings +++ b/Resources/Domain/Localization/en.lproj/Localizable.strings @@ -15,3 +15,5 @@ spanish = "Spanish"; italian = "Italian"; german = "German"; russian = "Russian"; + +daily_reminder = "Daily Reminder"; diff --git a/Resources/Domain/Localization/ko.lproj/Localizable.strings b/Resources/Domain/Localization/ko.lproj/Localizable.strings index 6012dacf..7cd58c69 100644 --- a/Resources/Domain/Localization/ko.lproj/Localizable.strings +++ b/Resources/Domain/Localization/ko.lproj/Localizable.strings @@ -15,3 +15,5 @@ spanish = "스페인어"; italian = "이탈리아어"; german = "독일어"; russian = "러시아어"; + +daily_reminder = "매일 알림"; diff --git a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings index 6d39f51e..e22a0b0e 100644 --- a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings @@ -51,8 +51,14 @@ google_drive_download = "Download data from Google Drive"; google_drive_download_successfully = "Successfully downloading from Google Drive"; google_drive_logout = "Google Drive Logout"; -signed_out_of_google_drive = "Signed out of Google Drive."; +signed_out_of_google_drive_successfully = "Signed out of Google Drive successfully"; synchronize_to_google_drive = "Synchronize to Google Drive."; please_check_your_network_connection = "Please check your network connection."; + +daily_reminder = "Daily Reminder"; +time = "Time"; +notifications = "Notifications"; +allow_notifications_is_required = "Allow notifications is required."; +dailyReminderFooter = "Sends a daily push notification at the time you set."; diff --git a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings index ca235191..2d44d952 100644 --- a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings @@ -51,8 +51,14 @@ google_drive_download = "구글 드라이브에서 데이터 다운로드"; google_drive_download_successfully = "구글 드라이브에서 다운로드 성공"; google_drive_logout = "구글 드라이브 로그아웃"; -signed_out_of_google_drive = "구글 드라이브에서 로그아웃되었습니다."; +signed_out_of_google_drive_successfully = "구글 드라이브에서 로그아웃되었습니다."; synchronize_to_google_drive = "구글 드라이브에 동기화 합니다."; please_check_your_network_connection = "네트워크 연결 상태를 확인해 주세요."; + +daily_reminder = "매일 알림"; +time = "시간"; +notifications = "알림"; +allow_notifications_is_required = "알림 허용이 필요합니다."; +dailyReminderFooter = "설정한 시각에 매일 푸시 알림을 보냅니다."; diff --git a/Sources/DataDriver/UserDefaults/DomainImplement/UserSettingsRepository.swift b/Sources/DataDriver/UserDefaults/DomainImplement/UserSettingsRepository.swift index 7cc0f40a..7d72be10 100644 --- a/Sources/DataDriver/UserDefaults/DomainImplement/UserSettingsRepository.swift +++ b/Sources/DataDriver/UserDefaults/DomainImplement/UserSettingsRepository.swift @@ -45,4 +45,14 @@ final class UserSettingsRepository: UserSettingsRepositoryProtocol { } } + func updateLatestDailyReminderTime(_ time: DateComponents) throws { + let result = userDefaults.setCodable(time, forKey: UserDefaultsKey.dailyReminderTime) + try result.get() + } + + func getLatestDailyReminderTime() throws -> DateComponents { + let result = userDefaults.object(DateComponents.self, forKey: UserDefaultsKey.dailyReminderTime) + return try result.get() + } + } diff --git a/Sources/DataDriver/UserDefaults/UserDefaultsKey.swift b/Sources/DataDriver/UserDefaults/UserDefaultsKey.swift index 659bb1fa..224fea84 100644 --- a/Sources/DataDriver/UserDefaults/UserDefaultsKey.swift +++ b/Sources/DataDriver/UserDefaults/UserDefaultsKey.swift @@ -15,6 +15,8 @@ enum UserDefaultsKey: UserDefaultsKeyProtocol, CaseIterable { case translationTargetLocale + case dailyReminderTime + /// 테스트용 Key 입니다. case test diff --git a/Sources/DataDriverTesting/UserSettingsRepositoryFake.swift b/Sources/DataDriverTesting/UserSettingsRepositoryFake.swift index f36be9ed..2e29a678 100644 --- a/Sources/DataDriverTesting/UserSettingsRepositoryFake.swift +++ b/Sources/DataDriverTesting/UserSettingsRepositoryFake.swift @@ -13,6 +13,7 @@ import RxSwift enum UserSettingsRepositoryError: Error { case notSavedUserSettings + case notSavedLatestDailyReminderTime } @@ -20,6 +21,8 @@ public final class UserSettingsRepositoryFake: UserSettingsRepositoryProtocol { public var _userSettings: UserSettings? + public var _latestDailyReminderTime: DateComponents? + public init() {} public func saveUserSettings(_ userSettings: Domain.UserSettings) -> RxSwift.Single { @@ -42,4 +45,16 @@ public final class UserSettingsRepositoryFake: UserSettingsRepositoryProtocol { } } + public func updateLatestDailyReminderTime(_ time: DateComponents) throws { + _latestDailyReminderTime = time + } + + public func getLatestDailyReminderTime() throws -> DateComponents { + guard let latestDailyReminderTime = _latestDailyReminderTime else { + throw UserSettingsRepositoryError.notSavedLatestDailyReminderTime + } + + return latestDailyReminderTime + } + } diff --git a/Sources/Domain/DI/UserSettingsUseCaseAssembly.swift b/Sources/Domain/DI/UserSettingsUseCaseAssembly.swift index b8a7903c..0b19d8d9 100644 --- a/Sources/Domain/DI/UserSettingsUseCaseAssembly.swift +++ b/Sources/Domain/DI/UserSettingsUseCaseAssembly.swift @@ -8,15 +8,22 @@ import Swinject import SwinjectExtension +import UserNotifications final class UserSettingsUseCaseAssembly: Assembly { func assemble(container: Container) { container.register(UserSettingsUseCaseProtocol.self) { resolver in let userSettingsRepository: UserSettingsRepositoryProtocol = resolver.resolve() - return UserSettingsUseCase.init(userSettingsRepository: userSettingsRepository) + + return UserSettingsUseCase.init( + userSettingsRepository: userSettingsRepository, + notificationCenter: UNUserNotificationCenter.current() + ) } .inObjectScope(.container) } } + +extension UNUserNotificationCenter: UserNotificationCenter {} diff --git a/Sources/Domain/Interfaces/Repositories/UserSettingsRepositoryProtocol.swift b/Sources/Domain/Interfaces/Repositories/UserSettingsRepositoryProtocol.swift index f0e47a1c..a49a5dda 100644 --- a/Sources/Domain/Interfaces/Repositories/UserSettingsRepositoryProtocol.swift +++ b/Sources/Domain/Interfaces/Repositories/UserSettingsRepositoryProtocol.swift @@ -15,4 +15,8 @@ public protocol UserSettingsRepositoryProtocol { func getUserSettings() -> Single + func updateLatestDailyReminderTime(_ time: DateComponents) throws + + func getLatestDailyReminderTime() throws -> DateComponents + } diff --git a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift index 71337e04..e6f5eacf 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift @@ -9,17 +9,35 @@ import Foundation import RxSwift import RxRelay +import UserNotifications public protocol UserSettingsUseCaseProtocol { - var currentUserSettingsRelay: BehaviorRelay { get } - func updateTranslationLocale(source sourceLocale: TranslationLanguage, target targetLocale: TranslationLanguage) -> Single - var currentTranslationLocale: Single<(source: TranslationLanguage, target: TranslationLanguage)> { get } + func getCurrentTranslationLocale() -> Single<(source: TranslationLanguage, target: TranslationLanguage)> + + func getCurrentUserSettings() -> Single + + /// Requests the user’s authorization to allow local and remote notifications for your app. + func requestNotificationAuthorization(with options: UNAuthorizationOptions) -> Single + + /// Retrieves the notification authorization status for your app. + /// + /// 이 함수가 반환하는 Single 시퀀스는 error 를 방출하지 않습니다. + func getNotificationAuthorizationStatus() -> Single + + /// 지정한 시각에 매일 알림을 설정합니다. + func setDailyReminder(at time: DateComponents) -> Single + + /// 설정된 매일 알림을 삭제합니다. + func removeDailyReminder() - func initUserSettings() -> Single + /// 설정되어 있는 매일 알림을 방출하는 시퀀스를 반환합니다. + /// - Returns: 설정된 매일 알림이 있는 경우 알림 객체를 반환합니다. 설정된 매일 알림이 없거나 알림이 꺼져있는 경우 `error` 이벤트를 방출합니다. + func getDailyReminder() -> Single - var currentUserSettings: Single { get } + /// 마지막으로 설정한 매일 알림의 시간을 반환합니다. + func getLatestDailyReminderTime() throws -> DateComponents } diff --git a/Sources/Domain/Interfaces/UseCases/WordRxUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/WordRxUseCaseProtocol.swift index fc4b87dd..3882c5c4 100644 --- a/Sources/Domain/Interfaces/UseCases/WordRxUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/WordRxUseCaseProtocol.swift @@ -32,6 +32,6 @@ public protocol WordRxUseCaseProtocol { func markCurrentWordAsMemorized(uuid: UUID) -> Single - var currentUnmemorizedWord: Word? { get } + func getCurrentUnmemorizedWord() -> Word? } diff --git a/Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift index ec32a10d..53aeb924 100644 --- a/Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/WordUseCaseProtocol.swift @@ -31,6 +31,6 @@ public protocol WordUseCaseProtocol { func markCurrentWordAsMemorized(uuid: UUID) - var currentUnmemorizedWord: Word? { get } + func getCurrentUnmemorizedWord() -> Word? } diff --git a/Sources/Domain/Localization/DomainString.swift b/Sources/Domain/Localization/DomainString.swift index f27676e1..88fc4195 100644 --- a/Sources/Domain/Localization/DomainString.swift +++ b/Sources/Domain/Localization/DomainString.swift @@ -22,4 +22,6 @@ struct DomainString { static let german = NSLocalizedString("german", bundle: Bundle.module, comment: "") static let russian = NSLocalizedString("russian", bundle: Bundle.module, comment: "") + static let daily_reminder = NSLocalizedString("daily_reminder", bundle: Bundle.module, comment: "") + } diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index 51a53aab..5f70786e 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -10,21 +10,35 @@ import Foundation import RxSwift import RxRelay import RxUtility +import Then +import UserNotifications +import Utility + +protocol UserNotificationCenter { + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) + func pendingNotificationRequests() async -> [UNNotificationRequest] + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func notificationSettings() async -> UNNotificationSettings +} public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { - let userSettingsRepository: UserSettingsRepositoryProtocol + /// Notification request 의 고유 ID + let DAILY_REMINDER_NOTIFICATION_ID: String = "DailyReminder" - public let currentUserSettingsRelay: BehaviorRelay + let userSettingsRepository: UserSettingsRepositoryProtocol + let notificationCenter: UserNotificationCenter - public init(userSettingsRepository: UserSettingsRepositoryProtocol) { + init( + userSettingsRepository: UserSettingsRepositoryProtocol, + notificationCenter: UserNotificationCenter + ) { self.userSettingsRepository = userSettingsRepository - self.currentUserSettingsRelay = .init(value: nil) + self.notificationCenter = notificationCenter - self.userSettingsRepository.getUserSettings() - .subscribe(onSuccess: { - self.currentUserSettingsRelay.accept($0) - }) + initUserSettingsIfNoUserSettings() + .subscribe() .dispose() } @@ -36,50 +50,145 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { newSettings.translationTargetLocale = targetLocale return newSettings } - .doOnSuccess { self.currentUserSettingsRelay.accept($0) } .flatMap { self.userSettingsRepository.saveUserSettings($0) } } - public var currentTranslationLocale: RxSwift.Single<(source: TranslationLanguage, target: TranslationLanguage)> { + public func getCurrentTranslationLocale() -> RxSwift.Single<(source: TranslationLanguage, target: TranslationLanguage)> { return userSettingsRepository.getUserSettings() .map { userSettings -> (source: TranslationLanguage, target: TranslationLanguage) in return (userSettings.translationSourceLocale, userSettings.translationTargetLocale) } } - public func initUserSettings() -> RxSwift.Single { - var translationTargetLocale: TranslationLanguage - - switch Locale.current.language.region?.identifier { - case "KR": - translationTargetLocale = .korean - case "CN": - translationTargetLocale = .chinese - case "FR": - translationTargetLocale = .french - case "DE": - translationTargetLocale = .german - case "IT": - translationTargetLocale = .italian - case "JP": - translationTargetLocale = .japanese - case "RU": - translationTargetLocale = .russian - case "ES": - translationTargetLocale = .spanish - default: - translationTargetLocale = .english + public func getCurrentUserSettings() -> Single { + return userSettingsRepository.getUserSettings() + } + + public func requestNotificationAuthorization(with options: UNAuthorizationOptions) -> Single { + return .create { observer in + Task { + let hasAuthorization = try await self.notificationCenter.requestAuthorization(options: options) + observer(.success(hasAuthorization)) + } catch: { error in + observer(.failure(error)) + } + + return Disposables.create() } + } - let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: translationTargetLocale) // FIXME: 처음에 Source Locale 설정 가능하게 (현재 .english 고정) + public func getNotificationAuthorizationStatus() -> Single { + return .create { observer in + Task { + let notificationSettings = await self.notificationCenter.notificationSettings() + observer(.success(notificationSettings.authorizationStatus)) + } - return userSettingsRepository.saveUserSettings(userSettings) - .flatMap { self.userSettingsRepository.getUserSettings() } - .doOnSuccess { self.currentUserSettingsRelay.accept($0) } + return Disposables.create() + } } - public var currentUserSettings: Single { + public func setDailyReminder(at time: DateComponents) -> Single { + let setDailyReminderSequence: Single = .create { observer in + let content: UNMutableNotificationContent = .init().then { + $0.body = DomainString.daily_reminder + $0.sound = .default + } + let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) + let notificationRequest: UNNotificationRequest = .init( + identifier: self.DAILY_REMINDER_NOTIFICATION_ID, + content: content, + trigger: trigger + ) + + do { + try self.userSettingsRepository.updateLatestDailyReminderTime(time) + } catch { + // TODO: 예외 상황 로그 추가 + } + + Task { + try await self.notificationCenter.add(notificationRequest) + observer(.success(())) + } catch: { error in + observer(.failure(error)) + } + + return Disposables.create() + } + + return self.getNotificationAuthorizationStatus() + .flatMap { authorizationStatus in + if authorizationStatus == .authorized { + return setDailyReminderSequence + } else { + return .error(UserSettingsUseCaseError.noNotificationAuthorization) + } + } + } + + public func removeDailyReminder() { + notificationCenter.removePendingNotificationRequests(withIdentifiers: [DAILY_REMINDER_NOTIFICATION_ID]) + } + + public func getDailyReminder() -> Single { + return .create { observer in + Task { + guard let dailyReminder = await self.notificationCenter.pendingNotificationRequests() + .filter({ $0.identifier == self.DAILY_REMINDER_NOTIFICATION_ID }) + .first + else { + observer(.failure(UserSettingsUseCaseError.noPendingDailyReminder)) + return + } + + observer(.success(dailyReminder)) + } + + return Disposables.create() + } + } + + public func getLatestDailyReminderTime() throws -> DateComponents { + return try userSettingsRepository.getLatestDailyReminderTime() + } + + func initUserSettingsIfNoUserSettings() -> RxSwift.Single { return userSettingsRepository.getUserSettings() + .mapToVoid() + .catch { _ in + var translationTargetLocale: TranslationLanguage + + switch Locale.current.language.region?.identifier { + case "KR": + translationTargetLocale = .korean + case "CN": + translationTargetLocale = .chinese + case "FR": + translationTargetLocale = .french + case "DE": + translationTargetLocale = .german + case "IT": + translationTargetLocale = .italian + case "JP": + translationTargetLocale = .japanese + case "RU": + translationTargetLocale = .russian + case "ES": + translationTargetLocale = .spanish + default: + translationTargetLocale = .english + } + + let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: translationTargetLocale) // FIXME: 처음에 Source Locale 설정 가능하게 (현재 .english 고정) + + return self.userSettingsRepository.saveUserSettings(userSettings) + } } } + +enum UserSettingsUseCaseError: Error { + case noPendingDailyReminder + case noNotificationAuthorization +} diff --git a/Sources/Domain/UseCases/WordRxUseCase.swift b/Sources/Domain/UseCases/WordRxUseCase.swift index 40337ac8..2157c642 100644 --- a/Sources/Domain/UseCases/WordRxUseCase.swift +++ b/Sources/Domain/UseCases/WordRxUseCase.swift @@ -128,8 +128,8 @@ public final class WordRxUseCase: WordRxUseCaseProtocol { } } - public var currentUnmemorizedWord: Word? { - wordUseCase.currentUnmemorizedWord + public func getCurrentUnmemorizedWord() -> Word? { + return wordUseCase.getCurrentUnmemorizedWord() } } diff --git a/Sources/Domain/UseCases/WordUseCase.swift b/Sources/Domain/UseCases/WordUseCase.swift index ff72ff51..e6eee52b 100644 --- a/Sources/Domain/UseCases/WordUseCase.swift +++ b/Sources/Domain/UseCases/WordUseCase.swift @@ -93,8 +93,8 @@ final class WordUseCase: WordUseCaseProtocol { wordRepository.save(currentWord) } - var currentUnmemorizedWord: Word? { - unmemorizedWordListRepository.getCurrentWord() + func getCurrentUnmemorizedWord() -> Word? { + return unmemorizedWordListRepository.getCurrentWord() } } diff --git a/Sources/Domain/ValueObjects/ProgressStatus.swift b/Sources/Domain/ValueObjects/ProgressStatus.swift index 3b698614..e4b95363 100644 --- a/Sources/Domain/ValueObjects/ProgressStatus.swift +++ b/Sources/Domain/ValueObjects/ProgressStatus.swift @@ -14,4 +14,6 @@ public enum ProgressStatus { case complete + case noTask + } diff --git a/Sources/DomainTesting/ExternalStoreUseCaseFake.swift b/Sources/DomainTesting/ExternalStoreUseCaseFake.swift index 3684c6e6..455af648 100644 --- a/Sources/DomainTesting/ExternalStoreUseCaseFake.swift +++ b/Sources/DomainTesting/ExternalStoreUseCaseFake.swift @@ -12,9 +12,13 @@ import RxSwift public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { + let scheduler: SchedulerType + public var _hasSigned: Bool = false - public init() {} + public init(scheduler: SchedulerType = ConcurrentDispatchQueueScheduler(qos: .userInitiated)) { + self.scheduler = scheduler + } public func signInWithAuthorization(presenting: Domain.PresentingConfiguration) -> RxSwift.Single { _hasSigned = true @@ -41,6 +45,7 @@ public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { return Disposables.create() } + .subscribe(on: scheduler) } if _hasSigned { @@ -67,6 +72,7 @@ public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { return Disposables.create() } + .subscribe(on: scheduler) } if _hasSigned { diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index 078a130b..55b74311 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -6,31 +6,95 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import Domain +@testable import Domain + import Foundation import RxSwift import RxRelay +import UserNotifications public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { - public var currentUserSettingsRelay: RxRelay.BehaviorRelay = .init(value: nil) + public var currentUserSettings: Domain.UserSettings = .init( + translationSourceLocale: .english, + translationTargetLocale: .korean + ) + + public var _authorizationStatus: UNAuthorizationStatus = .notDetermined + public var expectedAuthorizationStatus: UNAuthorizationStatus = .notDetermined + public var _dailyReminder: UNNotificationRequest? + public var dailyReminderIsRemoved: Bool = true - public init() {} + public init(expectedAuthorizationStatus: UNAuthorizationStatus) { + self.expectedAuthorizationStatus = expectedAuthorizationStatus + } public func updateTranslationLocale(source sourceLocale: Domain.TranslationLanguage, target targetLocale: Domain.TranslationLanguage) -> RxSwift.Single { - return .never() + currentUserSettings.translationSourceLocale = sourceLocale + currentUserSettings.translationTargetLocale = targetLocale + + return .just(()) + } + + public func getCurrentTranslationLocale() -> RxSwift.Single<(source: Domain.TranslationLanguage, target: Domain.TranslationLanguage)> { + return .just(( + source: currentUserSettings.translationSourceLocale, + target: currentUserSettings.translationTargetLocale)) + } + + public func getCurrentUserSettings() -> RxSwift.Single { + return .just(currentUserSettings) + } + + public func setDailyReminder(at time: DateComponents) -> RxSwift.Single { + if _authorizationStatus != .authorized { + return .error(UserSettingsUseCaseError.noNotificationAuthorization) + } + + dailyReminderIsRemoved = false + + let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) + _dailyReminder = .init(identifier: "Test", content: .init(), trigger: trigger) + return .just(()) + } + + public func removeDailyReminder() { + dailyReminderIsRemoved = true + } + + public func getDailyReminder() -> RxSwift.Single { + guard + _authorizationStatus == .authorized, + dailyReminderIsRemoved == false, + let dailyReminder = _dailyReminder + else { + return .error(UserSettingsUseCaseError.noPendingDailyReminder) + } + + return .just(dailyReminder) } - public var currentTranslationLocale: RxSwift.Single<(source: Domain.TranslationLanguage, target: Domain.TranslationLanguage)> { - return .never() + public func getLatestDailyReminderTime() throws -> DateComponents { + guard let trigger = _dailyReminder?.trigger as? UNCalendarNotificationTrigger else { + throw UserSettingsUseCaseError.noPendingDailyReminder + } + + return trigger.dateComponents } - public func initUserSettings() -> RxSwift.Single { - return .never() + /// Test - 현재 인증 상태를 미리 설정한 예상 인증 상태(`expectedAuthorizationStatus`)로 설정하고 `UNAuthorizationOptions.authorized` 일때만 true 를 방출합니다. + public func requestNotificationAuthorization(with options: UNAuthorizationOptions) -> RxSwift.Single { + _authorizationStatus = expectedAuthorizationStatus + + if _authorizationStatus == .authorized { + return .just(true) + } + + return .just(false) } - public var currentUserSettings: RxSwift.Single { - return .never() + public func getNotificationAuthorizationStatus() -> RxSwift.Single { + return .just(_authorizationStatus) } } diff --git a/Sources/DomainTesting/WordRxUseCaseFake.swift b/Sources/DomainTesting/WordRxUseCaseFake.swift index 90c27bf1..d606f361 100644 --- a/Sources/DomainTesting/WordRxUseCaseFake.swift +++ b/Sources/DomainTesting/WordRxUseCaseFake.swift @@ -74,8 +74,8 @@ public final class WordRxUseCaseFake: WordRxUseCaseProtocol { return .just(()) } - public var currentUnmemorizedWord: Domain.Word? { - wordUseCaseFake.currentUnmemorizedWord + public func getCurrentUnmemorizedWord() -> Domain.Word? { + return wordUseCaseFake.getCurrentUnmemorizedWord() } } diff --git a/Sources/DomainTesting/WordUseCaseFake.swift b/Sources/DomainTesting/WordUseCaseFake.swift index 31318a6a..a720e315 100644 --- a/Sources/DomainTesting/WordUseCaseFake.swift +++ b/Sources/DomainTesting/WordUseCaseFake.swift @@ -72,8 +72,8 @@ public final class WordUseCaseFake: WordUseCaseProtocol { _unmemorizedWordList.deleteWord(by: uuid) } - public var currentUnmemorizedWord: Domain.Word? { - _unmemorizedWordList.getCurrentWord() + public func getCurrentUnmemorizedWord() -> Domain.Word? { + return _unmemorizedWordList.getCurrentWord() } } diff --git a/Sources/iOSSupport/BasicExtensions/Collection+isNotEmpty.swift b/Sources/FoundationExtension/Collection+isNotEmpty.swift similarity index 100% rename from Sources/iOSSupport/BasicExtensions/Collection+isNotEmpty.swift rename to Sources/FoundationExtension/Collection+isNotEmpty.swift diff --git a/Sources/TestsSupport/UNUserNotificationCenterTesting.swift b/Sources/TestsSupport/UNUserNotificationCenterTesting.swift new file mode 100644 index 00000000..510a7472 --- /dev/null +++ b/Sources/TestsSupport/UNUserNotificationCenterTesting.swift @@ -0,0 +1,71 @@ +// +// UNUserNotificationCenterTesting.swift +// DomainTests +// +// Created by Jaewon Yun on 12/6/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +@testable import Domain + +import Foundation +import UserNotifications + +public final class UNUserNotificationCenterFake: UserNotificationCenter { + + public var _authorizationStatus: UNAuthorizationStatus = .notDetermined + public var _pendingNotifications: [UNNotificationRequest] = [] + + public init() { + + } + + public func add(_ request: UNNotificationRequest) async throws { + if let duplicatedIDIndex = _pendingNotifications.firstIndex(where: { $0.identifier == request.identifier }) { + _pendingNotifications[duplicatedIDIndex] = request + return + } + + _pendingNotifications.append(request) + } + + public func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + self._pendingNotifications.removeAll(where: { $0.identifier == identifier }) + } + } + + public func pendingNotificationRequests() async -> [UNNotificationRequest] { + return _pendingNotifications + } + + public func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + _authorizationStatus = .authorized + return true + } + + public func notificationSettings() async -> UNNotificationSettings { + let coder: NotificationSettingsCoder = .init() + coder.authorizationStatus = _authorizationStatus + return UNNotificationSettings(coder: coder)! + } + +} + +private final class NotificationSettingsCoder: NSCoder { + + var authorizationStatus: UNAuthorizationStatus! + + override func decodeInteger(forKey key: String) -> Int { + if key == "authorizationStatus" { + return authorizationStatus.rawValue + } + + return -1 + } + + override func decodeBool(forKey key: String) -> Bool { + return false + } + +} diff --git a/Sources/WordChecker/AppDelegate.swift b/Sources/WordChecker/AppDelegate.swift index b9671fc4..e074c9f2 100644 --- a/Sources/WordChecker/AppDelegate.swift +++ b/Sources/WordChecker/AppDelegate.swift @@ -9,6 +9,7 @@ import DataDriver import Domain import GoogleSignIn import LanguageSetting +import PushNotificationSettings import RxSwift import Swinject import SwinjectDIContainer @@ -25,7 +26,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { initDIContainer() - initUserSettingsIfFirstLaunch() restoreGoogleSignInState() NetworkMonitor.start() @@ -67,19 +67,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate { - func initUserSettingsIfFirstLaunch() { - let userSettingsUseCase: UserSettingsUseCaseProtocol = DIContainer.shared.resolver.resolve() - - userSettingsUseCase.currentUserSettings - .mapToVoid() - .catch { _ in - userSettingsUseCase.initUserSettings() - .mapToVoid() - } - .subscribe() - .dispose() - } - func restoreGoogleSignInState() { let googleDriveUseCase: ExternalStoreUseCaseProtocol = DIContainer.shared.resolver.resolve() googleDriveUseCase.restoreSignIn() @@ -98,6 +85,7 @@ extension AppDelegate { WordAdditionAssembly(), UserSettingsAssembly(), LanguageSettingAssembly(), + PushNotificationSettingsAssembly(), ]) } diff --git a/Sources/WordCheckerDev/AppDelegate.swift b/Sources/WordCheckerDev/AppDelegate.swift index d1d819f7..5692e94f 100644 --- a/Sources/WordCheckerDev/AppDelegate.swift +++ b/Sources/WordCheckerDev/AppDelegate.swift @@ -9,6 +9,7 @@ import DataDriver import Domain import GoogleSignIn import LanguageSetting +import PushNotificationSettings import RxSwift import Swinject import SwinjectDIContainer @@ -25,7 +26,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { initDIContainerForDev() - initUserSettingsIfFirstLaunch() NetworkMonitor.start() return true @@ -66,19 +66,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate { - func initUserSettingsIfFirstLaunch() { - let userSettingsUseCase: UserSettingsUseCaseProtocol = DIContainer.shared.resolver.resolve() - - userSettingsUseCase.currentUserSettings - .mapToVoid() - .catch { _ in - userSettingsUseCase.initUserSettings() - .mapToVoid() - } - .subscribe() - .dispose() - } - func initDIContainerForDev() { DIContainer.shared.assembler.apply(assemblies: [ DomainAssembly(), @@ -89,6 +76,7 @@ extension AppDelegate { WordAdditionAssembly(), UserSettingsAssembly(), LanguageSettingAssembly(), + PushNotificationSettingsAssembly(), ]) } diff --git a/Sources/iOSScenes/LanguageSetting/LanguageSettingViewModel.swift b/Sources/iOSScenes/LanguageSetting/LanguageSettingViewModel.swift index b12f085b..4e0eaa4d 100644 --- a/Sources/iOSScenes/LanguageSetting/LanguageSettingViewModel.swift +++ b/Sources/iOSScenes/LanguageSetting/LanguageSettingViewModel.swift @@ -34,7 +34,7 @@ public final class LanguageSettingViewModel: ViewModelType { public func transform(input: Input) -> Output { let selectableLocales = Driver.just(TranslationLanguage.allCases) - let currentTranslationLocale = userSettingsUseCase.currentTranslationLocale + let currentTranslationLocale = userSettingsUseCase.getCurrentTranslationLocale() .asSignalOnErrorJustComplete() let didSelectCell = input.selectCell diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift new file mode 100644 index 00000000..6834bea8 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift @@ -0,0 +1,39 @@ +// +// DatePickerCell.swift +// iOSSupport +// +// Created by Jaewon Yun on 12/1/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import UIKit + +final class DatePickerCell: RxBaseReusableCell { + + struct Model { + let title: String + let date: Date + } + + let trailingDatePicker: UIDatePicker = .init() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.accessoryView = trailingDatePicker + self.selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(model: Model) { + var config: UIListContentConfiguration = .cell() + config.text = model.title + self.contentConfiguration = config + trailingDatePicker.setDate(model.date, animated: false) + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift new file mode 100644 index 00000000..187aed38 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift @@ -0,0 +1,44 @@ +// +// ManualSwitchCell.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import UIKit + +final class ManualSwitchCell: RxBaseReusableCell { + + struct Model { + let title: String + let isOn: Bool + } + + let leadingLabel: UILabel = .init() + let trailingSwitch: UISwitch = .init() + let wrappingButton: UIButton = .init() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + trailingSwitch.addSubview(wrappingButton) + wrappingButton.frame = trailingSwitch.bounds + + self.accessoryView = trailingSwitch + self.selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(model: Model) { + var config: UIListContentConfiguration = .cell() + config.text = model.title + self.contentConfiguration = config + trailingSwitch.setOn(model.isOn, animated: true) + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift new file mode 100644 index 00000000..67b1a345 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift @@ -0,0 +1,32 @@ +// +// PushNotificationSettingsAssembly.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation +import Swinject +import SwinjectExtension +import Then + +public final class PushNotificationSettingsAssembly: Assembly { + + public init() {} + + public func assemble(container: Container) { + container.register(PushNotificationSettingsReactor.self) { resolver in + return .init(userSettingsUseCase: resolver.resolve(), globalAction: .shared) + } + + container.register(PushNotificationSettingsViewController.self) { resolver in + let reactor: PushNotificationSettingsReactor = resolver.resolve() + + return .init().then { + $0.reactor = reactor + } + } + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift new file mode 100644 index 00000000..659ffe50 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift @@ -0,0 +1,161 @@ +// +// PushNotificationSettingsReactor.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Domain +import Foundation +import iOSSupport +import ReactorKit + +public final class PushNotificationSettingsReactor: Reactor { + + public enum Action { + case reactorNeedsUpdate + case tapDailyReminderSwitch + case changeReminderTime(Date) + } + + public enum Mutation { + case enableDailyReminder + case disableDailyReminder + case setReminderTime(DateComponents) + case showNeedAuthAlert + case showMoveToAuthSettingAlert + } + + public struct State { + var isOnDailyReminder: Bool + var reminderTime: DateComponents + @Pulse var needAuthAlert: Void? + @Pulse var moveToAuthSettingAlert: Void? + } + + public let initialState: State = .init( + isOnDailyReminder: false, + reminderTime: .init(hour: 9, minute: 0) + ) + + let userSettingsUseCase: UserSettingsUseCaseProtocol + let globalAction: GlobalAction + + init(userSettingsUseCase: UserSettingsUseCaseProtocol, globalAction: GlobalAction) { + self.userSettingsUseCase = userSettingsUseCase + self.globalAction = globalAction + } + + public func transform(action: Observable) -> Observable { + return .merge([ + action, + globalAction.sceneWillEnterForeground + .map { Action.reactorNeedsUpdate }, + ]) + } + + public func mutate(action: Action) -> Observable { + switch action { + case .reactorNeedsUpdate: + let latestTime = try? userSettingsUseCase.getLatestDailyReminderTime() + + let setReminderTimeSequence: Observable = latestTime != nil + ? .just(.setReminderTime(latestTime!)) + : .empty() + + let setDailyReminderSequence = userSettingsUseCase.getDailyReminder() + .asObservable() + .map { _ in Mutation.enableDailyReminder } + .catch { _ in self.removeDailyReminder() } + + return .merge([ + setDailyReminderSequence, + setReminderTimeSequence, + ]) + + case .tapDailyReminderSwitch: + return userSettingsUseCase.getNotificationAuthorizationStatus() + .asObservable() + .flatMap { status -> Observable in + switch status { + case .notDetermined: // 권한이 결정되어 있지 않은 경우(대부분 첫 알림 설정) + return self.userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) + .asObservable() + .flatMap { hasAuthorization -> Observable in + if hasAuthorization { + return self.toggleDailyReminder() + } else { + return .merge([ + self.removeDailyReminder(), + .just(.showNeedAuthAlert), + ]) + } + } + case .authorized: // 알림 권한 승인 + return self.toggleDailyReminder() + default: // 알림 권한 거부 + return .merge([ + self.removeDailyReminder(), + .just(.showMoveToAuthSettingAlert), + ]) + } + } + + case .changeReminderTime(let date): + let hourAndMinute = Calendar.current.dateComponents([.hour, .minute], from: date) + return userSettingsUseCase.setDailyReminder(at: hourAndMinute) + .asObservable() + .map { Mutation.setReminderTime(hourAndMinute) } + } + } + + public func reduce(state: State, mutation: Mutation) -> State { + var state = state + + switch mutation { + case .enableDailyReminder: + state.isOnDailyReminder = true + case .disableDailyReminder: + state.isOnDailyReminder = false + case .setReminderTime(let dateComponents): + state.reminderTime = dateComponents + case .showNeedAuthAlert: + state.needAuthAlert = () + case .showMoveToAuthSettingAlert: + state.moveToAuthSettingAlert = () + } + + return state + } + +} + +// MARK: - Helpers + +extension PushNotificationSettingsReactor { + + /// 매일 알림을 설정하고 `Mutation Sequence` 를 반환합니다. + func setDailyReminder() -> Observable { + return userSettingsUseCase.setDailyReminder(at: self.currentState.reminderTime) + .asObservable() + .map { Mutation.enableDailyReminder } + .catchAndReturn(.disableDailyReminder) + } + + /// 매일 알림을 삭제하고 `Mutation Sequence` 를 반환합니다. + func removeDailyReminder() -> Observable { + userSettingsUseCase.removeDailyReminder() + return .just(.disableDailyReminder) + } + + /// 현재 설정되어 있는 매일 알림이 없으면 설정하고, 설정되어 있는 매일 알림이 있으면 삭제합니다. + func toggleDailyReminder() -> Observable { + if currentState.isOnDailyReminder { + return removeDailyReminder() + } else { + return setDailyReminder() + } + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift new file mode 100644 index 00000000..359b6a29 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift @@ -0,0 +1,32 @@ +// +// PushNotificationSettingsView.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import Then +import UIKit + +final class PushNotificationSettingsView: UITableView { + + let footerLabel: PaddingLabel = .init(padding: .init(top: 8, left: 20, bottom: 8, right: 20)).then { + $0.text = WCString.dailyReminderFooter + $0.font = .preferredFont(forTextStyle: .footnote) + $0.textColor = .secondaryLabel + } + + override init(frame: CGRect, style: UITableView.Style) { + super.init(frame: frame, style: style) + + self.register(ManualSwitchCell.self) + self.register(DatePickerCell.self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift new file mode 100644 index 00000000..03dfc5cf --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift @@ -0,0 +1,192 @@ +import iOSSupport +import ReactorKit +import Then +import UIKit + +public protocol PushNotificationSettingsDelegate: AnyObject { + func willPopView() +} + +public final class PushNotificationSettingsViewController: RxBaseViewController, View { + + enum SectionIdentifier { + case dailyReminder + } + + enum ItemIdentifier { + case dailyReminderSwitch + case dailyReminderTimeSetter + } + + lazy var rootTableView: PushNotificationSettingsView = .init(frame: .zero, style: .insetGrouped).then { + $0.delegate = self + } + + public weak var delegate: PushNotificationSettingsDelegate? + + lazy var dataSource: UITableViewDiffableDataSource = .init(tableView: rootTableView) { [weak self] tableView, indexPath, itemIdentifier in + guard let self = self else { return nil } + guard let reactor = self.reactor else { return nil } + + switch itemIdentifier { + case .dailyReminderSwitch: + let cell = tableView.dequeueReusableCell(ManualSwitchCell.self, for: indexPath) + cell.prepareForReuse() + cell.bind(model: .init(title: WCString.daily_reminder, isOn: reactor.currentState.isOnDailyReminder)) + // Bind to Reactor.Action + cell.wrappingButton.rx.tap + .map { _ in Reactor.Action.tapDailyReminderSwitch } + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + return cell + case .dailyReminderTimeSetter: + let cell = tableView.dequeueReusableCell(DatePickerCell.self, for: indexPath) + cell.prepareForReuse() + cell.trailingDatePicker.datePickerMode = .time + cell.trailingDatePicker.minuteInterval = 5 + + guard let date = Calendar.current.date(from: reactor.currentState.reminderTime) else { + preconditionFailure("Failed to create Date instance with DateComponents.") + } + + cell.bind(model: .init(title: WCString.time, date: date)) + // Bind to Reactor.Action + cell.trailingDatePicker.rx.date + .map { Reactor.Action.changeReminderTime($0) } + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + return cell + } + } + + private var isViewAppeared: Bool = false + + init() { + super.init(nibName: nil, bundle: nil) + initDataSource() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func loadView() { + self.view = rootTableView + } + + public override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .systemGroupedBackground + + setupNavigationBar() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + isViewAppeared = true + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if self.isMovingFromParent { + delegate?.willPopView() + } + } + + func initDataSource() { + var snapshot = dataSource.snapshot() + snapshot.appendSections([.dailyReminder]) + snapshot.appendItems([.dailyReminderSwitch]) + dataSource.apply(snapshot) + } + + func setupNavigationBar() { + self.navigationItem.title = WCString.notifications + self.navigationItem.largeTitleDisplayMode = .never + } + + public func bind(reactor: PushNotificationSettingsReactor) { + // Action + self.rx.sentMessage(#selector(viewDidLoad)) + .map { _ in Reactor.Action.reactorNeedsUpdate } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.state + .map(\.isOnDailyReminder) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, isOnDailyReminder in + var snapshot = owner.dataSource.snapshot() + + /// `isViewAppeared` 프로퍼티에 따라 애니메이션 적용 여부를 판단하여 Snapshot 을 적용합니다. + func applySnapshot() { + if owner.isViewAppeared { + owner.dataSource.apply(snapshot) + } else { + owner.dataSource.apply(snapshot, animatingDifferences: false) + } + } + + if isOnDailyReminder { + snapshot.appendItems([.dailyReminderTimeSetter], toSection: .dailyReminder) + } else { + snapshot.deleteItems([.dailyReminderTimeSetter]) + } + applySnapshot() + + snapshot.reconfigureItems([.dailyReminderSwitch]) + applySnapshot() + }) + .disposed(by: self.disposeBag) + + reactor.state + .map(\.reminderTime) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, _ in + guard owner.dataSource.indexPath(for: .dailyReminderTimeSetter) != nil else { + return + } + + var snapshot = owner.dataSource.snapshot() + snapshot.reconfigureItems([.dailyReminderTimeSetter]) + owner.dataSource.apply(snapshot) + }) + .disposed(by: self.disposeBag) + + reactor.pulse(\.$needAuthAlert) + .asDriverOnErrorJustComplete() + .drive(with: self) { owner, _ in + owner.presentOKAlert(title: WCString.notice, message: WCString.allow_notifications_is_required) + } + .disposed(by: self.disposeBag) + + reactor.pulse(\.$moveToAuthSettingAlert) + .asDriverOnErrorJustComplete() + .drive(with: self) { owner, _ in + owner.presentOKAlert(title: WCString.notice, message: WCString.allow_notifications_is_required) + } + .disposed(by: self.disposeBag) + } + +} + +extension PushNotificationSettingsViewController: UITableViewDelegate { + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } + + public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return rootTableView.footerLabel + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return rootTableView.footerLabel.intrinsicContentSize.height + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift b/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift new file mode 100644 index 00000000..a9c0ad16 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift @@ -0,0 +1,17 @@ +// +// AppDelegate.swift +// WordChecker +// +// Created by Jaewon Yun on 2023/08/23. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift b/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift new file mode 100644 index 00000000..d79f20f1 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift @@ -0,0 +1,41 @@ +// +// SceneDelegate.swift +// WordChecker +// +// Created by Jaewon Yun on 2023/08/23. +// + +@testable import PushNotificationSettings + +import DomainTesting +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + 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 viewController: PushNotificationSettingsViewController = .init() + let reactor: PushNotificationSettingsReactor = .init(userSettingsUseCase: UserSettingsUseCaseFake()) + viewController.reactor = reactor + + let navigationController: UINavigationController = .init(rootViewController: viewController) + navigationController.tabBarItem = .init(title: "Settings", image: .init(systemName: "gearshape"), tag: 0) + + let tabBarController: UITabBarController = .init() + + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + tabBarController.tabBar.standardAppearance = appearance + tabBarController.tabBar.scrollEdgeAppearance = appearance + + tabBarController.viewControllers = [navigationController] + + window?.rootViewController = tabBarController + } + +} diff --git a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift new file mode 100644 index 00000000..b59e5488 --- /dev/null +++ b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift @@ -0,0 +1,29 @@ +// +// ButtonCell.swift +// UserSettings +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import UIKit + +/// 버튼 역할을 하기 위한 Cell 클래스 입니다. +/// +/// 버튼 가이드라인에 따른 텍스트 색상을 사용하세요. +final class ButtonCell: UITableViewCell, ReusableIdentifying { + + struct Model { + let title: String + let textColor: UIColor + } + + func bind(model: Model) { + var config: UIListContentConfiguration = .cell() + config.text = model.title + config.textProperties.color = model.textColor + self.contentConfiguration = config + } + +} diff --git a/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift b/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift new file mode 100644 index 00000000..5054dd46 --- /dev/null +++ b/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift @@ -0,0 +1,36 @@ +// +// DisclosureIndicatorCell.swift +// UserSettings +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import UIKit + +final class DisclosureIndicatorCell: UITableViewCell, ReusableIdentifying { + + struct Model { + let title: String + let value: String? + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.accessoryType = .disclosureIndicator + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(model: Model) { + var config: UIListContentConfiguration = .valueCell() + config.text = model.title + config.secondaryText = model.value + self.contentConfiguration = config + } + +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsAssembly.swift b/Sources/iOSScenes/UserSettings/UserSettingsAssembly.swift index c0255788..296d8400 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsAssembly.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsAssembly.swift @@ -9,19 +9,24 @@ import Domain import Swinject import SwinjectExtension +import Then public final class UserSettingsAssembly: Assembly { public init() {} public func assemble(container: Container) { - container.register(UserSettingsViewController.self) { resolver in - let userSettingsUseCase: UserSettingsUseCaseProtocol = resolver.resolve() - let externalStoreUseCase: ExternalStoreUseCaseProtocol = resolver.resolve() - let viewModel: UserSettingsViewModel = .init(userSettingsUseCase: userSettingsUseCase, googleDriveUseCase: externalStoreUseCase) + container.register(UserSettingsReactor.self) { resolver in + return .init( + userSettingsUseCase: resolver.resolve(), + googleDriveUseCase: resolver.resolve(), + globalAction: .shared + ) + } + container.register(UserSettingsViewController.self) { resolver in return .init().then { - $0.viewModel = viewModel + $0.reactor = resolver.resolve() } } } diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift new file mode 100644 index 00000000..8c031bb0 --- /dev/null +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift @@ -0,0 +1,22 @@ +// +// UserSettingsItemIdentifier.swift +// UserSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +/// 화면에 아이템들이 실제 표시되는 순서대로 cases 가 선언되어 있습니다. +enum UserSettingsItemIdentifier { + + case changeSourceLanguage + case changeTargetLanguage + + case notifications + + case googleDriveUpload + case googleDriveDownload + + case googleDriveSignOut + +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift new file mode 100644 index 00000000..8c7ce7bf --- /dev/null +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift @@ -0,0 +1,14 @@ +// +// UserSettingsItemModel.swift +// UserSettings +// +// Created by Jaewon Yun on 11/28/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation + +enum UserSettingsItemModel { + case disclosureIndicator(DisclosureIndicatorCell.Model) + case button(ButtonCell.Model) +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift new file mode 100644 index 00000000..265d00e7 --- /dev/null +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift @@ -0,0 +1,17 @@ +// +// UserSettingsSectionIdentifier.swift +// iOSCore +// +// Created by Jaewon Yun on 2023/10/03. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation + +/// 화면에 섹션들이 실제 표시되는 순서대로 cases 가 선언되어 있습니다. +enum UserSettingsSectionIdentifier: Hashable, Sendable { + case changeLanguage + case notifications + case googleDriveSync + case signOut +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsItemModel.swift b/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsItemModel.swift deleted file mode 100644 index ba011962..00000000 --- a/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsItemModel.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// UserSettingsItemModel.swift -// iOSCore -// -// Created by Jaewon Yun on 2023/09/20. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import Domain -import Foundation -import iOSSupport - -struct UserSettingsItemModel: Hashable, Sendable { - - let settingType: SettingType - - var primaryText: String { - switch settingType { - case .changeSourceLanguage: - WCString.source_language - case .changeTargetLanguage: - WCString.translation_language - case .googleDriveUpload: - WCString.google_drive_upload - case .googleDriveDownload: - WCString.google_drive_download - case .googleDriveSignOut: - WCString.google_drive_logout - } - } - - var subtitle: String? - - var value: TranslationLanguage? - - init(settingType: SettingType, subtitle: String? = nil, value: TranslationLanguage? = nil) { - self.settingType = settingType - self.subtitle = subtitle - self.value = value - } - -} - -extension UserSettingsItemModel { - - enum SettingType: CaseIterable { - - case changeSourceLanguage - case changeTargetLanguage - - case googleDriveUpload - case googleDriveDownload - case googleDriveSignOut - - } - -} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsSection.swift b/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsSection.swift deleted file mode 100644 index 569b7898..00000000 --- a/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsSection.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// UserSettingsSection.swift -// iOSCore -// -// Created by Jaewon Yun on 2023/10/03. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import Foundation - -enum UserSettingsSection: Hashable, Sendable { - - case changeLanguage - - case googleDriveSync - - case signOut - -} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift b/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift new file mode 100644 index 00000000..56150fa5 --- /dev/null +++ b/Sources/iOSScenes/UserSettings/UserSettingsReactor.swift @@ -0,0 +1,145 @@ +// +// UserSettingsReactor.swift +// UserSettings +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Domain +import iOSSupport +import ReactorKit + +public final class UserSettingsReactor: Reactor { + + public enum Action { + case viewDidLoad + case uploadData(PresentingConfiguration) + case downloadData(PresentingConfiguration) + case signOut + } + + public enum Mutation { + case setSourceLanguage(TranslationLanguage) + case setTargetLanguage(TranslationLanguage) + case signIn + case signOut + case setUploadStatus(ProgressStatus) + case setDownloadStatus(ProgressStatus) + } + + public struct State { + @Pulse var showSignOutAlert: Void? + var sourceLanguage: TranslationLanguage + var targetLanguage: TranslationLanguage + var hasSigned: Bool + var uploadStatus: ProgressStatus + var downloadStatus: ProgressStatus + } + + public var initialState: State = .init( + sourceLanguage: .english, + targetLanguage: .korean, + hasSigned: false, + uploadStatus: .noTask, + downloadStatus: .noTask + ) + + let userSettingsUseCase: UserSettingsUseCaseProtocol + let googleDriveUseCase: ExternalStoreUseCaseProtocol + let globalAction: GlobalAction + + init( + userSettingsUseCase: UserSettingsUseCaseProtocol, + googleDriveUseCase: ExternalStoreUseCaseProtocol, + globalAction: GlobalAction + ) { + self.userSettingsUseCase = userSettingsUseCase + self.googleDriveUseCase = googleDriveUseCase + self.globalAction = globalAction + } + + public func mutate(action: Action) -> Observable { + switch action { + case .viewDidLoad: + let setCurrentLanguageSequence = userSettingsUseCase.getCurrentTranslationLocale() + .asObservable() + .flatMapFirst { (source: TranslationLanguage, target: TranslationLanguage) in + return Observable.merge([ + .just(.setSourceLanguage(source)), + .just(.setTargetLanguage(target)), + ]) + } + return .merge([ + .just(googleDriveUseCase.hasSigned ? .signIn : .signOut), + setCurrentLanguageSequence, + ]) + + case .uploadData(let presentingWindow): + return self.currentState.hasSigned + ? googleDriveUseCase.upload(presenting: presentingWindow) + .map(Mutation.setUploadStatus) + : .concat([ + googleDriveUseCase.signInWithAuthorization(presenting: presentingWindow) + .asObservable() + .map({ _ in Mutation.signIn }), + googleDriveUseCase.upload(presenting: presentingWindow) + .map(Mutation.setUploadStatus), + ]) + + case .downloadData(let presentingWindow): + return self.currentState.hasSigned + ? googleDriveUseCase.download(presenting: presentingWindow) + .map(Mutation.setDownloadStatus) + : .concat([ + googleDriveUseCase.signInWithAuthorization(presenting: presentingWindow) + .asObservable() + .map({ _ in Mutation.signIn }), + googleDriveUseCase.download(presenting: presentingWindow) + .doOnNext { progressStatus in + if progressStatus == .complete { + self.globalAction.didResetWordList.accept(()) + } + } + .map(Mutation.setDownloadStatus), + ]) + + case .signOut: + googleDriveUseCase.signOut() + return .just(.signOut) + } + } + + public func transform(mutation: Observable) -> Observable { + return .merge([ + mutation, + globalAction.didSetSourceLanguage + .map(Mutation.setSourceLanguage), + globalAction.didSetTargetLanguage + .map(Mutation.setTargetLanguage), + ]) + } + + public func reduce(state: State, mutation: Mutation) -> State { + var state = state + + switch mutation { + case .setSourceLanguage(let translationLanguage): + state.sourceLanguage = translationLanguage + case .setTargetLanguage(let translationLanguage): + state.targetLanguage = translationLanguage + case .signIn: + state.hasSigned = true + case .signOut: + state.hasSigned = false + state.showSignOutAlert = () + case .setUploadStatus(let progressStatus): + state.uploadStatus = progressStatus + case .setDownloadStatus(let progressStatus): + state.downloadStatus = progressStatus + } + + return state + } + +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index 578a620a..007dce48 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -8,9 +8,9 @@ import Domain import iOSSupport +import ReactorKit import RxSwift import RxUtility -import SnapKit import Then import UIKit @@ -20,174 +20,203 @@ public protocol UserSettingsViewControllerDelegate: AnyObject { func didTapTargetLanguageSettingRow(currentSettingLocale: TranslationLanguage) + func didTapNotificationsSettingRow() + } -public final class UserSettingsViewController: RxBaseViewController { +public final class UserSettingsViewController: RxBaseViewController, View { - let userSettingsCellID = "USER_SETTINGS_CELL" + private(set) var dataSourceModel: [UserSettingsItemIdentifier: UserSettingsItemModel] = [ + .changeSourceLanguage: .disclosureIndicator(.init(title: WCString.source_language, value: nil)), + .changeTargetLanguage: .disclosureIndicator(.init(title: WCString.translation_language, value: nil)), + .notifications: .disclosureIndicator(.init(title: WCString.notifications, value: nil)), + .googleDriveUpload: .button(.init(title: WCString.google_drive_upload, textColor: .systemBlue)), + .googleDriveDownload: .button(.init(title: WCString.google_drive_download, textColor: .systemBlue)), + .googleDriveSignOut: .button(.init(title: WCString.google_drive_logout, textColor: .systemRed)), + ] - var viewModel: UserSettingsViewModel! + lazy var settingsTableViewDataSource: UITableViewDiffableDataSource = .init(tableView: settingsTableView) { tableView, indexPath, id in + guard let itemModel = self.dataSourceModel[id] else { + preconditionFailure("dataSourceModel 에 해당 \(id) 를 가진 Item 이 없습니다.") + } - var settingsTableViewDataSource: UITableViewDiffableDataSource! + switch itemModel { + case .disclosureIndicator(let model): + let cell = tableView.dequeueReusableCell(DisclosureIndicatorCell.self, for: indexPath) + cell.bind(model: model) + return cell + case .button(let model): + let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) + cell.bind(model: model) + return cell + } + } public weak var delegate: UserSettingsViewControllerDelegate? lazy var settingsTableView: UITableView = .init(frame: .zero, style: .insetGrouped).then { - $0.backgroundColor = .systemGroupedBackground - $0.register(UITableViewCell.self, forCellReuseIdentifier: userSettingsCellID) + $0.register(DisclosureIndicatorCell.self) + $0.register(ButtonCell.self) + } + + public override func loadView() { + self.view = settingsTableView } public override func viewDidLoad() { super.viewDidLoad() + self.view.backgroundColor = .systemGroupedBackground + applyDefaultSnapshot() + } - setupDataSource() - applyDefualtSnapshot() - setupSubviews() + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) setupNavigationBar() - bindViewModel() } - func setupDataSource() { - self.settingsTableViewDataSource = .init(tableView: settingsTableView) { tableView, indexPath, item in - let cell = tableView.dequeueReusableCell(withIdentifier: self.userSettingsCellID, for: indexPath) + func applyDefaultSnapshot() { + var snapshot: NSDiffableDataSourceSnapshot = .init() + snapshot.appendSections([.changeLanguage, .notifications, .googleDriveSync]) - cell.accessoryType = .none - - var config: UIListContentConfiguration - - switch item.settingType { - case .changeSourceLanguage, .changeTargetLanguage: - config = .valueCell() - config.secondaryText = item.value?.localizedString + snapshot.appendItems( + [.changeSourceLanguage, .changeTargetLanguage], + toSection: .changeLanguage + ) - cell.accessoryType = .disclosureIndicator - case .googleDriveUpload, .googleDriveDownload: - config = .cell() - config.textProperties.color = .systemBlue - case .googleDriveSignOut: - config = .cell() - config.textProperties.color = .systemRed - } + snapshot.appendItems( + [.notifications], + toSection: .notifications + ) - config.text = item.primaryText + snapshot.appendItems( + [.googleDriveUpload, .googleDriveDownload], + toSection: .googleDriveSync + ) - cell.contentConfiguration = config - return cell - } + settingsTableViewDataSource.apply(snapshot) } - func applyDefualtSnapshot() { - var snapshot: NSDiffableDataSourceSnapshot = .init() - - snapshot.appendSections([.changeLanguage]) - snapshot.appendItems([ - .init(settingType: .changeSourceLanguage), - .init(settingType: .changeTargetLanguage), - ]) - - snapshot.appendSections([.googleDriveSync]) - snapshot.appendItems([ - .init(settingType: .googleDriveUpload), - .init(settingType: .googleDriveDownload), - ]) - - settingsTableViewDataSource.applySnapshotUsingReloadData(snapshot) + func setupNavigationBar() { + self.navigationItem.title = WCString.settings + self.navigationController?.navigationBar.prefersLargeTitles = true + self.navigationController?.navigationBar.sizeToFit() } // swiftlint:disable:next function_body_length - func bindViewModel() { + public func bind(reactor: UserSettingsReactor) { + // Action let itemSelectedEvent = settingsTableView.rx.itemSelected.asSignal() .doOnNext { [weak self] in self?.settingsTableView.deselectRow(at: $0, animated: true) } - let input: UserSettingsViewModel.Input = .init( - uploadTrigger: itemSelectedEvent - .filter({ $0.section == 1 && $0.row == 0 }) - .mapToVoid(), - downloadTrigger: itemSelectedEvent - .filter({ $0.section == 1 && $0.row == 1 }) - .mapToVoid(), - signOut: itemSelectedEvent - .filter({ $0.section == 2 && $0.row == 0 }) - .mapToVoid(), - presentingConfiguration: .just(.init(window: self)) - ) - let output = viewModel.transform(input: input) + let presentingWindow: PresentingConfiguration = .init(window: self) itemSelectedEvent - .filter({ $0.section == 0 && $0.row == 0 }) - .mapToVoid() - .withLatestFrom(output.currentTranslationSourceLanguage) - .emit(with: self, onNext: { owner, translationSourceLanguage in - owner.delegate?.didTapSourceLanguageSettingRow(currentSettingLocale: translationSourceLanguage) - }) - .disposed(by: disposeBag) + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .googleDriveUpload } + .map { _ in Reactor.Action.uploadData(presentingWindow) } + .emit(to: reactor.action) + .disposed(by: self.disposeBag) itemSelectedEvent - .filter({ $0.section == 0 && $0.row == 1 }) - .mapToVoid() - .withLatestFrom(output.currentTranslationTargetLanguage) - .emit(with: self, onNext: { owner, translationTargetLanguage in - owner.delegate?.didTapTargetLanguageSettingRow(currentSettingLocale: translationTargetLanguage) - }) - .disposed(by: disposeBag) + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .googleDriveDownload } + .map { _ in Reactor.Action.downloadData(presentingWindow) } + .emit(to: reactor.action) + .disposed(by: self.disposeBag) - output.currentTranslationSourceLanguage - .drive(with: self, onNext: { owner, translationSourceLanguage in - var snapshot = owner.settingsTableViewDataSource.snapshot() - let originItems = snapshot.itemIdentifiers(inSection: .changeLanguage) - - guard let targetIndex = originItems.firstIndex(where: { $0.settingType == .changeSourceLanguage }) else { - return - } - - var newItems = originItems - newItems[targetIndex].value = translationSourceLanguage + itemSelectedEvent + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .googleDriveSignOut } + .map { _ in Reactor.Action.signOut } + .emit(to: reactor.action) + .disposed(by: self.disposeBag) - snapshot.deleteItems(originItems) - snapshot.appendItems(newItems, toSection: .changeLanguage) + itemSelectedEvent + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .changeSourceLanguage } + .emit(with: self, onNext: { owner, _ in + owner.delegate?.didTapSourceLanguageSettingRow(currentSettingLocale: reactor.currentState.sourceLanguage) + }) + .disposed(by: self.disposeBag) - owner.settingsTableViewDataSource.apply(snapshot, animatingDifferences: false) + itemSelectedEvent + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .changeTargetLanguage } + .emit(with: self, onNext: { owner, _ in + owner.delegate?.didTapTargetLanguageSettingRow(currentSettingLocale: reactor.currentState.targetLanguage) }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - output.currentTranslationTargetLanguage - .drive(with: self, onNext: { owner, translationTargetLanguage in + itemSelectedEvent + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .notifications } + .emit(with: self, onNext: { owner, _ in + owner.delegate?.didTapNotificationsSettingRow() + }) + .disposed(by: self.disposeBag) + + self.rx.sentMessage(#selector(viewDidLoad)) + .map { _ in Reactor.Action.viewDidLoad } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.state + .map(\.hasSigned) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, hasSigned in var snapshot = owner.settingsTableViewDataSource.snapshot() - let originItems = snapshot.itemIdentifiers(inSection: .changeLanguage) - guard let targetIndex = originItems.firstIndex(where: { $0.settingType == .changeTargetLanguage }) else { - return + if hasSigned { + snapshot.insertSections([.signOut], afterSection: .googleDriveSync) + snapshot.appendItems( + [.googleDriveSignOut], + toSection: .signOut + ) + } else { + snapshot.deleteSections([.signOut]) } - var newItems = originItems - newItems[targetIndex].value = translationTargetLanguage - - snapshot.deleteItems(originItems) - snapshot.appendItems(newItems, toSection: .changeLanguage) + owner.settingsTableViewDataSource.apply(snapshot) + }) + .disposed(by: self.disposeBag) - owner.settingsTableViewDataSource.apply(snapshot, animatingDifferences: false) + reactor.pulse(\.$showSignOutAlert) + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, _ in + owner.presentOKAlert(title: WCString.notice, message: WCString.signed_out_of_google_drive_successfully) }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) + + reactor.state + .map(\.sourceLanguage) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, translationSourceLanguage in + owner.dataSourceModel[.changeSourceLanguage] = .disclosureIndicator(.init(title: WCString.source_language, value: translationSourceLanguage.localizedString)) - output.hasSigned - .drive(with: self, onNext: { owner, hasSigned in var snapshot = owner.settingsTableViewDataSource.snapshot() - snapshot.deleteSections([.signOut]) + snapshot.reconfigureItems([.changeSourceLanguage]) + owner.settingsTableViewDataSource.apply(snapshot) + }) + .disposed(by: self.disposeBag) - if hasSigned { - snapshot.appendSections([.signOut]) - snapshot.appendItems([ - .init(settingType: .googleDriveSignOut) - ]) - } + reactor.state + .map(\.targetLanguage) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, translationTargetLanguage in + owner.dataSourceModel[.changeTargetLanguage] = .disclosureIndicator(.init(title: WCString.translation_language, value: translationTargetLanguage.localizedString)) + var snapshot = owner.settingsTableViewDataSource.snapshot() + snapshot.reconfigureItems([.changeTargetLanguage]) owner.settingsTableViewDataSource.apply(snapshot) }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - output.uploadStatus - .emit(with: self, onNext: { owner, status in + reactor.state + .map(\.uploadStatus) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, status in switch status { + case .noTask: + break case .inProgress: ActivityIndicatorViewController.shared.startAnimating(on: self) case .complete: @@ -195,11 +224,16 @@ public final class UserSettingsViewController: RxBaseViewController { owner.presentOKAlert(title: WCString.notice, message: WCString.google_drive_upload_successfully) } }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - output.downloadStatus - .emit(with: self, onNext: { owner, status in + reactor.state + .map(\.downloadStatus) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, status in switch status { + case .noTask: + break case .inProgress: ActivityIndicatorViewController.shared.startAnimating(on: self) case .complete: @@ -207,26 +241,7 @@ public final class UserSettingsViewController: RxBaseViewController { owner.presentOKAlert(title: WCString.notice, message: WCString.google_drive_download_successfully) } }) - .disposed(by: disposeBag) - - output.signOut - .emit(with: self, onNext: { owner, _ in - owner.presentOKAlert(title: WCString.notice, message: WCString.signed_out_of_google_drive) - }) - .disposed(by: disposeBag) - } - - func setupSubviews() { - self.view.addSubview(settingsTableView) - - settingsTableView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - } - - func setupNavigationBar() { - self.navigationItem.title = WCString.settings - self.navigationController?.navigationBar.prefersLargeTitles = true + .disposed(by: self.disposeBag) } } diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewModel.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewModel.swift deleted file mode 100644 index 74a8e6ae..00000000 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewModel.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// UserSettingsViewModel.swift -// iOSCore -// -// Created by Jaewon Yun on 2023/09/20. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import Domain -import Foundation -import iOSSupport -import RxSwift -import RxCocoa -import RxUtility - -final class UserSettingsViewModel: ViewModelType { - - let userSettingsUseCase: UserSettingsUseCaseProtocol - let googleDriveUseCase: ExternalStoreUseCaseProtocol - - init(userSettingsUseCase: UserSettingsUseCaseProtocol, googleDriveUseCase: ExternalStoreUseCaseProtocol) { - self.userSettingsUseCase = userSettingsUseCase - self.googleDriveUseCase = googleDriveUseCase - } - - func transform(input: Input) -> Output { - let hasSigned: BehaviorRelay = .init(value: googleDriveUseCase.hasSigned) - - let currentTranslationSourceLanguage = userSettingsUseCase.currentUserSettingsRelay - .asDriver() - .compactMap(\.?.translationSourceLocale) - - let currentTranslationTargetLanguage = userSettingsUseCase.currentUserSettingsRelay - .asDriver() - .compactMap(\.?.translationTargetLocale) - - let uploadStatus = input.uploadTrigger - .withLatestFrom(input.presentingConfiguration) - .flatMapFirst { - return self.googleDriveUseCase.signInWithAuthorization(presenting: $0) - .doOnSuccess { hasSigned.accept((true)) } - .asSignalOnErrorJustComplete() - } - .flatMapFirst { - return self.googleDriveUseCase.upload(presenting: nil) - .asSignalOnErrorJustComplete() - } - - let downloadStatus = input.downloadTrigger - .withLatestFrom(input.presentingConfiguration) - .flatMapFirst { - return self.googleDriveUseCase.signInWithAuthorization(presenting: $0) - .doOnSuccess { hasSigned.accept((true)) } - .asSignalOnErrorJustComplete() - } - .flatMapFirst { - return self.googleDriveUseCase.download(presenting: nil) - .asSignalOnErrorJustComplete() - } - .doOnNext { progressStatus in - if progressStatus == .complete { - GlobalAction.shared.didResetWordList.accept(()) - } - } - - let signOut = input.signOut - .doOnNext { [weak self] _ in - self?.googleDriveUseCase.signOut() - hasSigned.accept(false) - } - - return .init( - hasSigned: hasSigned.asDriver(), - currentTranslationSourceLanguage: currentTranslationSourceLanguage, - currentTranslationTargetLanguage: currentTranslationTargetLanguage, - uploadStatus: uploadStatus, - downloadStatus: downloadStatus, - signOut: signOut - ) - } - -} - -extension UserSettingsViewModel { - - struct Input { - - let uploadTrigger: Signal - - let downloadTrigger: Signal - - let signOut: Signal - - let presentingConfiguration: Driver - - } - - struct Output { - - let hasSigned: Driver - - let currentTranslationSourceLanguage: Driver - - let currentTranslationTargetLanguage: Driver - - let uploadStatus: Signal - - let downloadStatus: Signal - - let signOut: Signal - - } - -} - -extension UserSettingsViewModel { - - enum InternalError: Error { - - case noCurrentSettingLocale - - case invalidSettingType - } -} diff --git a/Sources/iOSScenes/UserSettingsExample/AppDelegate.swift b/Sources/iOSScenes/UserSettingsExample/AppDelegate.swift new file mode 100644 index 00000000..a9c0ad16 --- /dev/null +++ b/Sources/iOSScenes/UserSettingsExample/AppDelegate.swift @@ -0,0 +1,17 @@ +// +// AppDelegate.swift +// WordChecker +// +// Created by Jaewon Yun on 2023/08/23. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + +} diff --git a/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift b/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift new file mode 100644 index 00000000..4cf7eb86 --- /dev/null +++ b/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift @@ -0,0 +1,45 @@ +// +// SceneDelegate.swift +// WordChecker +// +// Created by Jaewon Yun on 2023/08/23. +// + +@testable import UserSettings + +import DomainTesting +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + 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 viewController: UserSettingsViewController = .init() + let reactor: UserSettingsReactor = .init( + userSettingsUseCase: UserSettingsUseCaseFake(), + googleDriveUseCase: GoogleDriveUseCaseFake(), + globalAction: .shared + ) + viewController.reactor = reactor + + let navigationController: UINavigationController = .init(rootViewController: viewController) + navigationController.tabBarItem = .init(title: "Settings", image: .init(systemName: "gearshape"), tag: 0) + + let tabBarController: UITabBarController = .init() + + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + tabBarController.tabBar.standardAppearance = appearance + tabBarController.tabBar.scrollEdgeAppearance = appearance + + tabBarController.viewControllers = [navigationController] + + window?.rootViewController = tabBarController + } + +} diff --git a/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift b/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift index f1779bf7..f18a9b0c 100644 --- a/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift +++ b/Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift @@ -7,6 +7,7 @@ import Domain import Foundation +import FoundationExtension import iOSSupport import RxSwift import RxCocoa diff --git a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift index 95370254..6846cd9a 100644 --- a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift +++ b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift @@ -59,13 +59,13 @@ public final class WordCheckingReactor: Reactor { switch action { case .viewDidLoad: let initUnmemorizedWordList = wordUseCase.randomizeUnmemorizedWordList() - .map { Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) } + .map { Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } .asObservable() - let initTranslationSourceLanguage = userSettingsUseCase.currentTranslationLocale + let initTranslationSourceLanguage = userSettingsUseCase.getCurrentTranslationLocale() .map(\.source) .map { Mutation.setSourceLanguage($0) } .asObservable() - let initTranslationTargetLanguage = userSettingsUseCase.currentTranslationLocale + let initTranslationTargetLanguage = userSettingsUseCase.getCurrentTranslationLocale() .map(\.target) .map { Mutation.setTargetLanguage($0) } .asObservable() @@ -81,27 +81,27 @@ public final class WordCheckingReactor: Reactor { return wordUseCase.addNewWord(newWord) .asObservable() .map { - return Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) + return Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } case .updateToNextWord: return wordUseCase.updateToNextWord() .map { - return Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) + return Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } .asObservable() case .updateToPreviousWord: return wordUseCase.updateToPreviousWord() .map { - return Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) + return Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } .asObservable() case .shuffleWordList: return wordUseCase.randomizeUnmemorizedWordList() .map { - return Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) + return Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } .asObservable() @@ -112,7 +112,7 @@ public final class WordCheckingReactor: Reactor { return wordUseCase.deleteWord(by: uuid) .map { - return Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) + return Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } .asObservable() @@ -123,7 +123,7 @@ public final class WordCheckingReactor: Reactor { return wordUseCase.markCurrentWordAsMemorized(uuid: uuid) .map { - return Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) + return Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) } .asObservable() } @@ -137,12 +137,12 @@ public final class WordCheckingReactor: Reactor { globalAction.didSetTargetLanguage .map(Mutation.setTargetLanguage), globalAction.didEditWord - .map { _ in Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) }, + .map { _ in Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) }, globalAction.didDeleteWord .filter { $0.uuid == self.currentState.currentWord?.uuid } - .map { _ in Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) }, + .map { _ in Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) }, globalAction.didResetWordList - .map { _ in Mutation.setCurrentWord(self.wordUseCase.currentUnmemorizedWord) }, + .map { _ in Mutation.setCurrentWord(self.wordUseCase.getCurrentUnmemorizedWord()) }, ]) } diff --git a/Sources/iOSScenes/WordDetail/WordDetailViewController.swift b/Sources/iOSScenes/WordDetail/WordDetailViewController.swift index aa2491b6..26297c0d 100644 --- a/Sources/iOSScenes/WordDetail/WordDetailViewController.swift +++ b/Sources/iOSScenes/WordDetail/WordDetailViewController.swift @@ -15,7 +15,7 @@ import Utility public protocol WordDetailViewControllerDelegate: AnyObject { - func didFinishInteraction() + func willFinishInteraction() } @@ -102,10 +102,10 @@ public final class WordDetailViewController: RxBaseViewController { .drive(with: self) { owner, _ in if owner.reactor!.currentState.hasChanges { owner.presentDismissActionSheet { - owner.delegate?.didFinishInteraction() + owner.delegate?.willFinishInteraction() } } else { - owner.delegate?.didFinishInteraction() + owner.delegate?.willFinishInteraction() } } .disposed(by: self.disposeBag) @@ -125,7 +125,7 @@ extension WordDetailViewController: View { .disposed(by: self.disposeBag) doneBarButton.rx.tap - .doOnNext { [weak self] _ in self?.delegate?.didFinishInteraction() } + .doOnNext { [weak self] _ in self?.delegate?.willFinishInteraction() } .map { Reactor.Action.doneEditing } .bind(to: reactor.action) .disposed(by: self.disposeBag) @@ -181,12 +181,12 @@ extension WordDetailViewController: UIAdaptivePresentationControllerDelegate { public func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { self.presentDismissActionSheet { - self.delegate?.didFinishInteraction() + self.delegate?.willFinishInteraction() } } public func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - delegate?.didFinishInteraction() + delegate?.willFinishInteraction() } } diff --git a/Sources/iOSScenes/WordList/WordSearchResultsController.swift b/Sources/iOSScenes/WordList/WordSearchResultsController.swift index eaa18b10..b4510faa 100644 --- a/Sources/iOSScenes/WordList/WordSearchResultsController.swift +++ b/Sources/iOSScenes/WordList/WordSearchResultsController.swift @@ -108,7 +108,7 @@ extension WordSearchResultsController { let editedWord = self.searchedList[indexPath.row] guard let index = self.reactor?.currentState.wordList.firstIndex(of: editedWord) else { return } self.reactor?.action.onNext(.editWord(newWord, index)) - self.updateSearchedList(with: currentSearchBarText) // FIXME: 동기화 문제 발생 가능성 + self.updateSearchedList(with: currentSearchBarText) } alertController.addAction(cancelAction) alertController.addAction(completeAction) @@ -157,7 +157,7 @@ extension WordSearchResultsController: UISearchResultsUpdating { extension WordSearchResultsController: WordDetailViewControllerDelegate { - func didFinishInteraction() { + func willFinishInteraction() { self.presentingViewController?.tabBarController?.dismiss(animated: true) } diff --git a/Sources/iOSSupport/Common/Foundations/ReusableIdentifying.swift b/Sources/iOSSupport/Common/Foundations/ReusableIdentifying.swift new file mode 100644 index 00000000..d296e052 --- /dev/null +++ b/Sources/iOSSupport/Common/Foundations/ReusableIdentifying.swift @@ -0,0 +1,29 @@ +// +// ReusableIdentifying.swift +// iOSSupport +// +// Created by Jaewon Yun on 11/30/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation + +/// A protocol that provides a static variable `reuseIdentifier`. +public protocol ReusableIdentifying { + + /// A `reusableIdentifier` . + /// + /// If you don't override, it's concrete class name. + static var reuseIdentifier: String { get } + +} + +// MARK: - Default implemetation + +extension ReusableIdentifying { + + public static var reuseIdentifier: String { + return .init(describing: Self.self) + } + +} diff --git a/Sources/iOSSupport/Common/GlobalReactor.swift b/Sources/iOSSupport/Common/GlobalReactor.swift index 605d9364..8c788be6 100644 --- a/Sources/iOSSupport/Common/GlobalReactor.swift +++ b/Sources/iOSSupport/Common/GlobalReactor.swift @@ -26,4 +26,7 @@ public final class GlobalAction { public var didResetWordList: PublishRelay = .init() + public var sceneWillEnterForeground: PublishRelay = .init() + public var sceneDidBecomeActive: PublishRelay = .init() + } diff --git a/Sources/iOSSupport/Localization/WCString.swift b/Sources/iOSSupport/Localization/WCString.swift index 8d16c1d1..7d6ef8d0 100644 --- a/Sources/iOSSupport/Localization/WCString.swift +++ b/Sources/iOSSupport/Localization/WCString.swift @@ -41,7 +41,7 @@ public struct WCString { public static let google_drive_download = NSLocalizedString("google_drive_download", bundle: Bundle.module, comment: "") public static let google_drive_download_successfully = NSLocalizedString("google_drive_download_successfully", bundle: Bundle.module, comment: "") public static let google_drive_logout = NSLocalizedString("google_drive_logout", bundle: Bundle.module, comment: "") - public static let signed_out_of_google_drive = NSLocalizedString("signed_out_of_google_drive", bundle: Bundle.module, comment: "") + public static let signed_out_of_google_drive_successfully = NSLocalizedString("signed_out_of_google_drive_successfully", bundle: Bundle.module, comment: "") public static let synchronize_to_google_drive = NSLocalizedString("synchronize_to_google_drive", bundle: Bundle.module, comment: "") @@ -66,4 +66,10 @@ public struct WCString { public static let please_check_your_network_connection = NSLocalizedString("please_check_your_network_connection", bundle: Bundle.module, comment: "") + public static let daily_reminder = NSLocalizedString("daily_reminder", bundle: Bundle.module, comment: "") + public static let time = NSLocalizedString("time", bundle: Bundle.module, comment: "") + public static let notifications = NSLocalizedString("notifications", bundle: Bundle.module, comment: "") + public static let allow_notifications_is_required = NSLocalizedString("allow_notifications_is_required", bundle: Bundle.module, comment: "") + public static let dailyReminderFooter = NSLocalizedString("dailyReminderFooter", bundle: Bundle.module, comment: "") + } diff --git a/Sources/iOSSupport/UIKitExtension/PaddingLabel.swift b/Sources/iOSSupport/UIKitExtension/PaddingLabel.swift new file mode 100644 index 00000000..ffdb484e --- /dev/null +++ b/Sources/iOSSupport/UIKitExtension/PaddingLabel.swift @@ -0,0 +1,39 @@ +// +// PaddingLabel.swift +// iOSSupport +// +// Created by Jaewon Yun on 12/2/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import UIKit + +public final class PaddingLabel: UILabel { + + public var padding: UIEdgeInsets { + didSet { + self.setNeedsDisplay() + } + } + + public init(padding: UIEdgeInsets = .zero) { + self.padding = padding + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: padding)) + } + + public override var intrinsicContentSize: CGSize { + var contentSize = super.intrinsicContentSize + contentSize.width += (padding.left + padding.right) + contentSize.height += (padding.top + padding.bottom) + return contentSize + } + +} diff --git a/Sources/iOSSupport/UIKitExtension/RxBaseReusableCell.swift b/Sources/iOSSupport/UIKitExtension/RxBaseReusableCell.swift new file mode 100644 index 00000000..9fbcde1e --- /dev/null +++ b/Sources/iOSSupport/UIKitExtension/RxBaseReusableCell.swift @@ -0,0 +1,25 @@ +// +// RxBaseReusableCell.swift +// iOSSupport +// +// Created by Jaewon Yun on 12/2/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import RxSwift +import UIKit + +open class RxBaseReusableCell: UITableViewCell, ReusableIdentifying { + + /// Cell 이 재사용될 때 폐기할 subscriptions 를 모아놓는 disposeBag 입니다. + /// + /// `prepareForReuse()` 가 호출될 때 초기화 되면서 연관된 subscriptions 이 폐기됩니다. + public var disposeBag: DisposeBag = .init() + + open override func prepareForReuse() { + super.prepareForReuse() + + disposeBag = .init() + } + +} diff --git a/Sources/iOSSupport/BasicExtensions/UIButton+setBackgroundColor.swift b/Sources/iOSSupport/UIKitExtension/UIButton+setBackgroundColor.swift similarity index 100% rename from Sources/iOSSupport/BasicExtensions/UIButton+setBackgroundColor.swift rename to Sources/iOSSupport/UIKitExtension/UIButton+setBackgroundColor.swift diff --git a/Sources/iOSSupport/BasicExtensions/UIFont+preferredFont.swift b/Sources/iOSSupport/UIKitExtension/UIFont+preferredFont.swift similarity index 100% rename from Sources/iOSSupport/BasicExtensions/UIFont+preferredFont.swift rename to Sources/iOSSupport/UIKitExtension/UIFont+preferredFont.swift diff --git a/Sources/iOSSupport/UIKitExtension/UITableView+reusable.swift b/Sources/iOSSupport/UIKitExtension/UITableView+reusable.swift new file mode 100644 index 00000000..e0c3f809 --- /dev/null +++ b/Sources/iOSSupport/UIKitExtension/UITableView+reusable.swift @@ -0,0 +1,32 @@ +// +// UITableView+dequeueReusableCell.swift +// iOSSupport +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import UIKit + +extension UITableView { + + /// Returns a reusable table-view cell object for the class that conform to the `ReusableIdentifying` protocol and adds it to the table. + /// - Parameters: + /// - type: `ReusableIdentifying` 프로토콜을 준수하는 Cell 객체 타입. + /// - indexPath: The index path specifying the location of the cell. Always specify the index path provided to you by your data source object. This method uses the index path to perform additional configuration based on the cell’s position in the table view. + /// - Returns: `ReusableIdentifying` 프로토콜의 `reuseIdentifier` 와 연관된 재사용 가능한 Cell 입니다. + public func dequeueReusableCell(_ type: Cell.Type, for indexPath: IndexPath) -> Cell { + let cell = self.dequeueReusableCell(withIdentifier: type.reuseIdentifier, for: indexPath) + + guard let reusableCell = cell as? Cell else { + preconditionFailure("Failed to dequeue cell with '\(Cell.self)'type") + } + + return reusableCell + } + + public func register(_ cellClass: Cell.Type) { + self.register(cellClass.self, forCellReuseIdentifier: Cell.reuseIdentifier) + } + +} diff --git a/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift b/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift new file mode 100644 index 00000000..b9b30034 --- /dev/null +++ b/Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift @@ -0,0 +1,41 @@ +// +// PushNotificationSettingsCoordinator.swift +// iPhoneDriver +// +// Created by Jaewon Yun on 2023/12/08. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import PushNotificationSettings +import SwinjectDIContainer +import SwinjectExtension +import UIKit + +final class PushNotificationSettingsCoordinator: Coordinator { + + weak var parentCoordinator: Coordinator? + var childCoordinators: [Coordinator] = [] + + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let viewController: PushNotificationSettingsViewController = DIContainer.shared.resolver.resolve() + viewController.delegate = 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/UserSettingsCoordinator.swift b/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift index 3e2febb0..6c13d265 100644 --- a/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/UserSettingsCoordinator.swift @@ -49,4 +49,11 @@ extension UserSettingsCoordinator: UserSettingsViewControllerDelegate { coordinator.start(with: LanguageSettingViewModel.SettingsDirection.targetLanguage, currentSettingLocale) } + func didTapNotificationsSettingRow() { + let coordinator: PushNotificationSettingsCoordinator = .init(navigationController: navigationController) + coordinator.parentCoordinator = self + childCoordinators.append(coordinator) + coordinator.start() + } + } diff --git a/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift b/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift index ce4aa6dc..639bb4a0 100644 --- a/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift +++ b/Sources/iPhoneDriver/Coordinators/WordDetailCoordinator.swift @@ -33,7 +33,7 @@ final class WordDetailCoordinator: Coordinator { extension WordDetailCoordinator: WordDetailViewControllerDelegate { - func didFinishInteraction() { + func willFinishInteraction() { navigationController.dismiss(animated: true) parentCoordinator?.childCoordinators.removeAll(where: { $0 === self }) } diff --git a/Sources/iPhoneDriver/SceneDelegate.swift b/Sources/iPhoneDriver/SceneDelegate.swift index 2b828661..16ecf9d0 100644 --- a/Sources/iPhoneDriver/SceneDelegate.swift +++ b/Sources/iPhoneDriver/SceneDelegate.swift @@ -5,6 +5,7 @@ // Created by Jaewon Yun on 2023/08/23. // +import iOSSupport import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -13,6 +14,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var appCoordinator: AppCoordinator? + let globalAction: GlobalAction = .shared + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } window = .init(windowScene: windowScene) @@ -25,4 +28,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { appCoordinator?.start() } + func sceneWillEnterForeground(_ scene: UIScene) { + globalAction.sceneWillEnterForeground.accept(()) + } + + func sceneDidBecomeActive(_ scene: UIScene) { + globalAction.sceneDidBecomeActive.accept(()) + } + } diff --git a/TestPlans/FoundationExtension.xctestplan b/TestPlans/FoundationExtension.xctestplan new file mode 100644 index 00000000..6c803c28 --- /dev/null +++ b/TestPlans/FoundationExtension.xctestplan @@ -0,0 +1,24 @@ +{ + "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/PushNotificationSettings.xctestplan b/TestPlans/PushNotificationSettings.xctestplan new file mode 100644 index 00000000..3afa2792 --- /dev/null +++ b/TestPlans/PushNotificationSettings.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "33DC4C4D-6922-4D95-92A4-65E64DD26377", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:WordChecker.xcodeproj", + "identifier" : "3F3E981FC1A96E4F891CA1D4", + "name" : "PushNotificationSettingsTests" + } + } + ], + "version" : 1 +} diff --git a/TestPlans/WordCheckerIntergrationTests.xctestplan b/TestPlans/WordCheckerIntergrationTests.xctestplan index 7127ebc3..812ec470 100644 --- a/TestPlans/WordCheckerIntergrationTests.xctestplan +++ b/TestPlans/WordCheckerIntergrationTests.xctestplan @@ -164,6 +164,20 @@ "identifier" : "AA3329E574FC503FF669CE7B", "name" : "WordListTests" } + }, + { + "target" : { + "containerPath" : "container:WordChecker.xcodeproj", + "identifier" : "0D0A048C4E8A1F30CBB8F2BE", + "name" : "FoundationExtensionTests" + } + }, + { + "target" : { + "containerPath" : "container:WordChecker.xcodeproj", + "identifier" : "3F3E981FC1A96E4F891CA1D4", + "name" : "PushNotificationSettingsTests" + } } ], "version" : 1 diff --git a/Tests/DomainTests/SettingUseCaseTests.swift b/Tests/DomainTests/SettingUseCaseTests.swift deleted file mode 100644 index af7b3703..00000000 --- a/Tests/DomainTests/SettingUseCaseTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// SettingUseCaseTests.swift -// DomainUnitTests -// -// Created by Jaewon Yun on 2023/09/17. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import DataDriverTesting -import Domain -import RxBlocking -import XCTest - -final class SettingUseCaseTests: XCTestCase { - - var sut: UserSettingsUseCaseProtocol! - - override func setUpWithError() throws { - try super.setUpWithError() - - let userSettingsRepository: UserSettingsRepositoryProtocol = UserSettingsRepositoryFake() - - sut = UserSettingsUseCase.init(userSettingsRepository: userSettingsRepository) - } - - override func tearDownWithError() throws { - try super.tearDownWithError() - - sut = nil - } - - func testSetToOtherTranslationLocale() throws { - // Given - _ = try sut.initUserSettings() - .toBlocking() - .single() - - // Act1 - do { - try sut.updateTranslationLocale(source: .english, target: .korean) - .toBlocking() - .single() - - let currentTranslationLocale = try sut.currentTranslationLocale - .toBlocking() - .single() - - XCTAssertEqual(currentTranslationLocale.source, .english) - XCTAssertEqual(currentTranslationLocale.target, .korean) - } - - // Act2 - do { - try sut.updateTranslationLocale(source: .korean, target: .english) - .toBlocking() - .single() - - let currentTranslationLocale = try sut.currentTranslationLocale - .toBlocking() - .single() - - XCTAssertEqual(currentTranslationLocale.source, .korean) - XCTAssertEqual(currentTranslationLocale.target, .english) - } - } - -} diff --git a/Tests/DomainTests/UserSettingsUseCaseTests.swift b/Tests/DomainTests/UserSettingsUseCaseTests.swift new file mode 100644 index 00000000..55569c88 --- /dev/null +++ b/Tests/DomainTests/UserSettingsUseCaseTests.swift @@ -0,0 +1,181 @@ +// +// UserSettingsUseCaseTests.swift +// DomainUnitTests +// +// Created by Jaewon Yun on 2023/09/17. +// Copyright © 2023 woin2ee. All rights reserved. +// + +@testable import Domain + +import DataDriverTesting +import RxBlocking +import TestsSupport +import XCTest + +final class UserSettingsUseCaseTests: XCTestCase { + + var sut: UserSettingsUseCaseProtocol! + + override func setUpWithError() throws { + try super.setUpWithError() + + sut = UserSettingsUseCase.init( + userSettingsRepository: UserSettingsRepositoryFake(), + notificationCenter: UNUserNotificationCenterFake() + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + sut = nil + } + + func testSetToOtherTranslationLocale() throws { + // Act1 + do { + try sut.updateTranslationLocale(source: .english, target: .korean) + .toBlocking() + .single() + + let currentTranslationLocale = try sut.getCurrentTranslationLocale() + .toBlocking() + .single() + + XCTAssertEqual(currentTranslationLocale.source, .english) + XCTAssertEqual(currentTranslationLocale.target, .korean) + } + + // Act2 + do { + try sut.updateTranslationLocale(source: .korean, target: .english) + .toBlocking() + .single() + + let currentTranslationLocale = try sut.getCurrentTranslationLocale() + .toBlocking() + .single() + + XCTAssertEqual(currentTranslationLocale.source, .korean) + XCTAssertEqual(currentTranslationLocale.target, .english) + } + } + + func test_setDailyReminder_whenNoAuthorized() { + // Given + let time: DateComponents = .init(hour: 11, minute: 11) + + // When + do { + try sut.setDailyReminder(at: time) + .toBlocking() + .single() + + XCTFail("에러 발생하지 않음") + } + + // Then + catch { + XCTAssertEqual(error as? UserSettingsUseCaseError, UserSettingsUseCaseError.noNotificationAuthorization) + } + } + + func test_setDailyReminder_whenAuthorized() throws { + // Given + let isAuthorized = try sut.requestNotificationAuthorization(with: .alert) + .toBlocking() + .single() + XCTAssertTrue(isAuthorized) + + let time: DateComponents = .init(hour: 11, minute: 11) + + // When + try sut.setDailyReminder(at: time) + .toBlocking() + .single() + + // Then + let dailyReminder = try sut.getDailyReminder() + .toBlocking() + .single() + + XCTAssertEqual((dailyReminder.trigger as? UNCalendarNotificationTrigger)?.dateComponents, time) + } + + func test_getLatestDailyReminderTime_whenNeverSetDailyReminder() { + XCTAssertThrowsError(try sut.getLatestDailyReminderTime()) + } + + func test_removeDailyReminder() throws { + // Given + let isAuthorized = try sut.requestNotificationAuthorization(with: .alert) + .toBlocking() + .single() + XCTAssertTrue(isAuthorized) + + let time: DateComponents = .init(hour: 11, minute: 11) + try sut.setDailyReminder(at: time) + .toBlocking() + .single() + + // When + sut.removeDailyReminder() + + // Then + XCTAssertThrowsError( + try sut.getDailyReminder() + .toBlocking() + .single() + ) + } + + func test_getLatestDailyReminderTime_afterTurnOffDailyReminder() throws { + // Given + let isAuthorized = try sut.requestNotificationAuthorization(with: .alert) + .toBlocking() + .single() + XCTAssertTrue(isAuthorized) + + let time: DateComponents = .init(hour: 11, minute: 11) + try sut.setDailyReminder(at: time) + .toBlocking() + .single() + sut.removeDailyReminder() + + // When + let latestTime = try sut.getLatestDailyReminderTime() + + // Then + XCTAssertEqual(latestTime, time) + } + + func test_updateDailyReminerTime() throws { + // Given + let isAuthorized = try sut.requestNotificationAuthorization(with: .alert) + .toBlocking() + .single() + XCTAssertTrue(isAuthorized) + + let time: DateComponents = .init(hour: 11, minute: 11) + try sut.setDailyReminder(at: time) + .toBlocking() + .single() + + // When + let newTime: DateComponents = .init(hour: 12, minute: 12) + try sut.setDailyReminder(at: newTime) + .toBlocking() + .single() + + // Then + let latestTime = try sut.getLatestDailyReminderTime() + XCTAssertEqual(latestTime, newTime) + + let dailyReminder = try sut.getDailyReminder() + .toBlocking() + .single() + XCTAssertEqual((dailyReminder.trigger as? UNCalendarNotificationTrigger)?.dateComponents, newTime) + } + +} diff --git a/Tests/DomainTests/WordUseCaseTests.swift b/Tests/DomainTests/WordUseCaseTests.swift index 9b40687c..9e034c63 100644 --- a/Tests/DomainTests/WordUseCaseTests.swift +++ b/Tests/DomainTests/WordUseCaseTests.swift @@ -15,9 +15,6 @@ final class WordUseCaseTests: XCTestCase { var sut: WordUseCaseProtocol! - var wordRepository: WordRepositoryFake! - var unmemorizedWordListRepository: UnmemorizedWordListRepositorySpy! - let memorizedWordList: [Word] = [ .init(word: "F", memorizedState: .memorized), .init(word: "G", memorizedState: .memorized), @@ -37,38 +34,16 @@ final class WordUseCaseTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - wordRepository = makePreparedWordRepository() - unmemorizedWordListRepository = makePreparedUnmemorizedWordListRepository() - sut = WordUseCase.init( - wordRepository: wordRepository, - unmemorizedWordListRepository: unmemorizedWordListRepository + wordRepository: makePreparedWordRepository(), + unmemorizedWordListRepository: makePreparedUnmemorizedWordListRepository() ) } - func makePreparedWordRepository() -> WordRepositoryFake { - let repository = WordRepositoryFake() - zip(memorizedWordList, unmemorizedWordList).forEach { - repository.save($0) - repository.save($1) - } - return repository - } - - func makePreparedUnmemorizedWordListRepository() -> UnmemorizedWordListRepositorySpy { - let repository: UnmemorizedWordListRepositorySpy = .init() - unmemorizedWordList.forEach { - repository.addWord($0) - } - return repository - } - override func tearDownWithError() throws { try super.tearDownWithError() sut = nil - wordRepository = nil - unmemorizedWordListRepository = nil } func test_addNewWord() { @@ -82,8 +57,7 @@ final class WordUseCaseTests: XCTestCase { sut.addNewWord(testWord) // Assert - XCTAssert(wordRepository._wordList.contains(where: { $0.uuid == testUUID })) - XCTAssert(unmemorizedWordListRepository._storedWords.contains(where: { $0.uuid == testUUID })) + XCTAssertEqual(sut.getWord(by: testUUID), testWord) } func test_deleteUnmemorizedWord() { @@ -96,8 +70,8 @@ final class WordUseCaseTests: XCTestCase { sut.deleteWord(by: deleteTarget.uuid) // Assert - XCTAssertFalse(wordRepository._wordList.contains(where: { $0.uuid == deleteTarget.uuid })) - XCTAssertEqual(unmemorizedWordListRepository._storedWords.count, unmemorizedWordList.count - 1) + XCTAssertNil(sut.getWord(by: deleteTarget.uuid)) + XCTAssertFalse(sut.getUnmemorizedWordList().contains(where: { $0.uuid == deleteTarget.uuid })) } func test_deleteMemorizedWord() { @@ -110,8 +84,8 @@ final class WordUseCaseTests: XCTestCase { sut.deleteWord(by: deleteTarget.uuid) // Assert - XCTAssertFalse(wordRepository._wordList.contains(where: { $0.uuid == deleteTarget.uuid })) - XCTAssertEqual(unmemorizedWordListRepository._storedWords.count, unmemorizedWordList.count) + XCTAssertNil(sut.getWord(by: deleteTarget.uuid)) + XCTAssertFalse(sut.getMemorizedWordList().contains(where: { $0.uuid == deleteTarget.uuid })) } func test_getWordList() { @@ -122,9 +96,7 @@ final class WordUseCaseTests: XCTestCase { let wordList = sut.getWordList() // Assert - wordList.forEach { word in - XCTAssert(preparedList.contains(where: { $0.uuid == word.uuid })) - } + XCTAssertEqual(Set(wordList), Set(preparedList)) } func test_updateUnmemorizedWordLiteral() { @@ -138,8 +110,8 @@ final class WordUseCaseTests: XCTestCase { sut.updateWord(by: updateTarget.uuid, to: updateTarget) // Assert - XCTAssertEqual(wordRepository._wordList.first(where: { $0.uuid == updateTarget.uuid })?.word, "UpdatedWord") - XCTAssertEqual(unmemorizedWordListRepository._storedWords.first(where: { $0.uuid == updateTarget.uuid })?.word, "UpdatedWord") + XCTAssertEqual(sut.getWord(by: updateTarget.uuid)?.word, "UpdatedWord") + XCTAssertEqual(sut.getWord(by: updateTarget.uuid)?.memorizedState, .memorizing) } func test_updateUnmemorizedWordToMemorized() { @@ -153,11 +125,10 @@ final class WordUseCaseTests: XCTestCase { sut.updateWord(by: updateTarget.uuid, to: updateTarget) // Assert - XCTAssertEqual(wordRepository._wordList.first(where: { $0.uuid == updateTarget.uuid }), updateTarget) - XCTAssertFalse(unmemorizedWordListRepository._storedWords.contains(where: { $0.uuid == updateTarget.uuid })) + XCTAssertEqual(sut.getWord(by: updateTarget.uuid)?.memorizedState, .memorized) } - func test_updateMemorizedWordToUnMemorized() { + func test_updateMemorizedWordToUnmemorized() { // Arrange guard let updateTarget: Word = memorizedWordList.last else { return XCTFail("'memorizedWordList' property is empty.") @@ -168,8 +139,7 @@ final class WordUseCaseTests: XCTestCase { sut.updateWord(by: updateTarget.uuid, to: updateTarget) // Assert - XCTAssertEqual(wordRepository._wordList.first(where: { $0.uuid == updateTarget.uuid }), updateTarget) - XCTAssert(unmemorizedWordListRepository._storedWords.contains(where: { $0.uuid == updateTarget.uuid })) + XCTAssertEqual(sut.getWord(by: updateTarget.uuid)?.memorizedState, .memorizing) } func test_randomizeUnmemorizedWordListWhenOnly1Element() { @@ -184,18 +154,41 @@ final class WordUseCaseTests: XCTestCase { sut.randomizeUnmemorizedWordList() // Assert - XCTAssertEqual(sut.currentUnmemorizedWord, testWord) + XCTAssertEqual(sut.getCurrentUnmemorizedWord(), testWord) } func test_randomizeUnmemorizedWordListWhenMoreThen2Element() { // Arrange - let oldCurrentWord = sut.currentUnmemorizedWord + let oldCurrentWord = sut.getCurrentUnmemorizedWord() // Act sut.randomizeUnmemorizedWordList() // Assert - XCTAssertNotEqual(sut.currentUnmemorizedWord, oldCurrentWord) + XCTAssertNotEqual(sut.getCurrentUnmemorizedWord(), oldCurrentWord) + } + +} + +// MARK: - Helpers + +extension WordUseCaseTests { + + func makePreparedWordRepository() -> WordRepositoryFake { + let repository = WordRepositoryFake() + zip(memorizedWordList, unmemorizedWordList).forEach { + repository.save($0) + repository.save($1) + } + return repository + } + + func makePreparedUnmemorizedWordListRepository() -> UnmemorizedWordListRepositorySpy { + let repository: UnmemorizedWordListRepositorySpy = .init() + unmemorizedWordList.forEach { + repository.addWord($0) + } + return repository } } diff --git a/Tests/FoundationExtensionTests/FoundationExtensionTests.swift b/Tests/FoundationExtensionTests/FoundationExtensionTests.swift new file mode 100644 index 00000000..7fce248d --- /dev/null +++ b/Tests/FoundationExtensionTests/FoundationExtensionTests.swift @@ -0,0 +1,18 @@ +@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/iOSCoreUnitTests/WordCheckerTests-Bridging-Header.h b/Tests/iOSCoreUnitTests/WordCheckerTests-Bridging-Header.h deleted file mode 100644 index 1b2cb5d6..00000000 --- a/Tests/iOSCoreUnitTests/WordCheckerTests-Bridging-Header.h +++ /dev/null @@ -1,4 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - diff --git a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift new file mode 100644 index 00000000..4b3d9077 --- /dev/null +++ b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift @@ -0,0 +1,176 @@ +@testable import PushNotificationSettings + +import DomainTesting +import RxBlocking +import XCTest + +final class PushNotificationSettingsTests: XCTestCase { + + var sut: PushNotificationSettingsReactor! + + override func setUpWithError() throws { + try super.setUpWithError() + + sut = .init( + userSettingsUseCase: UserSettingsUseCaseFake(expectedAuthorizationStatus: .authorized), + globalAction: .shared + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + sut = nil + } + + func test_reactorNeedsUpdate_whenDailyReminderIsSet() throws { + // Common Given: DailyReminder 설정 + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .authorized) + _ = try userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) + .toBlocking() + .single() + try userSettingsUseCase.setDailyReminder(at: .init(hour: 11, minute: 22)) + .toBlocking() + .single() + + // when authorized + do { + // Given + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) + + // When + sut.action.onNext(.reactorNeedsUpdate) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, true) + XCTAssertEqual(sut.currentState.reminderTime, .init(hour: 11, minute: 22)) + } + + // when not authorized + do { + // Given: 알림 거부 설정 + userSettingsUseCase.expectedAuthorizationStatus = .denied + _ = try userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) + .toBlocking() + .single() + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) + + // When + sut.action.onNext(.reactorNeedsUpdate) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertEqual(sut.currentState.reminderTime, .init(hour: 11, minute: 22)) + } + } + + func test_turnOnOffDailyReminder_whenAuthorized() throws { + // Given + sut.action.onNext(.reactorNeedsUpdate) + + // On + do { + // When + sut.action.onNext(.tapDailyReminderSwitch) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, true) + XCTAssertNil(sut.currentState.needAuthAlert) + XCTAssertNil(sut.currentState.moveToAuthSettingAlert) + } + + // Off + do { + // When + sut.action.onNext(.tapDailyReminderSwitch) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertNil(sut.currentState.needAuthAlert) + XCTAssertNil(sut.currentState.moveToAuthSettingAlert) + } + } + + func test_turnOnOffDailyReminder_whenNotAuthorized() throws { + // Given + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .denied) + _ = try userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) + .toBlocking() + .single() + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) + + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertNil(sut.currentState.moveToAuthSettingAlert) + + // When + sut.action.onNext(.tapDailyReminderSwitch) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertNotNil(sut.currentState.moveToAuthSettingAlert) // Alert 표시됨 + } + + func test_turnOnOffDailyReminder_whenNotDetermined() throws { + // Given + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .notDetermined) + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) + + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertNil(sut.currentState.needAuthAlert) + + // When + sut.action.onNext(.tapDailyReminderSwitch) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertNotNil(sut.currentState.needAuthAlert) + } + + func test_changeReminderTime() { + // Given + sut.action.onNext(.tapDailyReminderSwitch) + XCTAssertEqual(sut.currentState.reminderTime, sut.initialState.reminderTime) + + // When + let newTime = DateComponents(calendar: .current, hour: 11, minute: 22).date! + sut.action.onNext(.changeReminderTime(newTime)) + + // Then + XCTAssertEqual(sut.currentState.reminderTime, DateComponents(hour: 11, minute: 22)) + } + + func test_sceneWillEnterForeground_whenAuthorizationChangesToDenied() throws { + // Given + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .authorized) + _ = try userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) + .toBlocking() + .single() + try userSettingsUseCase.setDailyReminder(at: .init(hour: 11, minute: 22)) + .toBlocking() + .single() + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) + sut.action.onNext(.reactorNeedsUpdate) + + XCTAssertEqual(sut.currentState.isOnDailyReminder, true) + + // When + userSettingsUseCase._authorizationStatus = .denied + sut.globalAction.sceneWillEnterForeground.accept(()) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertNotNil(sut.currentState.$needAuthAlert) + } + +} + +// func test_() { +// // Given +// +// +// // When +// +// +// // Then +// +// } diff --git a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift new file mode 100644 index 00000000..2680cf5b --- /dev/null +++ b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift @@ -0,0 +1,139 @@ +// +// UserSettingsReactorTests.swift +// DomainTests +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +@testable import UserSettings + +import Domain +import DomainTesting +import RxTest +import TestsSupport +import XCTest + +final class UserSettingsReactorTests: RxBaseTestCase { + + var sut: UserSettingsReactor! + + override func setUpWithError() throws { + try super.setUpWithError() + + sut = .init( + userSettingsUseCase: UserSettingsUseCaseFake(expectedAuthorizationStatus: .authorized), + googleDriveUseCase: GoogleDriveUseCaseFake(scheduler: testScheduler), + globalAction: .shared + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + sut = nil + } + + func test_uploadWhenNotSignedIn() { + // Given + let presentingWindow: PresentingConfiguration = .init(window: UIViewController()) + + // When + testScheduler + .createHotObservable([ + .next(210, .uploadData(presentingWindow)) + ]) + .subscribe(sut.action) + .disposed(by: disposeBag) + + // Then + let result = testScheduler.start { + self.sut.state.map(\.uploadStatus) + } + XCTAssertEqual(result.events.map(\.value.element), [ + .noTask, // initialState + .noTask, // due to commit signIn mutation + .inProgress, + .complete, + ]) + XCTAssertEqual(sut.currentState.hasSigned, true) + } + + func test_downloadWhenSignedIn() { + // Given + let presentingWindow: PresentingConfiguration = .init(window: UIViewController()) + sut.initialState = .init( + sourceLanguage: .english, + targetLanguage: .korean, + hasSigned: true, + uploadStatus: .noTask, + downloadStatus: .noTask + ) + + // When + testScheduler + .createHotObservable([ + .next(210, .downloadData(presentingWindow)) + ]) + .subscribe(sut.action) + .disposed(by: disposeBag) + + // Then + let result = testScheduler.start { + self.sut.state.map(\.downloadStatus) + } + XCTAssertEqual(result.events.map(\.value.element), [ + .noTask, // initialState + .inProgress, + .complete, + ]) + } + + func test_signOut() { + // Given + sut.initialState = .init( + sourceLanguage: .english, + targetLanguage: .korean, + hasSigned: true, + uploadStatus: .noTask, + downloadStatus: .noTask + ) + + // When + sut.action.onNext(.signOut) + + // Then + XCTAssertEqual(sut.currentState.hasSigned, false) + } + + func test_viewDidLoad() { + // Given + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .authorized) + userSettingsUseCase.currentUserSettings = .init(translationSourceLocale: .german, translationTargetLocale: .italian) + sut = .init( + userSettingsUseCase: userSettingsUseCase, + googleDriveUseCase: GoogleDriveUseCaseFake(scheduler: testScheduler), + globalAction: .shared + ) + + // When + sut.action.onNext(.viewDidLoad) + + // Then + XCTAssertEqual(sut.currentState.sourceLanguage, .german) + XCTAssertEqual(sut.currentState.targetLanguage, .italian) + } + +} + +// MARK: Templates +// func test_() { +// // Given +// +// +// // When +// +// +// // Then +// +// } diff --git a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsViewModelTests.swift b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsViewModelTests.swift deleted file mode 100644 index d3ba1f0e..00000000 --- a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsViewModelTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// UserSettingsViewModelTests.swift -// DomainUnitTests -// -// Created by Jaewon Yun on 2023/10/03. -// Copyright © 2023 woin2ee. All rights reserved. -// - -@testable import UserSettings - -import DomainTesting -import RxBlocking -import RxCocoa -import RxTest -import TestsSupport -import XCTest - -final class UserSettingsViewModelTests: RxBaseTestCase { - - var sut: UserSettingsViewModel! - - override func setUpWithError() throws { - try super.setUpWithError() - - sut = .init( - userSettingsUseCase: UserSettingsUseCaseFake.init(), - googleDriveUseCase: GoogleDriveUseCaseFake.init() - ) - } - - override func tearDownWithError() throws { - try super.tearDownWithError() - - sut = nil - } - - func testUpload() throws { - // Given - let input = makeInput(uploadTrigger: .just(())) - let output = sut.transform(input: input) - - // When - let elements = try output.uploadStatus - .toBlocking() - .toArray() - - // Then - XCTAssertEqual(elements, [.inProgress, .complete]) - } - - func testDownload() throws { - // Given - let input = makeInput(downloadTrigger: .just(())) - let output = sut.transform(input: input) - - // When - let elements = try output.downloadStatus - .toBlocking() - .toArray() - - // Then - XCTAssertEqual(elements, [.inProgress, .complete]) - } - - func testSignOut() throws { - // Given - let input = makeInput(signOut: .just(())) - let output = sut.transform(input: input) - - // When & Then - try output.signOut - .toBlocking() - .single() - } - - func test_beSignedAfterUpload() throws { - // Given - let trigger = testScheduler.createTrigger(emitAt: 300) - let input = makeInput(uploadTrigger: trigger.asSignalOnErrorJustComplete()) - let output = sut.transform(input: input) - - output.uploadStatus - .emit() - .disposed(by: disposeBag) - - // When - let result = testScheduler.start { - return output.hasSigned - } - - // Then - XCTAssert(result.events.contains { $0.time == TestScheduler.Defaults.subscribed && $0.value == .next(false) }) - XCTAssert(result.events.contains { $0.time == 300 && $0.value == .next(true) }) - } - - func test_beSignedAfterDownload() throws { - // Given - let trigger = testScheduler.createTrigger(emitAt: 300) - let input = makeInput(downloadTrigger: trigger.asSignalOnErrorJustComplete()) - let output = sut.transform(input: input) - - output.downloadStatus - .emit() - .disposed(by: disposeBag) - - // When - let result = testScheduler.start { - return output.hasSigned - } - - // Then - XCTAssert(result.events.contains { $0.time == TestScheduler.Defaults.subscribed && $0.value == .next(false) }) - XCTAssert(result.events.contains { $0.time == 300 && $0.value == .next(true) }) - } - -} - -// MARK: - Helpers - -extension UserSettingsViewModelTests { - - func makeInput( - uploadTrigger: Signal = .never(), - downloadTrigger: Signal = .never(), - signOut: Signal = .never() - ) -> UserSettingsViewModel.Input { - return .init( - uploadTrigger: uploadTrigger, - downloadTrigger: downloadTrigger, - signOut: signOut, - presentingConfiguration: .just(.init(window: UIViewController())) - ) - } - -} diff --git a/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift b/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift index dcf44c20..f5a73167 100644 --- a/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift +++ b/Tests/iOSScenesTests/WordCheckingTests/WordCheckingReactorTests.swift @@ -19,7 +19,7 @@ final class WordCheckingReactorTests: XCTestCase { try super.setUpWithError() let wordUseCase: WordRxUseCaseFake = .init() - let userSettingsUseCase: UserSettingsUseCaseFake = .init() + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .authorized) sut = .init( wordUseCase: wordUseCase, diff --git a/Tuist/Templates/ModuleWithTests/ModuleWithTests.swift b/Tuist/Templates/ModuleWithTests/ModuleWithTests.swift index ca00dbc3..26fa4fa5 100644 --- a/Tuist/Templates/ModuleWithTests/ModuleWithTests.swift +++ b/Tuist/Templates/ModuleWithTests/ModuleWithTests.swift @@ -14,7 +14,7 @@ let template = Template( templatePath: .relativeToCurrentFile("../Source.stencil") ), .file( - path: "Tests/\(nameAttribute)/\(nameAttribute)Tests.swift", + path: "Tests/\(nameAttribute)Tests/\(nameAttribute)Tests.swift", templatePath: .relativeToCurrentFile("../UnitTests.stencil") ), .file( diff --git a/Tuist/Templates/UnitTests.stencil b/Tuist/Templates/UnitTests.stencil index 238eb8d4..15cc4144 100644 --- a/Tuist/Templates/UnitTests.stencil +++ b/Tuist/Templates/UnitTests.stencil @@ -1,3 +1,5 @@ +@testable import {{ name }} + import XCTest final class {{ name }}Tests: XCTestCase {