From 29d70632eeb7d45a6b2483375dfe70c25824bb42 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Sun, 26 Nov 2023 01:52:21 +0900 Subject: [PATCH 01/24] Update workflows --- .github/workflows/create_new_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create_new_release.yml b/.github/workflows/create_new_release.yml index 47b32c82..7726054c 100644 --- a/.github/workflows/create_new_release.yml +++ b/.github/workflows/create_new_release.yml @@ -1,7 +1,7 @@ name: Create new release on: - workflow_dispatch + workflow_dispatch: push: # branches: # - main From a1cd2e1377dee1e885d8b8704a69e93dd2a80228 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Fri, 24 Nov 2023 17:12:15 +0900 Subject: [PATCH 02/24] Separate module --- Project.swift | 6 +++++ .../Collection+isNotEmpty.swift | 0 .../WordAddition/WordAdditionViewModel.swift | 1 + .../UIButton+setBackgroundColor.swift | 0 .../UIFont+preferredFont.swift | 0 TestPlans/FoundationExtension.xctestplan | 24 +++++++++++++++++++ .../FoundationExtensionTests.swift | 18 ++++++++++++++ .../WordCheckerTests-Bridging-Header.h | 4 ---- .../ModuleWithTests/ModuleWithTests.swift | 2 +- Tuist/Templates/UnitTests.stencil | 2 ++ 10 files changed, 52 insertions(+), 5 deletions(-) rename Sources/{iOSSupport/BasicExtensions => FoundationExtension}/Collection+isNotEmpty.swift (100%) rename Sources/iOSSupport/{BasicExtensions => UIKitExtension}/UIButton+setBackgroundColor.swift (100%) rename Sources/iOSSupport/{BasicExtensions => UIKitExtension}/UIFont+preferredFont.swift (100%) create mode 100644 TestPlans/FoundationExtension.xctestplan create mode 100644 Tests/FoundationExtensionTests/FoundationExtensionTests.swift delete mode 100644 Tests/iOSCoreUnitTests/WordCheckerTests-Bridging-Header.h diff --git a/Project.swift b/Project.swift index 75d9a045..febe4e8f 100644 --- a/Project.swift +++ b/Project.swift @@ -39,6 +39,11 @@ func targets() -> [Target] { ], appendSchemeTo: &disposedSchemes ) + + Target.module( + name: "FoundationExtension", + hasTests: true, + appendSchemeTo: &schemes + ) + Target.module( name: "Utility", scripts: [ @@ -193,6 +198,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), 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/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/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/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/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/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 { From 21551803f38b9edd90046289246779f1a7f80a3a Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Fri, 24 Nov 2023 17:35:36 +0900 Subject: [PATCH 03/24] Enhance test logic --- .../UseCases/WordRxUseCaseProtocol.swift | 2 +- .../UseCases/WordUseCaseProtocol.swift | 2 +- Sources/Domain/UseCases/WordRxUseCase.swift | 4 +- Sources/Domain/UseCases/WordUseCase.swift | 4 +- Sources/DomainTesting/WordRxUseCaseFake.swift | 4 +- Sources/DomainTesting/WordUseCaseFake.swift | 4 +- .../WordChecking/WordCheckingReactor.swift | 20 ++--- Tests/DomainTests/WordUseCaseTests.swift | 85 +++++++++---------- 8 files changed, 59 insertions(+), 66 deletions(-) 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/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/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/iOSScenes/WordChecking/WordCheckingReactor.swift b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift index 95370254..96dcc8cc 100644 --- a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift +++ b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift @@ -59,7 +59,7 @@ 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 .map(\.source) @@ -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/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 } } From a7066f95495d33c81683c3bc65339600697f2a41 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Sun, 26 Nov 2023 01:32:57 +0900 Subject: [PATCH 04/24] Add Project.swift file to Additional files --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index febe4e8f..d13af7c5 100644 --- a/Project.swift +++ b/Project.swift @@ -404,6 +404,7 @@ let project: Project = .init( "TestPlans/", "Scripts/", ".gitignore", + "Project.swift", ], resourceSynthesizers: [] ) From c6a2a2913fe6f8154c9735868243e50f0e6606f9 Mon Sep 17 00:00:00 2001 From: Jaewon-Yun <81426024+woin2ee@users.noreply.github.com> Date: Sun, 26 Nov 2023 02:09:41 +0900 Subject: [PATCH 05/24] Update create_new_release.yml --- .github/workflows/create_new_release.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/create_new_release.yml b/.github/workflows/create_new_release.yml index 7726054c..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" From 52e235c382a960f2f1b2d94aae8e2ac6c7f813c1 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Mon, 27 Nov 2023 12:57:21 +0900 Subject: [PATCH 06/24] Add example app for UserSettings feature --- Project.swift | 11 +++++ .../ExternalStoreUseCaseFake.swift | 2 + .../UserSettingsUseCaseFake.swift | 33 +++++++++++-- .../UserSettings/Cells/ButtonCell.swift | 26 ++++++++++ .../Cells/ChangeLanguageCell.swift | 36 ++++++++++++++ .../UserSettingsViewController.swift | 33 +++++-------- .../UserSettingsExample/AppDelegate.swift | 17 +++++++ .../UserSettingsExample/SceneDelegate.swift | 47 +++++++++++++++++++ .../Common/Foundations/ReusableCell.swift | 31 ++++++++++++ .../UITableView+dequeueReusableCell.swift | 28 +++++++++++ 10 files changed, 238 insertions(+), 26 deletions(-) create mode 100644 Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift create mode 100644 Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift create mode 100644 Sources/iOSScenes/UserSettingsExample/AppDelegate.swift create mode 100644 Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift create mode 100644 Sources/iOSSupport/Common/Foundations/ReusableCell.swift create mode 100644 Sources/iOSSupport/UITableView+dequeueReusableCell.swift diff --git a/Project.swift b/Project.swift index d13af7c5..871c08f8 100644 --- a/Project.swift +++ b/Project.swift @@ -243,6 +243,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", diff --git a/Sources/DomainTesting/ExternalStoreUseCaseFake.swift b/Sources/DomainTesting/ExternalStoreUseCaseFake.swift index 3684c6e6..43c7ae19 100644 --- a/Sources/DomainTesting/ExternalStoreUseCaseFake.swift +++ b/Sources/DomainTesting/ExternalStoreUseCaseFake.swift @@ -41,6 +41,7 @@ public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { return Disposables.create() } + .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) } if _hasSigned { @@ -67,6 +68,7 @@ public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { return Disposables.create() } + .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) } if _hasSigned { diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index 078a130b..7dba9ed4 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -18,19 +18,44 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { public init() {} public func updateTranslationLocale(source sourceLocale: Domain.TranslationLanguage, target targetLocale: Domain.TranslationLanguage) -> RxSwift.Single { - return .never() + guard var currentUserSettings = currentUserSettingsRelay.value else { + fatalError("UserSettings 이 초기화되지 않았습니다. initUserSettings() 함수를 호출하여 초기화 해야합니다.") + } + + currentUserSettings.translationSourceLocale = sourceLocale + currentUserSettings.translationTargetLocale = targetLocale + + currentUserSettingsRelay.accept(currentUserSettings) + + return .just(()) } public var currentTranslationLocale: RxSwift.Single<(source: Domain.TranslationLanguage, target: Domain.TranslationLanguage)> { - return .never() + guard let currentUserSettings = currentUserSettingsRelay.value else { + fatalError("UserSettings 이 초기화되지 않았습니다. initUserSettings() 함수를 호출하여 초기화 해야합니다.") + } + + return .just(( + source: currentUserSettings.translationSourceLocale, + target: currentUserSettings.translationTargetLocale)) } public func initUserSettings() -> RxSwift.Single { - return .never() + let initUserSettings: UserSettings = .init( + translationSourceLocale: .english, + translationTargetLocale: .korean + ) + currentUserSettingsRelay = .init(value: initUserSettings) + + return .just(initUserSettings) } public var currentUserSettings: RxSwift.Single { - return .never() + guard let currentUserSettings = currentUserSettingsRelay.value else { + fatalError("UserSettings 이 초기화되지 않았습니다. initUserSettings() 함수를 호출하여 초기화 해야합니다.") + } + + return .just(currentUserSettings) } } diff --git a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift new file mode 100644 index 00000000..dff3fb7e --- /dev/null +++ b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift @@ -0,0 +1,26 @@ +// +// ButtonCell.swift +// UserSettings +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import UIKit + +final class ButtonCell: UITableViewCell, ReusableCell { + + struct CellModel { + let title: String + let textColor: UIColor + } + + func bind(model: CellModel) { + var config: UIListContentConfiguration = .cell() + config.text = model.title + config.textProperties.color = model.textColor + self.contentConfiguration = config + } + +} diff --git a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift b/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift new file mode 100644 index 00000000..dff382ec --- /dev/null +++ b/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift @@ -0,0 +1,36 @@ +// +// ChangeLanguageCell.swift +// UserSettings +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import UIKit + +final class ChangeLanguageCell: UITableViewCell, ReusableCell { + + struct CellModel { + 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: CellModel) { + var config: UIListContentConfiguration = .valueCell() + config.text = model.title + config.secondaryText = model.value + self.contentConfiguration = config + } + +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index 578a620a..e5bef52f 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -24,8 +24,6 @@ public protocol UserSettingsViewControllerDelegate: AnyObject { public final class UserSettingsViewController: RxBaseViewController { - let userSettingsCellID = "USER_SETTINGS_CELL" - var viewModel: UserSettingsViewModel! var settingsTableViewDataSource: UITableViewDiffableDataSource! @@ -34,7 +32,8 @@ public final class UserSettingsViewController: RxBaseViewController { lazy var settingsTableView: UITableView = .init(frame: .zero, style: .insetGrouped).then { $0.backgroundColor = .systemGroupedBackground - $0.register(UITableViewCell.self, forCellReuseIdentifier: userSettingsCellID) + $0.register(ChangeLanguageCell.self, forCellReuseIdentifier: ChangeLanguageCell.reusableIdentifier) + $0.register(ButtonCell.self, forCellReuseIdentifier: ButtonCell.reusableIdentifier) } public override func viewDidLoad() { @@ -49,30 +48,20 @@ public final class UserSettingsViewController: RxBaseViewController { func setupDataSource() { self.settingsTableViewDataSource = .init(tableView: settingsTableView) { tableView, indexPath, item in - let cell = tableView.dequeueReusableCell(withIdentifier: self.userSettingsCellID, for: indexPath) - - cell.accessoryType = .none - - var config: UIListContentConfiguration - switch item.settingType { case .changeSourceLanguage, .changeTargetLanguage: - config = .valueCell() - config.secondaryText = item.value?.localizedString - - cell.accessoryType = .disclosureIndicator + let cell = tableView.dequeueReusableCell(ChangeLanguageCell.self, for: indexPath) + cell.bind(model: .init(title: item.primaryText, value: item.value?.localizedString)) + return cell case .googleDriveUpload, .googleDriveDownload: - config = .cell() - config.textProperties.color = .systemBlue + let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) + cell.bind(model: .init(title: item.primaryText, textColor: .systemBlue)) + return cell case .googleDriveSignOut: - config = .cell() - config.textProperties.color = .systemRed + let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) + cell.bind(model: .init(title: item.primaryText, textColor: .systemRed)) + return cell } - - config.text = item.primaryText - - cell.contentConfiguration = config - return cell } } 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..af7fe2a3 --- /dev/null +++ b/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift @@ -0,0 +1,47 @@ +// +// 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 userSettingsUseCase: UserSettingsUseCaseFake = .init() + _ = userSettingsUseCase.initUserSettings() + + let viewController: UserSettingsViewController = .init() + let viewModel: UserSettingsViewModel = .init( + userSettingsUseCase: userSettingsUseCase, + googleDriveUseCase: GoogleDriveUseCaseFake() + ) + viewController.viewModel = viewModel + + 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/iOSSupport/Common/Foundations/ReusableCell.swift b/Sources/iOSSupport/Common/Foundations/ReusableCell.swift new file mode 100644 index 00000000..82109702 --- /dev/null +++ b/Sources/iOSSupport/Common/Foundations/ReusableCell.swift @@ -0,0 +1,31 @@ +// +// ReusableCell.swift +// UserSettings +// +// Created by Jaewon Yun on 11/27/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation + +public protocol ReusableCell { + + /// `Cell` 을 구성하는 데이터들의 집합입니다. + associatedtype CellModel + + /// A `reusableIdentifier` that will be used by `dequeueReusableCell(withIdentifier:for:)`. + /// + /// If you don't override, it's concrete class name. + static var reusableIdentifier: String { get } + + func bind(model: CellModel) + +} + +extension ReusableCell { + + public static var reusableIdentifier: String { + return .init(describing: Self.self) + } + +} diff --git a/Sources/iOSSupport/UITableView+dequeueReusableCell.swift b/Sources/iOSSupport/UITableView+dequeueReusableCell.swift new file mode 100644 index 00000000..789570d4 --- /dev/null +++ b/Sources/iOSSupport/UITableView+dequeueReusableCell.swift @@ -0,0 +1,28 @@ +// +// 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 `ReusableCell` protocol and adds it to the table. + /// - Parameters: + /// - type: `ReusableCell` 프로토콜을 준수하는 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: `ReusableCell` 프로토콜의 `reusableIdentifier` 와 연관된 재사용 가능한 Cell 입니다. + public func dequeueReusableCell(_ type: Cell.Type, for indexPath: IndexPath) -> Cell { + let cell = self.dequeueReusableCell(withIdentifier: type.reusableIdentifier, for: indexPath) + + guard let reusableCell = cell as? Cell else { + preconditionFailure("Failed to dequeue cell with '\(Cell.self)'type") + } + + return reusableCell + } + +} From 3d386c0c3af057d86f11c140b0ccb1b67baa9857 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Tue, 28 Nov 2023 03:23:18 +0900 Subject: [PATCH 07/24] Refactor ViewModel to Reactor --- .../UserSettingsUseCaseProtocol.swift | 8 +- .../Domain/UseCases/UserSettingsUseCase.swift | 72 +++--- .../Domain/ValueObjects/ProgressStatus.swift | 2 + .../ExternalStoreUseCaseFake.swift | 10 +- .../UserSettingsUseCaseFake.swift | 33 +-- Sources/WordChecker/AppDelegate.swift | 14 -- Sources/WordCheckerDev/AppDelegate.swift | 14 -- .../LanguageSettingViewModel.swift | 2 +- .../UserSettings/UserSettingsAssembly.swift | 15 +- .../UserSettings/UserSettingsReactor.swift | 145 ++++++++++++ .../UserSettingsViewController.swift | 214 ++++++++++-------- .../UserSettings/UserSettingsViewModel.swift | 124 ---------- .../UserSettingsExample/SceneDelegate.swift | 12 +- .../WordChecking/WordCheckingReactor.swift | 4 +- Tests/DomainTests/SettingUseCaseTests.swift | 9 +- .../UserSettingsReactorTests.swift | 139 ++++++++++++ .../UserSettingsViewModelTests.swift | 135 ----------- 17 files changed, 471 insertions(+), 481 deletions(-) create mode 100644 Sources/iOSScenes/UserSettings/UserSettingsReactor.swift delete mode 100644 Sources/iOSScenes/UserSettings/UserSettingsViewModel.swift create mode 100644 Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift delete mode 100644 Tests/iOSScenesTests/UserSettingsTests/UserSettingsViewModelTests.swift diff --git a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift index 71337e04..8df0f0bc 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift @@ -12,14 +12,10 @@ import RxRelay 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 initUserSettings() -> Single + func getCurrentTranslationLocale() -> Single<(source: TranslationLanguage, target: TranslationLanguage)> - var currentUserSettings: Single { get } + func getCurrentUserSettings() -> Single } diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index 51a53aab..268835ba 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -15,16 +15,11 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { let userSettingsRepository: UserSettingsRepositoryProtocol - public let currentUserSettingsRelay: BehaviorRelay - public init(userSettingsRepository: UserSettingsRepositoryProtocol) { self.userSettingsRepository = userSettingsRepository - self.currentUserSettingsRelay = .init(value: nil) - self.userSettingsRepository.getUserSettings() - .subscribe(onSuccess: { - self.currentUserSettingsRelay.accept($0) - }) + initUserSettingsIfNoUserSettings() + .subscribe() .dispose() } @@ -36,50 +31,51 @@ 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 + public func getCurrentUserSettings() -> Single { + return userSettingsRepository.getUserSettings() + } - 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 - } + func initUserSettingsIfNoUserSettings() -> RxSwift.Single { + return userSettingsRepository.getUserSettings() + .mapToVoid() + .catch { _ in + var translationTargetLocale: TranslationLanguage - let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: translationTargetLocale) // FIXME: 처음에 Source Locale 설정 가능하게 (현재 .english 고정) + 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 + } - return userSettingsRepository.saveUserSettings(userSettings) - .flatMap { self.userSettingsRepository.getUserSettings() } - .doOnSuccess { self.currentUserSettingsRelay.accept($0) } - } + let userSettings: UserSettings = .init(translationSourceLocale: .english, translationTargetLocale: translationTargetLocale) // FIXME: 처음에 Source Locale 설정 가능하게 (현재 .english 고정) - public var currentUserSettings: Single { - return userSettingsRepository.getUserSettings() + return self.userSettingsRepository.saveUserSettings(userSettings) + } } } 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 43c7ae19..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,7 +45,7 @@ public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { return Disposables.create() } - .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .subscribe(on: scheduler) } if _hasSigned { @@ -68,7 +72,7 @@ public final class GoogleDriveUseCaseFake: ExternalStoreUseCaseProtocol { return Disposables.create() } - .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .subscribe(on: scheduler) } if _hasSigned { diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index 7dba9ed4..351c70b4 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -13,48 +13,27 @@ import RxRelay public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { - public var currentUserSettingsRelay: RxRelay.BehaviorRelay = .init(value: nil) + public var currentUserSettings: Domain.UserSettings = .init( + translationSourceLocale: .english, + translationTargetLocale: .korean + ) public init() {} public func updateTranslationLocale(source sourceLocale: Domain.TranslationLanguage, target targetLocale: Domain.TranslationLanguage) -> RxSwift.Single { - guard var currentUserSettings = currentUserSettingsRelay.value else { - fatalError("UserSettings 이 초기화되지 않았습니다. initUserSettings() 함수를 호출하여 초기화 해야합니다.") - } - currentUserSettings.translationSourceLocale = sourceLocale currentUserSettings.translationTargetLocale = targetLocale - currentUserSettingsRelay.accept(currentUserSettings) - return .just(()) } - public var currentTranslationLocale: RxSwift.Single<(source: Domain.TranslationLanguage, target: Domain.TranslationLanguage)> { - guard let currentUserSettings = currentUserSettingsRelay.value else { - fatalError("UserSettings 이 초기화되지 않았습니다. initUserSettings() 함수를 호출하여 초기화 해야합니다.") - } - + public func getCurrentTranslationLocale() -> RxSwift.Single<(source: Domain.TranslationLanguage, target: Domain.TranslationLanguage)> { return .just(( source: currentUserSettings.translationSourceLocale, target: currentUserSettings.translationTargetLocale)) } - public func initUserSettings() -> RxSwift.Single { - let initUserSettings: UserSettings = .init( - translationSourceLocale: .english, - translationTargetLocale: .korean - ) - currentUserSettingsRelay = .init(value: initUserSettings) - - return .just(initUserSettings) - } - - public var currentUserSettings: RxSwift.Single { - guard let currentUserSettings = currentUserSettingsRelay.value else { - fatalError("UserSettings 이 초기화되지 않았습니다. initUserSettings() 함수를 호출하여 초기화 해야합니다.") - } - + public func getCurrentUserSettings() -> RxSwift.Single { return .just(currentUserSettings) } diff --git a/Sources/WordChecker/AppDelegate.swift b/Sources/WordChecker/AppDelegate.swift index b9671fc4..722225d0 100644 --- a/Sources/WordChecker/AppDelegate.swift +++ b/Sources/WordChecker/AppDelegate.swift @@ -25,7 +25,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { initDIContainer() - initUserSettingsIfFirstLaunch() restoreGoogleSignInState() NetworkMonitor.start() @@ -67,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 restoreGoogleSignInState() { let googleDriveUseCase: ExternalStoreUseCaseProtocol = DIContainer.shared.resolver.resolve() googleDriveUseCase.restoreSignIn() diff --git a/Sources/WordCheckerDev/AppDelegate.swift b/Sources/WordCheckerDev/AppDelegate.swift index d1d819f7..d0f76b51 100644 --- a/Sources/WordCheckerDev/AppDelegate.swift +++ b/Sources/WordCheckerDev/AppDelegate.swift @@ -25,7 +25,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { initDIContainerForDev() - initUserSettingsIfFirstLaunch() NetworkMonitor.start() return true @@ -66,19 +65,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(), 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/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/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 e5bef52f..a43fbc75 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 @@ -22,47 +22,45 @@ public protocol UserSettingsViewControllerDelegate: AnyObject { } -public final class UserSettingsViewController: RxBaseViewController { - - var viewModel: UserSettingsViewModel! - - var settingsTableViewDataSource: UITableViewDiffableDataSource! +public final class UserSettingsViewController: RxBaseViewController, View { + + lazy var settingsTableViewDataSource: UITableViewDiffableDataSource = .init(tableView: settingsTableView) { tableView, indexPath, item in + switch item.settingType { + case .changeSourceLanguage, .changeTargetLanguage: + let cell = tableView.dequeueReusableCell(ChangeLanguageCell.self, for: indexPath) + cell.bind(model: .init(title: item.primaryText, value: item.value?.localizedString)) + return cell + case .googleDriveUpload, .googleDriveDownload: + let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) + cell.bind(model: .init(title: item.primaryText, textColor: .systemBlue)) + return cell + case .googleDriveSignOut: + let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) + cell.bind(model: .init(title: item.primaryText, textColor: .systemRed)) + return cell + } + } public weak var delegate: UserSettingsViewControllerDelegate? lazy var settingsTableView: UITableView = .init(frame: .zero, style: .insetGrouped).then { - $0.backgroundColor = .systemGroupedBackground $0.register(ChangeLanguageCell.self, forCellReuseIdentifier: ChangeLanguageCell.reusableIdentifier) $0.register(ButtonCell.self, forCellReuseIdentifier: ButtonCell.reusableIdentifier) } + public override func loadView() { + self.view = settingsTableView + } + public override func viewDidLoad() { super.viewDidLoad() - - setupDataSource() + self.view.backgroundColor = .systemGroupedBackground applyDefualtSnapshot() - setupSubviews() - setupNavigationBar() - bindViewModel() } - func setupDataSource() { - self.settingsTableViewDataSource = .init(tableView: settingsTableView) { tableView, indexPath, item in - switch item.settingType { - case .changeSourceLanguage, .changeTargetLanguage: - let cell = tableView.dequeueReusableCell(ChangeLanguageCell.self, for: indexPath) - cell.bind(model: .init(title: item.primaryText, value: item.value?.localizedString)) - return cell - case .googleDriveUpload, .googleDriveDownload: - let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) - cell.bind(model: .init(title: item.primaryText, textColor: .systemBlue)) - return cell - case .googleDriveSignOut: - let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) - cell.bind(model: .init(title: item.primaryText, textColor: .systemRed)) - return cell - } - } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupNavigationBar() } func applyDefualtSnapshot() { @@ -83,49 +81,91 @@ public final class UserSettingsViewController: RxBaseViewController { settingsTableViewDataSource.applySnapshotUsingReloadData(snapshot) } + func setupNavigationBar() { + self.navigationItem.title = WCString.settings + self.navigationController?.navigationBar.prefersLargeTitles = true + } + // 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 == 1 && $0.row == 0 }) + .map { _ in Reactor.Action.uploadData(presentingWindow) } + .emit(to: reactor.action) + .disposed(by: self.disposeBag) + + itemSelectedEvent + .filter({ $0.section == 1 && $0.row == 1 }) + .map { _ in Reactor.Action.downloadData(presentingWindow) } + .emit(to: reactor.action) + .disposed(by: self.disposeBag) + + itemSelectedEvent + .filter({ $0.section == 2 && $0.row == 0 }) + .map { _ in Reactor.Action.signOut } + .emit(to: reactor.action) + .disposed(by: self.disposeBag) itemSelectedEvent .filter({ $0.section == 0 && $0.row == 0 }) - .mapToVoid() - .withLatestFrom(output.currentTranslationSourceLanguage) - .emit(with: self, onNext: { owner, translationSourceLanguage in - owner.delegate?.didTapSourceLanguageSettingRow(currentSettingLocale: translationSourceLanguage) + .emit(with: self, onNext: { owner, _ in + owner.delegate?.didTapSourceLanguageSettingRow(currentSettingLocale: reactor.currentState.sourceLanguage) }) - .disposed(by: disposeBag) + .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) + .emit(with: self, onNext: { owner, _ in + owner.delegate?.didTapTargetLanguageSettingRow(currentSettingLocale: reactor.currentState.targetLanguage) + }) + .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() + snapshot.deleteSections([.signOut]) + + if hasSigned { + snapshot.appendSections([.signOut]) + snapshot.appendItems([.init(settingType: .googleDriveSignOut)]) + } + + owner.settingsTableViewDataSource.apply(snapshot) + }) + .disposed(by: self.disposeBag) + + reactor.pulse(\.$showSignOutAlert) + .asDriverOnErrorJustComplete() + .drive(with: self, onNext: { owner, _ in + owner.presentOKAlert(title: WCString.notice, message: WCString.signed_out_of_google_drive) }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - output.currentTranslationSourceLanguage + reactor.state + .map(\.sourceLanguage) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() .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 { + assertionFailure("UserSettingsSection.changeLanguage 섹션에 SettingType.changeSourceLanguage 타입 item 이 없습니다.") return } @@ -137,9 +177,12 @@ public final class UserSettingsViewController: RxBaseViewController { owner.settingsTableViewDataSource.apply(snapshot, animatingDifferences: false) }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - output.currentTranslationTargetLanguage + reactor.state + .map(\.targetLanguage) + .distinctUntilChanged() + .asDriverOnErrorJustComplete() .drive(with: self, onNext: { owner, translationTargetLanguage in var snapshot = owner.settingsTableViewDataSource.snapshot() let originItems = snapshot.itemIdentifiers(inSection: .changeLanguage) @@ -156,27 +199,16 @@ public final class UserSettingsViewController: RxBaseViewController { owner.settingsTableViewDataSource.apply(snapshot, animatingDifferences: false) }) - .disposed(by: disposeBag) - - output.hasSigned - .drive(with: self, onNext: { owner, hasSigned in - var snapshot = owner.settingsTableViewDataSource.snapshot() - snapshot.deleteSections([.signOut]) - - if hasSigned { - snapshot.appendSections([.signOut]) - snapshot.appendItems([ - .init(settingType: .googleDriveSignOut) - ]) - } - - 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: @@ -184,11 +216,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: @@ -196,26 +233,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/SceneDelegate.swift b/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift index af7fe2a3..4cf7eb86 100644 --- a/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift +++ b/Sources/iOSScenes/UserSettingsExample/SceneDelegate.swift @@ -19,15 +19,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = .init(windowScene: windowScene) window?.makeKeyAndVisible() - let userSettingsUseCase: UserSettingsUseCaseFake = .init() - _ = userSettingsUseCase.initUserSettings() - let viewController: UserSettingsViewController = .init() - let viewModel: UserSettingsViewModel = .init( - userSettingsUseCase: userSettingsUseCase, - googleDriveUseCase: GoogleDriveUseCaseFake() + let reactor: UserSettingsReactor = .init( + userSettingsUseCase: UserSettingsUseCaseFake(), + googleDriveUseCase: GoogleDriveUseCaseFake(), + globalAction: .shared ) - viewController.viewModel = viewModel + viewController.reactor = reactor let navigationController: UINavigationController = .init(rootViewController: viewController) navigationController.tabBarItem = .init(title: "Settings", image: .init(systemName: "gearshape"), tag: 0) diff --git a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift index 96dcc8cc..6846cd9a 100644 --- a/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift +++ b/Sources/iOSScenes/WordChecking/WordCheckingReactor.swift @@ -61,11 +61,11 @@ public final class WordCheckingReactor: Reactor { let initUnmemorizedWordList = wordUseCase.randomizeUnmemorizedWordList() .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() diff --git a/Tests/DomainTests/SettingUseCaseTests.swift b/Tests/DomainTests/SettingUseCaseTests.swift index af7b3703..4d16843b 100644 --- a/Tests/DomainTests/SettingUseCaseTests.swift +++ b/Tests/DomainTests/SettingUseCaseTests.swift @@ -30,18 +30,13 @@ final class SettingUseCaseTests: XCTestCase { } 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 + let currentTranslationLocale = try sut.getCurrentTranslationLocale() .toBlocking() .single() @@ -55,7 +50,7 @@ final class SettingUseCaseTests: XCTestCase { .toBlocking() .single() - let currentTranslationLocale = try sut.currentTranslationLocale + let currentTranslationLocale = try sut.getCurrentTranslationLocale() .toBlocking() .single() diff --git a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift new file mode 100644 index 00000000..0ae6453e --- /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(), + 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() + 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())) - ) - } - -} From b63a572535880ac48d6ba0ede7f7b1c5476425aa Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Wed, 29 Nov 2023 15:04:54 +0900 Subject: [PATCH 08/24] Refactor TableViewDataSource in UserSettingsVC --- .../Localization/en.lproj/Localizable.strings | 2 +- .../Localization/ko.lproj/Localizable.strings | 2 +- .../UserSettings/Cells/ButtonCell.swift | 7 +- .../Cells/ChangeLanguageCell.swift | 4 +- .../UserSettingsItem.swift | 14 +++ .../UserSettingsItemIdentifier.swift | 17 +++ .../UserSettingsSectionIdentifier.swift} | 4 +- .../UserSettingsItemModel.swift | 57 --------- .../UserSettingsViewController.swift | 109 ++++++++---------- .../WordSearchResultsController.swift | 2 +- .../Common/Foundations/ReusableCell.swift | 4 +- .../iOSSupport/Localization/WCString.swift | 2 +- 12 files changed, 97 insertions(+), 127 deletions(-) create mode 100644 Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItem.swift create mode 100644 Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift rename Sources/iOSScenes/UserSettings/{UserSettingsItems/UserSettingsSection.swift => UserSettingsDataSource/UserSettingsSectionIdentifier.swift} (67%) delete mode 100644 Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsItemModel.swift diff --git a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings index 6d39f51e..ca3b800b 100644 --- a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings @@ -51,7 +51,7 @@ 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."; diff --git a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings index ca235191..87c71391 100644 --- a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings @@ -51,7 +51,7 @@ 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 = "구글 드라이브에 동기화 합니다."; diff --git a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift index dff3fb7e..dbdefa6c 100644 --- a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift +++ b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift @@ -9,14 +9,17 @@ import iOSSupport import UIKit +/// 버튼 역할을 하기 위한 Cell 클래스 입니다. +/// +/// 버튼 가이드라인에 따른 텍스트 색상을 사용하세요. final class ButtonCell: UITableViewCell, ReusableCell { - struct CellModel { + struct Model { let title: String let textColor: UIColor } - func bind(model: CellModel) { + func bind(model: Model) { var config: UIListContentConfiguration = .cell() config.text = model.title config.textProperties.color = model.textColor diff --git a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift b/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift index dff382ec..ad35d2a8 100644 --- a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift +++ b/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift @@ -11,7 +11,7 @@ import UIKit final class ChangeLanguageCell: UITableViewCell, ReusableCell { - struct CellModel { + struct Model { let title: String let value: String? } @@ -26,7 +26,7 @@ final class ChangeLanguageCell: UITableViewCell, ReusableCell { fatalError("init(coder:) has not been implemented") } - func bind(model: CellModel) { + func bind(model: Model) { var config: UIListContentConfiguration = .valueCell() config.text = model.title config.secondaryText = model.value diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItem.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItem.swift new file mode 100644 index 00000000..4f5e8b9c --- /dev/null +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItem.swift @@ -0,0 +1,14 @@ +// +// UserSettingsItem.swift +// UserSettings +// +// Created by Jaewon Yun on 11/28/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation + +enum UserSettingsItem { + case changeLanguage(ChangeLanguageCell.Model) + case button(ButtonCell.Model) +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift new file mode 100644 index 00000000..47f8eb1b --- /dev/null +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift @@ -0,0 +1,17 @@ +// +// UserSettingsItemIdentifier.swift +// UserSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +enum UserSettingsItemIdentifier { + case changeSourceLanguage + case changeTargetLanguage + + case googleDriveUpload + case googleDriveDownload + + case googleDriveSignOut +} diff --git a/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsSection.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift similarity index 67% rename from Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsSection.swift rename to Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift index 569b7898..8911b888 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsItems/UserSettingsSection.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift @@ -1,5 +1,5 @@ // -// UserSettingsSection.swift +// UserSettingsSectionIdentifier.swift // iOSCore // // Created by Jaewon Yun on 2023/10/03. @@ -8,7 +8,7 @@ import Foundation -enum UserSettingsSection: Hashable, Sendable { +enum UserSettingsSectionIdentifier: Hashable, Sendable { case changeLanguage 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/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index a43fbc75..a7e96108 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -24,19 +24,27 @@ public protocol UserSettingsViewControllerDelegate: AnyObject { public final class UserSettingsViewController: RxBaseViewController, View { - lazy var settingsTableViewDataSource: UITableViewDiffableDataSource = .init(tableView: settingsTableView) { tableView, indexPath, item in - switch item.settingType { - case .changeSourceLanguage, .changeTargetLanguage: + private(set) var dataSourceModel: [UserSettingsItemIdentifier: UserSettingsItem] = [ + .changeSourceLanguage: .changeLanguage(.init(title: WCString.source_language, value: nil)), + .changeTargetLanguage: .changeLanguage(.init(title: WCString.translation_language, 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)), + ] + + lazy var settingsTableViewDataSource: UITableViewDiffableDataSource = .init(tableView: settingsTableView) { tableView, indexPath, id in + guard let item = self.dataSourceModel[id] else { + preconditionFailure("dataSourceModel 에 해당 \(id) 를 가진 Item 이 없습니다.") + } + + switch item { + case .changeLanguage(let model): let cell = tableView.dequeueReusableCell(ChangeLanguageCell.self, for: indexPath) - cell.bind(model: .init(title: item.primaryText, value: item.value?.localizedString)) + cell.bind(model: model) return cell - case .googleDriveUpload, .googleDriveDownload: + case .button(let model): let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) - cell.bind(model: .init(title: item.primaryText, textColor: .systemBlue)) - return cell - case .googleDriveSignOut: - let cell = tableView.dequeueReusableCell(ButtonCell.self, for: indexPath) - cell.bind(model: .init(title: item.primaryText, textColor: .systemRed)) + cell.bind(model: model) return cell } } @@ -64,26 +72,26 @@ public final class UserSettingsViewController: RxBaseViewController, View { } func applyDefualtSnapshot() { - var snapshot: NSDiffableDataSourceSnapshot = .init() + var snapshot: NSDiffableDataSourceSnapshot = .init() + snapshot.appendSections([.changeLanguage, .googleDriveSync]) - snapshot.appendSections([.changeLanguage]) - snapshot.appendItems([ - .init(settingType: .changeSourceLanguage), - .init(settingType: .changeTargetLanguage), - ]) + snapshot.appendItems( + [.changeSourceLanguage, .changeTargetLanguage], + toSection: .changeLanguage + ) - snapshot.appendSections([.googleDriveSync]) - snapshot.appendItems([ - .init(settingType: .googleDriveUpload), - .init(settingType: .googleDriveDownload), - ]) + snapshot.appendItems( + [.googleDriveUpload, .googleDriveDownload], + toSection: .googleDriveSync + ) - settingsTableViewDataSource.applySnapshotUsingReloadData(snapshot) + settingsTableViewDataSource.apply(snapshot) } func setupNavigationBar() { self.navigationItem.title = WCString.settings self.navigationController?.navigationBar.prefersLargeTitles = true + self.navigationController?.navigationBar.sizeToFit() } // swiftlint:disable:next function_body_length @@ -95,32 +103,32 @@ public final class UserSettingsViewController: RxBaseViewController, View { let presentingWindow: PresentingConfiguration = .init(window: self) itemSelectedEvent - .filter({ $0.section == 1 && $0.row == 0 }) + .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 == 1 && $0.row == 1 }) + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .googleDriveDownload } .map { _ in Reactor.Action.downloadData(presentingWindow) } .emit(to: reactor.action) .disposed(by: self.disposeBag) itemSelectedEvent - .filter({ $0.section == 2 && $0.row == 0 }) + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .googleDriveSignOut } .map { _ in Reactor.Action.signOut } .emit(to: reactor.action) .disposed(by: self.disposeBag) itemSelectedEvent - .filter({ $0.section == 0 && $0.row == 0 }) + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .changeSourceLanguage } .emit(with: self, onNext: { owner, _ in owner.delegate?.didTapSourceLanguageSettingRow(currentSettingLocale: reactor.currentState.sourceLanguage) }) .disposed(by: self.disposeBag) itemSelectedEvent - .filter({ $0.section == 0 && $0.row == 1 }) + .filter { self.settingsTableViewDataSource.itemIdentifier(for: $0) == .changeTargetLanguage } .emit(with: self, onNext: { owner, _ in owner.delegate?.didTapTargetLanguageSettingRow(currentSettingLocale: reactor.currentState.targetLanguage) }) @@ -138,11 +146,15 @@ public final class UserSettingsViewController: RxBaseViewController, View { .asDriverOnErrorJustComplete() .drive(with: self, onNext: { owner, hasSigned in var snapshot = owner.settingsTableViewDataSource.snapshot() - snapshot.deleteSections([.signOut]) if hasSigned { - snapshot.appendSections([.signOut]) - snapshot.appendItems([.init(settingType: .googleDriveSignOut)]) + snapshot.insertSections([.signOut], afterSection: .googleDriveSync) + snapshot.appendItems( + [.googleDriveSignOut], + toSection: .signOut + ) + } else { + snapshot.deleteSections([.signOut]) } owner.settingsTableViewDataSource.apply(snapshot) @@ -152,7 +164,7 @@ public final class UserSettingsViewController: RxBaseViewController, View { reactor.pulse(\.$showSignOutAlert) .asDriverOnErrorJustComplete() .drive(with: self, onNext: { owner, _ in - owner.presentOKAlert(title: WCString.notice, message: WCString.signed_out_of_google_drive) + owner.presentOKAlert(title: WCString.notice, message: WCString.signed_out_of_google_drive_successfully) }) .disposed(by: self.disposeBag) @@ -161,21 +173,11 @@ public final class UserSettingsViewController: RxBaseViewController, View { .distinctUntilChanged() .asDriverOnErrorJustComplete() .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 { - assertionFailure("UserSettingsSection.changeLanguage 섹션에 SettingType.changeSourceLanguage 타입 item 이 없습니다.") - return - } - - var newItems = originItems - newItems[targetIndex].value = translationSourceLanguage - - snapshot.deleteItems(originItems) - snapshot.appendItems(newItems, toSection: .changeLanguage) + owner.dataSourceModel[.changeSourceLanguage] = .changeLanguage(.init(title: WCString.source_language, value: translationSourceLanguage.localizedString)) - owner.settingsTableViewDataSource.apply(snapshot, animatingDifferences: false) + var snapshot = owner.settingsTableViewDataSource.snapshot() + snapshot.reconfigureItems([.changeSourceLanguage]) + owner.settingsTableViewDataSource.apply(snapshot) }) .disposed(by: self.disposeBag) @@ -184,20 +186,11 @@ public final class UserSettingsViewController: RxBaseViewController, View { .distinctUntilChanged() .asDriverOnErrorJustComplete() .drive(with: self, onNext: { owner, translationTargetLanguage in - var snapshot = owner.settingsTableViewDataSource.snapshot() - let originItems = snapshot.itemIdentifiers(inSection: .changeLanguage) - - guard let targetIndex = originItems.firstIndex(where: { $0.settingType == .changeTargetLanguage }) else { - return - } + owner.dataSourceModel[.changeTargetLanguage] = .changeLanguage(.init(title: WCString.translation_language, value: translationTargetLanguage.localizedString)) - var newItems = originItems - newItems[targetIndex].value = translationTargetLanguage - - snapshot.deleteItems(originItems) - snapshot.appendItems(newItems, toSection: .changeLanguage) - - owner.settingsTableViewDataSource.apply(snapshot, animatingDifferences: false) + var snapshot = owner.settingsTableViewDataSource.snapshot() + snapshot.reconfigureItems([.changeTargetLanguage]) + owner.settingsTableViewDataSource.apply(snapshot) }) .disposed(by: self.disposeBag) diff --git a/Sources/iOSScenes/WordList/WordSearchResultsController.swift b/Sources/iOSScenes/WordList/WordSearchResultsController.swift index eaa18b10..b812e77e 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) diff --git a/Sources/iOSSupport/Common/Foundations/ReusableCell.swift b/Sources/iOSSupport/Common/Foundations/ReusableCell.swift index 82109702..65add336 100644 --- a/Sources/iOSSupport/Common/Foundations/ReusableCell.swift +++ b/Sources/iOSSupport/Common/Foundations/ReusableCell.swift @@ -11,14 +11,14 @@ import Foundation public protocol ReusableCell { /// `Cell` 을 구성하는 데이터들의 집합입니다. - associatedtype CellModel + associatedtype Model /// A `reusableIdentifier` that will be used by `dequeueReusableCell(withIdentifier:for:)`. /// /// If you don't override, it's concrete class name. static var reusableIdentifier: String { get } - func bind(model: CellModel) + func bind(model: Model) } diff --git a/Sources/iOSSupport/Localization/WCString.swift b/Sources/iOSSupport/Localization/WCString.swift index 8d16c1d1..4d3d5adc 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: "") From f1bc5d06ee6c2c17afd37bd2e33433a17f87d256 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Mon, 4 Dec 2023 21:01:17 +0900 Subject: [PATCH 09/24] Add PushNotification scene --- Project.swift | 28 ++++ QA.md | 5 +- .../Localization/en.lproj/Localizable.strings | 4 + .../Localization/ko.lproj/Localizable.strings | 4 + .../Cells/DatePickerCell.swift | 39 +++++ .../Cells/SwitchCell.swift | 44 +++++ .../PushNotificationSettingsAssembly.swift | 30 ++++ .../PushNotificationSettingsReactor.swift | 75 +++++++++ .../PushNotificationSettingsView.swift | 24 +++ ...shNotificationSettingsViewController.swift | 151 ++++++++++++++++++ .../AppDelegate.swift | 17 ++ .../SceneDelegate.swift | 40 +++++ .../UserSettings/Cells/ButtonCell.swift | 2 +- .../Cells/ChangeLanguageCell.swift | 2 +- .../UserSettingsViewController.swift | 4 +- .../Common/Foundations/ReusableCell.swift | 31 ---- .../Foundations/ReusableIdentifying.swift | 29 ++++ .../iOSSupport/Localization/WCString.swift | 4 + .../UIKitExtension/PaddingLabel.swift | 39 +++++ .../UIKitExtension/RxBaseReusableCell.swift | 25 +++ .../UITableView+reusable.swift} | 14 +- TestPlans/PushNotificationSettings.xctestplan | 18 +++ .../PushNotificationSettingsTests.swift | 48 ++++++ 23 files changed, 636 insertions(+), 41 deletions(-) create mode 100644 Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift create mode 100644 Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift create mode 100644 Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift create mode 100644 Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift create mode 100644 Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift create mode 100644 Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift create mode 100644 Sources/iOSScenes/PushNotificationSettingsExample/AppDelegate.swift create mode 100644 Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift delete mode 100644 Sources/iOSSupport/Common/Foundations/ReusableCell.swift create mode 100644 Sources/iOSSupport/Common/Foundations/ReusableIdentifying.swift create mode 100644 Sources/iOSSupport/UIKitExtension/PaddingLabel.swift create mode 100644 Sources/iOSSupport/UIKitExtension/RxBaseReusableCell.swift rename Sources/iOSSupport/{UITableView+dequeueReusableCell.swift => UIKitExtension/UITableView+reusable.swift} (55%) create mode 100644 TestPlans/PushNotificationSettings.xctestplan create mode 100644 Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift diff --git a/Project.swift b/Project.swift index 871c08f8..1fbf55f9 100644 --- a/Project.swift +++ b/Project.swift @@ -279,6 +279,34 @@ 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: [ + ], + appendSchemeTo: &schemes + ) + + Target.module( + name: "PushNotificationSettingsExample", + product: .app, + infoPlist: .file(path: "Resources/InfoPlist/InfoExample.plist"), + sourcesPrefix: "iOSScenes", + dependencies: [ + .target(name: "PushNotificationSettings"), + ], + appendSchemeTo: &schemes + ) + Target.module( name: "iPhoneDriver", dependencies: [ diff --git a/QA.md b/QA.md index c0ce8fbb..52004fa0 100644 --- a/QA.md +++ b/QA.md @@ -1,5 +1,8 @@ #Version -1.0.0 (build version: 13) +1.2.1 + +##Common +- Localization 적용 확인 ##WordCheking diff --git a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings index ca3b800b..3f123f58 100644 --- a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings @@ -56,3 +56,7 @@ signed_out_of_google_drive_successfully = "Signed out of Google Drive successful 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"; diff --git a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings index 87c71391..3c7917a5 100644 --- a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings @@ -56,3 +56,7 @@ signed_out_of_google_drive_successfully = "구글 드라이브에서 로그아 synchronize_to_google_drive = "구글 드라이브에 동기화 합니다."; please_check_your_network_connection = "네트워크 연결 상태를 확인해 주세요."; + +daily_reminder = "매일 알림"; +time = "시간"; +notifications = "알림"; 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/SwitchCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift new file mode 100644 index 00000000..6b9e05b1 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift @@ -0,0 +1,44 @@ +// +// SwitchCell.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import RxSwift +import Then +import UIKit + +final class SwitchCell: RxBaseReusableCell { + + struct Model { + let title: String + let isOn: Bool + } + + let leadingLabel: UILabel = .init().then { + $0.adjustsFontForContentSizeCategory = true + } + let trailingSwitch: UISwitch = .init() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + 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..1ff7c490 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift @@ -0,0 +1,30 @@ +// +// 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 func assemble(container: Container) { + container.register(PushNotificationSettingsReactor.self) { _ in + return .init() + } + + 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..757b3960 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift @@ -0,0 +1,75 @@ +// +// PushNotificationSettingsReactor.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import Foundation +import ReactorKit + +public final class PushNotificationSettingsReactor: Reactor { + + public enum Action { + case viewDidLoad + case onDailyReminder + case offDailyReminder + case changeReminderTime(Date) + } + + public enum Mutation { + case enableDailyReminder + case disableDailyReminder + case setReminderTime(DateComponents) + } + + public struct State { + var isOnDailyReminder: Bool + var reminderTime: DateComponents + } + + public let initialState: State = .init( + isOnDailyReminder: false, + reminderTime: .init(hour: 9, minute: 0) + ) + + init() { + + } + + public func mutate(action: Action) -> Observable { + switch action { + case .viewDidLoad: + return .never() + case .onDailyReminder: + return .just(.enableDailyReminder) + case .offDailyReminder: + return .just(.disableDailyReminder) + case .changeReminderTime(let date): + let hourAndMinute = Calendar.current.dateComponents([.hour, .minute], from: date) + + guard let hour = hourAndMinute.hour, let minute = hourAndMinute.minute else { + preconditionFailure("Not included [hour, minute] component in Date.") + } + + return .just(.setReminderTime(.init(hour: hour, minute: minute))) + } + } + + 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 + } + + return state + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift new file mode 100644 index 00000000..23e07b57 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift @@ -0,0 +1,24 @@ +// +// PushNotificationSettingsView.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import UIKit + +final class PushNotificationSettingsView: UITableView { + + override init(frame: CGRect, style: UITableView.Style) { + super.init(frame: frame, style: style) + + self.register(SwitchCell.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..74b34d19 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift @@ -0,0 +1,151 @@ +import iOSSupport +import ReactorKit +import Then +import UIKit + +public final class PushNotificationSettingsViewController: RxBaseViewController, View { + + enum SectionIdentifier { + case dailyReminder + } + + enum ItemIdentifier { + case dailyReminderSwitch + case dailyReminderTimeSetter + case dailyReminderFooter + } + + lazy var rootTableView: PushNotificationSettingsView = .init(frame: .zero, style: .insetGrouped).then { + $0.delegate = self + } + + let footerLabel: PaddingLabel = .init(padding: .init(top: 8, left: 20, bottom: 8, right: 20)).then { + $0.text = "Test footer." + $0.font = .preferredFont(forTextStyle: .footnote) + $0.textColor = .secondaryLabel + } + + 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(SwitchCell.self, for: indexPath) + cell.bind(model: .init(title: WCString.daily_reminder, isOn: reactor.currentState.isOnDailyReminder)) + // Bind to Reactor.Action + cell.trailingSwitch.rx.isOn + .map { $0 ? Reactor.Action.onDailyReminder : Reactor.Action.offDailyReminder } + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + return cell + case .dailyReminderTimeSetter: + let cell = tableView.dequeueReusableCell(DatePickerCell.self, for: indexPath) + cell.trailingDatePicker.datePickerMode = .time + + 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 + case .dailyReminderFooter: + return nil + } + } + + 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() + } + + func initDataSource() { + var snapshot = dataSource.snapshot() + snapshot.appendSections([.dailyReminder]) + snapshot.appendItems([.dailyReminderSwitch]) + dataSource.apply(snapshot) + } + + func setupNavigationBar() { + self.navigationItem.title = WCString.notifications + } + + public func bind(reactor: PushNotificationSettingsReactor) { + // Action + self.rx.sentMessage(#selector(viewDidLoad)) + .map { _ in Reactor.Action.viewDidLoad } + .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() + + if isOnDailyReminder { + snapshot.appendItems([.dailyReminderTimeSetter], toSection: .dailyReminder) + } else { + snapshot.deleteItems([.dailyReminderTimeSetter]) + } + + owner.dataSource.apply(snapshot) + }) + .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) + } + +} + +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 footerLabel + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 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..154c71bd --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift @@ -0,0 +1,40 @@ +// +// SceneDelegate.swift +// WordChecker +// +// Created by Jaewon Yun on 2023/08/23. +// + +@testable import PushNotificationSettings + +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() + 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 index dbdefa6c..b59e5488 100644 --- a/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift +++ b/Sources/iOSScenes/UserSettings/Cells/ButtonCell.swift @@ -12,7 +12,7 @@ import UIKit /// 버튼 역할을 하기 위한 Cell 클래스 입니다. /// /// 버튼 가이드라인에 따른 텍스트 색상을 사용하세요. -final class ButtonCell: UITableViewCell, ReusableCell { +final class ButtonCell: UITableViewCell, ReusableIdentifying { struct Model { let title: String diff --git a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift b/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift index ad35d2a8..c5996faa 100644 --- a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift +++ b/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift @@ -9,7 +9,7 @@ import iOSSupport import UIKit -final class ChangeLanguageCell: UITableViewCell, ReusableCell { +final class ChangeLanguageCell: UITableViewCell, ReusableIdentifying { struct Model { let title: String diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index a7e96108..17469250 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -52,8 +52,8 @@ public final class UserSettingsViewController: RxBaseViewController, View { public weak var delegate: UserSettingsViewControllerDelegate? lazy var settingsTableView: UITableView = .init(frame: .zero, style: .insetGrouped).then { - $0.register(ChangeLanguageCell.self, forCellReuseIdentifier: ChangeLanguageCell.reusableIdentifier) - $0.register(ButtonCell.self, forCellReuseIdentifier: ButtonCell.reusableIdentifier) + $0.register(ChangeLanguageCell.self) + $0.register(ButtonCell.self) } public override func loadView() { diff --git a/Sources/iOSSupport/Common/Foundations/ReusableCell.swift b/Sources/iOSSupport/Common/Foundations/ReusableCell.swift deleted file mode 100644 index 65add336..00000000 --- a/Sources/iOSSupport/Common/Foundations/ReusableCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// ReusableCell.swift -// UserSettings -// -// Created by Jaewon Yun on 11/27/23. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import Foundation - -public protocol ReusableCell { - - /// `Cell` 을 구성하는 데이터들의 집합입니다. - associatedtype Model - - /// A `reusableIdentifier` that will be used by `dequeueReusableCell(withIdentifier:for:)`. - /// - /// If you don't override, it's concrete class name. - static var reusableIdentifier: String { get } - - func bind(model: Model) - -} - -extension ReusableCell { - - public static var reusableIdentifier: String { - return .init(describing: Self.self) - } - -} 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/Localization/WCString.swift b/Sources/iOSSupport/Localization/WCString.swift index 4d3d5adc..27656ba9 100644 --- a/Sources/iOSSupport/Localization/WCString.swift +++ b/Sources/iOSSupport/Localization/WCString.swift @@ -66,4 +66,8 @@ 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: "") + } 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/UITableView+dequeueReusableCell.swift b/Sources/iOSSupport/UIKitExtension/UITableView+reusable.swift similarity index 55% rename from Sources/iOSSupport/UITableView+dequeueReusableCell.swift rename to Sources/iOSSupport/UIKitExtension/UITableView+reusable.swift index 789570d4..e0c3f809 100644 --- a/Sources/iOSSupport/UITableView+dequeueReusableCell.swift +++ b/Sources/iOSSupport/UIKitExtension/UITableView+reusable.swift @@ -10,13 +10,13 @@ import UIKit extension UITableView { - /// Returns a reusable table-view cell object for the class that conform to the `ReusableCell` protocol and adds it to the table. + /// Returns a reusable table-view cell object for the class that conform to the `ReusableIdentifying` protocol and adds it to the table. /// - Parameters: - /// - type: `ReusableCell` 프로토콜을 준수하는 Cell 객체 타입. + /// - 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: `ReusableCell` 프로토콜의 `reusableIdentifier` 와 연관된 재사용 가능한 Cell 입니다. - public func dequeueReusableCell(_ type: Cell.Type, for indexPath: IndexPath) -> Cell { - let cell = self.dequeueReusableCell(withIdentifier: type.reusableIdentifier, for: indexPath) + /// - 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") @@ -25,4 +25,8 @@ extension UITableView { return reusableCell } + public func register(_ cellClass: Cell.Type) { + self.register(cellClass.self, forCellReuseIdentifier: Cell.reuseIdentifier) + } + } diff --git a/TestPlans/PushNotificationSettings.xctestplan b/TestPlans/PushNotificationSettings.xctestplan new file mode 100644 index 00000000..619e61cf --- /dev/null +++ b/TestPlans/PushNotificationSettings.xctestplan @@ -0,0 +1,18 @@ +{ + "configurations" : [ + + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:WordChecker.xcodeproj", + "identifier" : "3F3E981FC1A96E4F891CA1D4", + "name" : "PushNotificationSettingsTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift new file mode 100644 index 00000000..b0a29a30 --- /dev/null +++ b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift @@ -0,0 +1,48 @@ +@testable import PushNotificationSettings + +import XCTest + +final class PushNotificationSettingsTests: XCTestCase { + + var sut: PushNotificationSettingsReactor! + + override func setUpWithError() throws { + try super.setUpWithError() + + sut = .init() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + sut = nil + } + + func test_viewDidLoad() { + // Given + + // When + + // Then + + } + + func test_onOffDailyReminder() { + // Given + + // When + + // Then + + } + + func test_changeReminderTime() { + // Given + + // When + + // Then + + } + +} From b6ef8deb020227c7c879b389ff4b357be2628e56 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Wed, 6 Dec 2023 21:54:38 +0900 Subject: [PATCH 10/24] Add use case for daily reminder to Domain --- Project.swift | 3 + .../UserSettingsRepository.swift | 10 ++ .../UserDefaults/UserDefaultsKey.swift | 2 + .../UserSettingsRepositoryFake.swift | 15 +++ .../DI/UserSettingsUseCaseAssembly.swift | 9 +- .../UserSettingsRepositoryProtocol.swift | 4 + .../UserSettingsUseCaseProtocol.swift | 13 ++ .../Domain/UseCases/UserSettingsUseCase.swift | 65 +++++++++- .../UNUserNotificationCenterTesting.swift | 32 +++++ Tests/DomainTests/SettingUseCaseTests.swift | 62 --------- .../UserSettingsUseCaseTests.swift | 122 ++++++++++++++++++ 11 files changed, 273 insertions(+), 64 deletions(-) create mode 100644 Sources/TestsSupport/UNUserNotificationCenterTesting.swift delete mode 100644 Tests/DomainTests/SettingUseCaseTests.swift create mode 100644 Tests/DomainTests/UserSettingsUseCaseTests.swift diff --git a/Project.swift b/Project.swift index 1fbf55f9..cf6630c0 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 @@ -360,6 +362,7 @@ func targets() -> [Target] { + Target.module( name: "TestsSupport", dependencies: [ + .target(name: "Domain"), .external(name: ExternalDependencyName.rxSwift), .external(name: ExternalDependencyName.rxTest), ], 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 8df0f0bc..24a5bd22 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift @@ -18,4 +18,17 @@ public protocol UserSettingsUseCaseProtocol { func getCurrentUserSettings() -> Single + /// 지정한 시각에 매일 알림을 설정합니다. + func setDailyReminder(at time: DateComponents) -> Single + + /// 설정된 매일 알림을 삭제합니다. + func removeDailyReminder() + + /// 현재 등록되어 있는 매일 알림의 시간을 변경합니다. + /// - Parameter time: 변경할 시간 + func updateDailyReminerTime(to time: DateComponents) -> Single + + /// 마지막으로 설정한 매일 알림의 시간을 반환합니다. + func getLatestDailyReminderTime() throws -> DateComponents + } diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index 268835ba..fcdd4203 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -10,13 +10,28 @@ 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]) +} public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { + let dailyReminderNotificationID: String = "DailyReminder" + let userSettingsRepository: UserSettingsRepositoryProtocol + let notificationCenter: UserNotificationCenter - public init(userSettingsRepository: UserSettingsRepositoryProtocol) { + init( + userSettingsRepository: UserSettingsRepositoryProtocol, + notificationCenter: UserNotificationCenter + ) { self.userSettingsRepository = userSettingsRepository + self.notificationCenter = notificationCenter initUserSettingsIfNoUserSettings() .subscribe() @@ -45,6 +60,50 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { return userSettingsRepository.getUserSettings() } + public func setDailyReminder(at time: DateComponents) -> Single { + let content: UNMutableNotificationContent = .init().then { + // TODO: Content 설정 + $0.title = "Title" + } + let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) + let request: UNNotificationRequest = .init( + identifier: self.dailyReminderNotificationID, + content: content, + trigger: trigger + ) + + do { + try userSettingsRepository.updateLatestDailyReminderTime(time) + } catch { + // TODO: 예외 상황 로그 추가 + } + + return .create { observer in + Task { + try await self.notificationCenter.add(request) + observer(.success(())) + } catch: { error in + observer(.failure(error)) + } + + return Disposables.create() + } + } + + public func removeDailyReminder() { + notificationCenter.removePendingNotificationRequests(withIdentifiers: [dailyReminderNotificationID]) + } + + public func updateDailyReminerTime(to time: DateComponents) -> Single { + notificationCenter.removePendingNotificationRequests(withIdentifiers: [dailyReminderNotificationID]) + + return self.setDailyReminder(at: time) + } + + public func getLatestDailyReminderTime() throws -> DateComponents { + return try userSettingsRepository.getLatestDailyReminderTime() + } + func initUserSettingsIfNoUserSettings() -> RxSwift.Single { return userSettingsRepository.getUserSettings() .mapToVoid() @@ -79,3 +138,7 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { } } + +enum UserSettingsUseCaseError: Error { + case notAddedDailyReminderNotification +} diff --git a/Sources/TestsSupport/UNUserNotificationCenterTesting.swift b/Sources/TestsSupport/UNUserNotificationCenterTesting.swift new file mode 100644 index 00000000..04db69c5 --- /dev/null +++ b/Sources/TestsSupport/UNUserNotificationCenterTesting.swift @@ -0,0 +1,32 @@ +// +// 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 _pendingNotifications: [UNNotificationRequest] = [] + + public init() { + + } + + public func add(_ request: UNNotificationRequest) async throws { + _pendingNotifications.append(request) + } + + public func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + self._pendingNotifications.removeAll(where: { $0.identifier == identifier }) + } + } + +} diff --git a/Tests/DomainTests/SettingUseCaseTests.swift b/Tests/DomainTests/SettingUseCaseTests.swift deleted file mode 100644 index 4d16843b..00000000 --- a/Tests/DomainTests/SettingUseCaseTests.swift +++ /dev/null @@ -1,62 +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 { - // 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) - } - } - -} diff --git a/Tests/DomainTests/UserSettingsUseCaseTests.swift b/Tests/DomainTests/UserSettingsUseCaseTests.swift new file mode 100644 index 00000000..57f716f4 --- /dev/null +++ b/Tests/DomainTests/UserSettingsUseCaseTests.swift @@ -0,0 +1,122 @@ +// +// 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! + var notificationCenterFake: UNUserNotificationCenterFake! + + override func setUpWithError() throws { + try super.setUpWithError() + + let userSettingsRepository: UserSettingsRepositoryProtocol = UserSettingsRepositoryFake() + notificationCenterFake = .init() + + sut = UserSettingsUseCase.init( + userSettingsRepository: userSettingsRepository, + notificationCenter: notificationCenterFake + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + + sut = nil + notificationCenterFake = 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_getLatestDailyReminderTime_whenNeverSetDailyReminder() { + XCTAssertThrowsError(try sut.getLatestDailyReminderTime()) + } + + func test_removeDailyReminder() throws { + // Given + let time: DateComponents = .init(hour: 11, minute: 11) + try sut.setDailyReminder(at: time) + .toBlocking() + .single() + XCTAssertEqual(notificationCenterFake._pendingNotifications.count, 1) + + // When + sut.removeDailyReminder() + + // Then + XCTAssertEqual(notificationCenterFake._pendingNotifications.count, 0) + } + + func test_getLatestDailyReminderTime_afterTurnOffDailyReminder() throws { + // Given + 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 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.updateDailyReminerTime(to: newTime) + .toBlocking() + .single() + + // Then + let latestTime = try sut.getLatestDailyReminderTime() + XCTAssertEqual(latestTime, newTime) + } + +} From b980151d81529b0267ac33602f765bd6b367c6b1 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Fri, 8 Dec 2023 20:19:20 +0900 Subject: [PATCH 11/24] Implement PushNotificationSettingsReactor --- Project.swift | 2 + .../UserSettingsUseCaseProtocol.swift | 5 ++ .../Domain/UseCases/UserSettingsUseCase.swift | 21 +++++++- .../UserSettingsUseCaseFake.swift | 49 ++++++++++++++++++- .../PushNotificationSettingsAssembly.swift | 4 +- .../PushNotificationSettingsReactor.swift | 29 +++++++++-- .../SceneDelegate.swift | 3 +- TestPlans/PushNotificationSettings.xctestplan | 6 +++ .../PushNotificationSettingsTests.swift | 40 ++++++++++++--- 9 files changed, 143 insertions(+), 16 deletions(-) diff --git a/Project.swift b/Project.swift index cf6630c0..81b5a8c1 100644 --- a/Project.swift +++ b/Project.swift @@ -296,6 +296,7 @@ func targets() -> [Target] { ], hasTests: true, additionalTestDependencies: [ + .external(name: ExternalDependencyName.rxBlocking), ], appendSchemeTo: &schemes ) @@ -306,6 +307,7 @@ func targets() -> [Target] { sourcesPrefix: "iOSScenes", dependencies: [ .target(name: "PushNotificationSettings"), + .target(name: "DomainTesting"), ], appendSchemeTo: &schemes ) diff --git a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift index 24a5bd22..cd9ae11d 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift @@ -9,6 +9,7 @@ import Foundation import RxSwift import RxRelay +import UserNotifications public protocol UserSettingsUseCaseProtocol { @@ -24,6 +25,10 @@ public protocol UserSettingsUseCaseProtocol { /// 설정된 매일 알림을 삭제합니다. func removeDailyReminder() + /// 설정되어 있는 매일 알림을 방출하는 시퀀스를 반환합니다. + /// - Returns: 설정된 매일 알림이 있는 경우 알림 객체를 반환합니다. 설정된 매일 알림이 없는 경우 `error` 이벤트를 방출합니다. + func getDailyReminder() -> Single + /// 현재 등록되어 있는 매일 알림의 시간을 변경합니다. /// - Parameter time: 변경할 시간 func updateDailyReminerTime(to time: DateComponents) -> Single diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index fcdd4203..5e7995e3 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -17,6 +17,7 @@ import Utility protocol UserNotificationCenter { func add(_ request: UNNotificationRequest) async throws func removePendingNotificationRequests(withIdentifiers identifiers: [String]) + func pendingNotificationRequests() async -> [UNNotificationRequest] } public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { @@ -94,6 +95,24 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { notificationCenter.removePendingNotificationRequests(withIdentifiers: [dailyReminderNotificationID]) } + public func getDailyReminder() -> Single { + return .create { observer in + Task { + guard let dailyReminder = await self.notificationCenter.pendingNotificationRequests() + .filter({ $0.identifier == self.dailyReminderNotificationID }) + .first + else { + observer(.failure(UserSettingsUseCaseError.notSetDailyReminder)) + return + } + + observer(.success(dailyReminder)) + } + + return Disposables.create() + } + } + public func updateDailyReminerTime(to time: DateComponents) -> Single { notificationCenter.removePendingNotificationRequests(withIdentifiers: [dailyReminderNotificationID]) @@ -140,5 +159,5 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { } enum UserSettingsUseCaseError: Error { - case notAddedDailyReminderNotification + case notSetDailyReminder } diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index 351c70b4..f462316d 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -6,10 +6,12 @@ // Copyright © 2023 woin2ee. All rights reserved. // -import Domain +@testable import Domain + import Foundation import RxSwift import RxRelay +import UserNotifications public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { @@ -18,6 +20,9 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { translationTargetLocale: .korean ) + public var _dailyReminder: UNNotificationRequest? + public var dailyReminderIsRemoved: Bool = true + public init() {} public func updateTranslationLocale(source sourceLocale: Domain.TranslationLanguage, target targetLocale: Domain.TranslationLanguage) -> RxSwift.Single { @@ -37,4 +42,46 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { return .just(currentUserSettings) } + public func setDailyReminder(at time: DateComponents) -> RxSwift.Single { + 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 { + if dailyReminderIsRemoved { + return .error(UserSettingsUseCaseError.notSetDailyReminder) + } + + guard let dailyReminder = _dailyReminder else { + return .error(UserSettingsUseCaseError.notSetDailyReminder) + } + + return .just(dailyReminder) + } + + public func updateDailyReminerTime(to time: DateComponents) -> RxSwift.Single { + if dailyReminderIsRemoved { + return .error(UserSettingsUseCaseError.notSetDailyReminder) + } + + let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) + _dailyReminder = .init(identifier: "Test", content: .init(), trigger: trigger) + return .just(()) + } + + public func getLatestDailyReminderTime() throws -> DateComponents { + guard let trigger = _dailyReminder?.trigger as? UNCalendarNotificationTrigger else { + throw UserSettingsUseCaseError.notSetDailyReminder + } + + return trigger.dateComponents + } + } diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift index 1ff7c490..335a6804 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift @@ -14,8 +14,8 @@ import Then public final class PushNotificationSettingsAssembly: Assembly { public func assemble(container: Container) { - container.register(PushNotificationSettingsReactor.self) { _ in - return .init() + container.register(PushNotificationSettingsReactor.self) { resolver in + return .init(userSettingsUseCase: resolver.resolve()) } container.register(PushNotificationSettingsViewController.self) { resolver in diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift index 757b3960..2c7db2fa 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift @@ -6,6 +6,7 @@ // Copyright © 2023 woin2ee. All rights reserved. // +import Domain import Foundation import ReactorKit @@ -34,18 +35,40 @@ public final class PushNotificationSettingsReactor: Reactor { reminderTime: .init(hour: 9, minute: 0) ) - init() { + let userSettingsUseCase: UserSettingsUseCaseProtocol + init(userSettingsUseCase: UserSettingsUseCaseProtocol) { + self.userSettingsUseCase = userSettingsUseCase } public func mutate(action: Action) -> Observable { switch action { case .viewDidLoad: - return .never() + let latestTime = try? userSettingsUseCase.getLatestDailyReminderTime() + + let setReminderTimeSequence: Observable = latestTime != nil + ? .just(.setReminderTime(latestTime!)) + : .empty() + + let getDailyReminderSequence = userSettingsUseCase.getDailyReminder() + .asObservable() + .catch { _ in return .empty() } + .map { _ in Mutation.enableDailyReminder } + + return .merge([ + getDailyReminderSequence, + setReminderTimeSequence, + ]) + case .onDailyReminder: - return .just(.enableDailyReminder) + return userSettingsUseCase.setDailyReminder(at: self.currentState.reminderTime) + .asObservable() + .map { Mutation.enableDailyReminder } + case .offDailyReminder: + userSettingsUseCase.removeDailyReminder() return .just(.disableDailyReminder) + case .changeReminderTime(let date): let hourAndMinute = Calendar.current.dateComponents([.hour, .minute], from: date) diff --git a/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift b/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift index 154c71bd..d79f20f1 100644 --- a/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift +++ b/Sources/iOSScenes/PushNotificationSettingsExample/SceneDelegate.swift @@ -7,6 +7,7 @@ @testable import PushNotificationSettings +import DomainTesting import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -19,7 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.makeKeyAndVisible() let viewController: PushNotificationSettingsViewController = .init() - let reactor: PushNotificationSettingsReactor = .init() + let reactor: PushNotificationSettingsReactor = .init(userSettingsUseCase: UserSettingsUseCaseFake()) viewController.reactor = reactor let navigationController: UINavigationController = .init(rootViewController: viewController) diff --git a/TestPlans/PushNotificationSettings.xctestplan b/TestPlans/PushNotificationSettings.xctestplan index 619e61cf..3afa2792 100644 --- a/TestPlans/PushNotificationSettings.xctestplan +++ b/TestPlans/PushNotificationSettings.xctestplan @@ -1,6 +1,12 @@ { "configurations" : [ + { + "id" : "33DC4C4D-6922-4D95-92A4-65E64DD26377", + "name" : "Configuration 1", + "options" : { + } + } ], "defaultOptions" : { "testTimeoutsEnabled" : true diff --git a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift index b0a29a30..23a173cf 100644 --- a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift +++ b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift @@ -1,5 +1,7 @@ @testable import PushNotificationSettings +import DomainTesting +import RxBlocking import XCTest final class PushNotificationSettingsTests: XCTestCase { @@ -9,7 +11,7 @@ final class PushNotificationSettingsTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - sut = .init() + sut = .init(userSettingsUseCase: UserSettingsUseCaseFake()) } override func tearDownWithError() throws { @@ -18,31 +20,53 @@ final class PushNotificationSettingsTests: XCTestCase { sut = nil } - func test_viewDidLoad() { + func test_viewDidLoad_whenDailyReminderIsSet() throws { // Given + let userSettingsUseCase: UserSettingsUseCaseFake = .init() + try userSettingsUseCase.setDailyReminder(at: .init(hour: 11, minute: 22)) + .toBlocking() + .single() + sut = .init(userSettingsUseCase: userSettingsUseCase) // When + sut.action.onNext(.viewDidLoad) // Then - + XCTAssertEqual(sut.currentState.isOnDailyReminder, true) + XCTAssertEqual(sut.currentState.reminderTime, .init(hour: 11, minute: 22)) } - func test_onOffDailyReminder() { - // Given + func test_turnOnOffDailyReminder() { + // On + do { + // When + sut.action.onNext(.onDailyReminder) - // When + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, true) + } - // Then + // Off + do { + // When + sut.action.onNext(.offDailyReminder) + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + } } func test_changeReminderTime() { // Given + sut.action.onNext(.onDailyReminder) + 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)) } } From 3201eb6de6663cb5edc1577f6bbb074a02a4a88a Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Fri, 8 Dec 2023 20:29:49 +0900 Subject: [PATCH 12/24] Delete dead code --- .../PushNotificationSettings/Cells/SwitchCell.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift index 6b9e05b1..94ba573d 100644 --- a/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift @@ -7,8 +7,6 @@ // import iOSSupport -import RxSwift -import Then import UIKit final class SwitchCell: RxBaseReusableCell { @@ -18,9 +16,6 @@ final class SwitchCell: RxBaseReusableCell { let isOn: Bool } - let leadingLabel: UILabel = .init().then { - $0.adjustsFontForContentSizeCategory = true - } let trailingSwitch: UISwitch = .init() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { From 67d0a5d5318843661e876fcfa5bd0abd30d43b6d Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Fri, 8 Dec 2023 20:40:54 +0900 Subject: [PATCH 13/24] Fix typo --- .../iOSScenes/UserSettings/UserSettingsViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index 17469250..e647fd39 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -63,7 +63,7 @@ public final class UserSettingsViewController: RxBaseViewController, View { public override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .systemGroupedBackground - applyDefualtSnapshot() + applyDefaultSnapshot() } public override func viewWillAppear(_ animated: Bool) { @@ -71,7 +71,7 @@ public final class UserSettingsViewController: RxBaseViewController, View { setupNavigationBar() } - func applyDefualtSnapshot() { + func applyDefaultSnapshot() { var snapshot: NSDiffableDataSourceSnapshot = .init() snapshot.appendSections([.changeLanguage, .googleDriveSync]) From 9d6bd35decf444ece9a3f58db16714197b555867 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Wed, 13 Dec 2023 13:58:01 +0900 Subject: [PATCH 14/24] Rename method of delegate --- .../WordDetail/WordDetailViewController.swift | 12 ++++++------ .../WordList/WordSearchResultsController.swift | 2 +- .../Coordinators/WordDetailCoordinator.swift | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) 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 b812e77e..b4510faa 100644 --- a/Sources/iOSScenes/WordList/WordSearchResultsController.swift +++ b/Sources/iOSScenes/WordList/WordSearchResultsController.swift @@ -157,7 +157,7 @@ extension WordSearchResultsController: UISearchResultsUpdating { extension WordSearchResultsController: WordDetailViewControllerDelegate { - func didFinishInteraction() { + func willFinishInteraction() { self.presentingViewController?.tabBarController?.dismiss(animated: true) } 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 }) } From 082ef0b129dce0028114baf6daf03ff0fe1fb433 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Tue, 26 Dec 2023 16:10:49 +0900 Subject: [PATCH 15/24] Improve UserSettingsUseCase and associated tests --- .../UserSettingsUseCaseProtocol.swift | 12 ++- .../Domain/UseCases/UserSettingsUseCase.swift | 77 ++++++++++++------ .../UserSettingsUseCaseFake.swift | 25 +++--- .../UNUserNotificationCenterTesting.swift | 39 +++++++++ .../UserSettingsUseCaseTests.swift | 79 ++++++++++++++++--- 5 files changed, 185 insertions(+), 47 deletions(-) diff --git a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift index cd9ae11d..156b6f13 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift @@ -19,6 +19,14 @@ public protocol UserSettingsUseCaseProtocol { 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 @@ -29,10 +37,6 @@ public protocol UserSettingsUseCaseProtocol { /// - Returns: 설정된 매일 알림이 있는 경우 알림 객체를 반환합니다. 설정된 매일 알림이 없는 경우 `error` 이벤트를 방출합니다. func getDailyReminder() -> Single - /// 현재 등록되어 있는 매일 알림의 시간을 변경합니다. - /// - Parameter time: 변경할 시간 - func updateDailyReminerTime(to time: DateComponents) -> Single - /// 마지막으로 설정한 매일 알림의 시간을 반환합니다. func getLatestDailyReminderTime() throws -> DateComponents diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index 5e7995e3..95a2b35b 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -18,10 +18,13 @@ 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 { + /// Notification request 의 고유 ID let dailyReminderNotificationID: String = "DailyReminder" let userSettingsRepository: UserSettingsRepositoryProtocol @@ -61,27 +64,51 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { return userSettingsRepository.getUserSettings() } - public func setDailyReminder(at time: DateComponents) -> Single { - let content: UNMutableNotificationContent = .init().then { - // TODO: Content 설정 - $0.title = "Title" - } - let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) - let request: UNNotificationRequest = .init( - identifier: self.dailyReminderNotificationID, - content: content, - trigger: trigger - ) - - do { - try userSettingsRepository.updateLatestDailyReminderTime(time) - } catch { - // TODO: 예외 상황 로그 추가 + 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() } + } + public func getNotificationAuthorizationStatus() -> Single { return .create { observer in Task { - try await self.notificationCenter.add(request) + let notificationSettings = await self.notificationCenter.notificationSettings() + observer(.success(notificationSettings.authorizationStatus)) + } + + return Disposables.create() + } + } + + public func setDailyReminder(at time: DateComponents) -> Single { + let setDailyReminderSequence: Single = .create { observer in + let content: UNMutableNotificationContent = .init().then { + // TODO: Content 설정 + $0.title = "Title" + } + let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) + let notificationRequest: UNNotificationRequest = .init( + identifier: self.dailyReminderNotificationID, + 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)) @@ -89,6 +116,15 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { return Disposables.create() } + + return self.getNotificationAuthorizationStatus() + .flatMap { authorizationStatus in + if authorizationStatus == .authorized { + return setDailyReminderSequence + } else { + return .error(UserSettingsUseCaseError.noNotificationAuthorization) + } + } } public func removeDailyReminder() { @@ -113,12 +149,6 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { } } - public func updateDailyReminerTime(to time: DateComponents) -> Single { - notificationCenter.removePendingNotificationRequests(withIdentifiers: [dailyReminderNotificationID]) - - return self.setDailyReminder(at: time) - } - public func getLatestDailyReminderTime() throws -> DateComponents { return try userSettingsRepository.getLatestDailyReminderTime() } @@ -160,4 +190,5 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { enum UserSettingsUseCaseError: Error { case notSetDailyReminder + case noNotificationAuthorization } diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index f462316d..ed7dd208 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -20,6 +20,7 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { translationTargetLocale: .korean ) + public var _authorizationStatus: UNAuthorizationStatus = .notDetermined public var _dailyReminder: UNNotificationRequest? public var dailyReminderIsRemoved: Bool = true @@ -43,6 +44,10 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { } 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) @@ -66,16 +71,6 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { return .just(dailyReminder) } - public func updateDailyReminerTime(to time: DateComponents) -> RxSwift.Single { - if dailyReminderIsRemoved { - return .error(UserSettingsUseCaseError.notSetDailyReminder) - } - - let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) - _dailyReminder = .init(identifier: "Test", content: .init(), trigger: trigger) - return .just(()) - } - public func getLatestDailyReminderTime() throws -> DateComponents { guard let trigger = _dailyReminder?.trigger as? UNCalendarNotificationTrigger else { throw UserSettingsUseCaseError.notSetDailyReminder @@ -84,4 +79,14 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { return trigger.dateComponents } + /// Test - 인증 상태를 `authorized` 로 설정하고 true 를 방출합니다. + public func requestNotificationAuthorization(with options: UNAuthorizationOptions) -> RxSwift.Single { + _authorizationStatus = .authorized + return .just(true) + } + + public func getNotificationAuthorizationStatus() -> RxSwift.Single { + return .just(_authorizationStatus) + } + } diff --git a/Sources/TestsSupport/UNUserNotificationCenterTesting.swift b/Sources/TestsSupport/UNUserNotificationCenterTesting.swift index 04db69c5..510a7472 100644 --- a/Sources/TestsSupport/UNUserNotificationCenterTesting.swift +++ b/Sources/TestsSupport/UNUserNotificationCenterTesting.swift @@ -13,6 +13,7 @@ import UserNotifications public final class UNUserNotificationCenterFake: UserNotificationCenter { + public var _authorizationStatus: UNAuthorizationStatus = .notDetermined public var _pendingNotifications: [UNNotificationRequest] = [] public init() { @@ -20,6 +21,11 @@ public final class UNUserNotificationCenterFake: UserNotificationCenter { } public func add(_ request: UNNotificationRequest) async throws { + if let duplicatedIDIndex = _pendingNotifications.firstIndex(where: { $0.identifier == request.identifier }) { + _pendingNotifications[duplicatedIDIndex] = request + return + } + _pendingNotifications.append(request) } @@ -29,4 +35,37 @@ public final class UNUserNotificationCenterFake: UserNotificationCenter { } } + 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/Tests/DomainTests/UserSettingsUseCaseTests.swift b/Tests/DomainTests/UserSettingsUseCaseTests.swift index 57f716f4..55569c88 100644 --- a/Tests/DomainTests/UserSettingsUseCaseTests.swift +++ b/Tests/DomainTests/UserSettingsUseCaseTests.swift @@ -16,17 +16,13 @@ import XCTest final class UserSettingsUseCaseTests: XCTestCase { var sut: UserSettingsUseCaseProtocol! - var notificationCenterFake: UNUserNotificationCenterFake! override func setUpWithError() throws { try super.setUpWithError() - let userSettingsRepository: UserSettingsRepositoryProtocol = UserSettingsRepositoryFake() - notificationCenterFake = .init() - sut = UserSettingsUseCase.init( - userSettingsRepository: userSettingsRepository, - notificationCenter: notificationCenterFake + userSettingsRepository: UserSettingsRepositoryFake(), + notificationCenter: UNUserNotificationCenterFake() ) } @@ -34,7 +30,6 @@ final class UserSettingsUseCaseTests: XCTestCase { try super.tearDownWithError() sut = nil - notificationCenterFake = nil } func testSetToOtherTranslationLocale() throws { @@ -67,27 +62,81 @@ final class UserSettingsUseCaseTests: XCTestCase { } } + 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() - XCTAssertEqual(notificationCenterFake._pendingNotifications.count, 1) // When sut.removeDailyReminder() // Then - XCTAssertEqual(notificationCenterFake._pendingNotifications.count, 0) + 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() @@ -103,6 +152,11 @@ final class UserSettingsUseCaseTests: XCTestCase { 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() @@ -110,13 +164,18 @@ final class UserSettingsUseCaseTests: XCTestCase { // When let newTime: DateComponents = .init(hour: 12, minute: 12) - try sut.updateDailyReminerTime(to: newTime) + 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) } } From ac99f83126563eb6f8f707e015cbec0097338a6a Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Tue, 26 Dec 2023 16:13:27 +0900 Subject: [PATCH 16/24] Add missing dependency --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index 81b5a8c1..3ff7d850 100644 --- a/Project.swift +++ b/Project.swift @@ -296,6 +296,7 @@ func targets() -> [Target] { ], hasTests: true, additionalTestDependencies: [ + .target(name: "DomainTesting"), .external(name: ExternalDependencyName.rxBlocking), ], appendSchemeTo: &schemes From 37c8de1377cf473644e3b4c0489ded17934fd93c Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Tue, 26 Dec 2023 16:53:07 +0900 Subject: [PATCH 17/24] Add coordinating for PushNotificationSettings --- Project.swift | 1 + Sources/WordChecker/AppDelegate.swift | 2 + Sources/WordCheckerDev/AppDelegate.swift | 2 + .../PushNotificationSettingsAssembly.swift | 2 + ...shNotificationSettingsViewController.swift | 15 +++++++ ...ll.swift => DisclosureIndicatorCell.swift} | 4 +- .../UserSettingsItemIdentifier.swift | 5 +++ ...Item.swift => UserSettingsItemModel.swift} | 6 +-- .../UserSettingsSectionIdentifier.swift | 6 +-- .../UserSettingsViewController.swift | 37 ++++++++++++----- .../PushNotificationSettingsCoordinator.swift | 41 +++++++++++++++++++ .../UserSettingsCoordinator.swift | 7 ++++ 12 files changed, 108 insertions(+), 20 deletions(-) rename Sources/iOSScenes/UserSettings/Cells/{ChangeLanguageCell.swift => DisclosureIndicatorCell.swift} (87%) rename Sources/iOSScenes/UserSettings/UserSettingsDataSource/{UserSettingsItem.swift => UserSettingsItemModel.swift} (58%) create mode 100644 Sources/iPhoneDriver/Coordinators/PushNotificationSettingsCoordinator.swift diff --git a/Project.swift b/Project.swift index 3ff7d850..8f2297a2 100644 --- a/Project.swift +++ b/Project.swift @@ -322,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), diff --git a/Sources/WordChecker/AppDelegate.swift b/Sources/WordChecker/AppDelegate.swift index 722225d0..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 @@ -84,6 +85,7 @@ extension AppDelegate { WordAdditionAssembly(), UserSettingsAssembly(), LanguageSettingAssembly(), + PushNotificationSettingsAssembly(), ]) } diff --git a/Sources/WordCheckerDev/AppDelegate.swift b/Sources/WordCheckerDev/AppDelegate.swift index d0f76b51..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 @@ -75,6 +76,7 @@ extension AppDelegate { WordAdditionAssembly(), UserSettingsAssembly(), LanguageSettingAssembly(), + PushNotificationSettingsAssembly(), ]) } diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift index 335a6804..2a7c91d9 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift @@ -13,6 +13,8 @@ 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()) diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift index 74b34d19..8eb86e60 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift @@ -3,6 +3,10 @@ import ReactorKit import Then import UIKit +public protocol PushNotificationSettingsDelegate: AnyObject { + func willPopView() +} + public final class PushNotificationSettingsViewController: RxBaseViewController, View { enum SectionIdentifier { @@ -25,6 +29,8 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, $0.textColor = .secondaryLabel } + 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 } @@ -80,6 +86,14 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, setupNavigationBar() } + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if self.isMovingFromParent { + delegate?.willPopView() + } + } + func initDataSource() { var snapshot = dataSource.snapshot() snapshot.appendSections([.dailyReminder]) @@ -89,6 +103,7 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, func setupNavigationBar() { self.navigationItem.title = WCString.notifications + self.navigationItem.largeTitleDisplayMode = .never } public func bind(reactor: PushNotificationSettingsReactor) { diff --git a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift b/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift similarity index 87% rename from Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift rename to Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift index c5996faa..5054dd46 100644 --- a/Sources/iOSScenes/UserSettings/Cells/ChangeLanguageCell.swift +++ b/Sources/iOSScenes/UserSettings/Cells/DisclosureIndicatorCell.swift @@ -1,5 +1,5 @@ // -// ChangeLanguageCell.swift +// DisclosureIndicatorCell.swift // UserSettings // // Created by Jaewon Yun on 11/27/23. @@ -9,7 +9,7 @@ import iOSSupport import UIKit -final class ChangeLanguageCell: UITableViewCell, ReusableIdentifying { +final class DisclosureIndicatorCell: UITableViewCell, ReusableIdentifying { struct Model { let title: String diff --git a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift index 47f8eb1b..8c031bb0 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemIdentifier.swift @@ -6,12 +6,17 @@ // 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/UserSettingsItem.swift b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift similarity index 58% rename from Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItem.swift rename to Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift index 4f5e8b9c..8c7ce7bf 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItem.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsItemModel.swift @@ -1,5 +1,5 @@ // -// UserSettingsItem.swift +// UserSettingsItemModel.swift // UserSettings // // Created by Jaewon Yun on 11/28/23. @@ -8,7 +8,7 @@ import Foundation -enum UserSettingsItem { - case changeLanguage(ChangeLanguageCell.Model) +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 index 8911b888..265d00e7 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsDataSource/UserSettingsSectionIdentifier.swift @@ -8,12 +8,10 @@ import Foundation +/// 화면에 섹션들이 실제 표시되는 순서대로 cases 가 선언되어 있습니다. enum UserSettingsSectionIdentifier: Hashable, Sendable { - case changeLanguage - + case notifications case googleDriveSync - case signOut - } diff --git a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift index e647fd39..007dce48 100644 --- a/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift +++ b/Sources/iOSScenes/UserSettings/UserSettingsViewController.swift @@ -20,26 +20,29 @@ public protocol UserSettingsViewControllerDelegate: AnyObject { func didTapTargetLanguageSettingRow(currentSettingLocale: TranslationLanguage) + func didTapNotificationsSettingRow() + } public final class UserSettingsViewController: RxBaseViewController, View { - private(set) var dataSourceModel: [UserSettingsItemIdentifier: UserSettingsItem] = [ - .changeSourceLanguage: .changeLanguage(.init(title: WCString.source_language, value: nil)), - .changeTargetLanguage: .changeLanguage(.init(title: WCString.translation_language, value: nil)), + 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)), ] lazy var settingsTableViewDataSource: UITableViewDiffableDataSource = .init(tableView: settingsTableView) { tableView, indexPath, id in - guard let item = self.dataSourceModel[id] else { + guard let itemModel = self.dataSourceModel[id] else { preconditionFailure("dataSourceModel 에 해당 \(id) 를 가진 Item 이 없습니다.") } - switch item { - case .changeLanguage(let model): - let cell = tableView.dequeueReusableCell(ChangeLanguageCell.self, for: indexPath) + switch itemModel { + case .disclosureIndicator(let model): + let cell = tableView.dequeueReusableCell(DisclosureIndicatorCell.self, for: indexPath) cell.bind(model: model) return cell case .button(let model): @@ -52,7 +55,7 @@ public final class UserSettingsViewController: RxBaseViewController, View { public weak var delegate: UserSettingsViewControllerDelegate? lazy var settingsTableView: UITableView = .init(frame: .zero, style: .insetGrouped).then { - $0.register(ChangeLanguageCell.self) + $0.register(DisclosureIndicatorCell.self) $0.register(ButtonCell.self) } @@ -73,13 +76,18 @@ public final class UserSettingsViewController: RxBaseViewController, View { func applyDefaultSnapshot() { var snapshot: NSDiffableDataSourceSnapshot = .init() - snapshot.appendSections([.changeLanguage, .googleDriveSync]) + snapshot.appendSections([.changeLanguage, .notifications, .googleDriveSync]) snapshot.appendItems( [.changeSourceLanguage, .changeTargetLanguage], toSection: .changeLanguage ) + snapshot.appendItems( + [.notifications], + toSection: .notifications + ) + snapshot.appendItems( [.googleDriveUpload, .googleDriveDownload], toSection: .googleDriveSync @@ -134,6 +142,13 @@ public final class UserSettingsViewController: RxBaseViewController, View { }) .disposed(by: self.disposeBag) + 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) @@ -173,7 +188,7 @@ public final class UserSettingsViewController: RxBaseViewController, View { .distinctUntilChanged() .asDriverOnErrorJustComplete() .drive(with: self, onNext: { owner, translationSourceLanguage in - owner.dataSourceModel[.changeSourceLanguage] = .changeLanguage(.init(title: WCString.source_language, value: translationSourceLanguage.localizedString)) + owner.dataSourceModel[.changeSourceLanguage] = .disclosureIndicator(.init(title: WCString.source_language, value: translationSourceLanguage.localizedString)) var snapshot = owner.settingsTableViewDataSource.snapshot() snapshot.reconfigureItems([.changeSourceLanguage]) @@ -186,7 +201,7 @@ public final class UserSettingsViewController: RxBaseViewController, View { .distinctUntilChanged() .asDriverOnErrorJustComplete() .drive(with: self, onNext: { owner, translationTargetLanguage in - owner.dataSourceModel[.changeTargetLanguage] = .changeLanguage(.init(title: WCString.translation_language, value: translationTargetLanguage.localizedString)) + owner.dataSourceModel[.changeTargetLanguage] = .disclosureIndicator(.init(title: WCString.translation_language, value: translationTargetLanguage.localizedString)) var snapshot = owner.settingsTableViewDataSource.snapshot() snapshot.reconfigureItems([.changeTargetLanguage]) 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() + } + } From f9bea252f5641aadde17b7c063ac8db76fcdacaa Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Wed, 17 Jan 2024 14:49:59 +0900 Subject: [PATCH 18/24] Update comment --- .../Interfaces/UseCases/UserSettingsUseCaseProtocol.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift index 156b6f13..e6f5eacf 100644 --- a/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift +++ b/Sources/Domain/Interfaces/UseCases/UserSettingsUseCaseProtocol.swift @@ -34,7 +34,7 @@ public protocol UserSettingsUseCaseProtocol { func removeDailyReminder() /// 설정되어 있는 매일 알림을 방출하는 시퀀스를 반환합니다. - /// - Returns: 설정된 매일 알림이 있는 경우 알림 객체를 반환합니다. 설정된 매일 알림이 없는 경우 `error` 이벤트를 방출합니다. + /// - Returns: 설정된 매일 알림이 있는 경우 알림 객체를 반환합니다. 설정된 매일 알림이 없거나 알림이 꺼져있는 경우 `error` 이벤트를 방출합니다. func getDailyReminder() -> Single /// 마지막으로 설정한 매일 알림의 시간을 반환합니다. From 37038029ef1e40a008cc1a2695d62cc5b01874c5 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Wed, 17 Jan 2024 14:52:48 +0900 Subject: [PATCH 19/24] Rename variables --- Sources/Domain/UseCases/UserSettingsUseCase.swift | 12 ++++++------ .../DomainTesting/UserSettingsUseCaseFake.swift | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/Domain/UseCases/UserSettingsUseCase.swift b/Sources/Domain/UseCases/UserSettingsUseCase.swift index 95a2b35b..8ee3b333 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -25,7 +25,7 @@ protocol UserNotificationCenter { public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { /// Notification request 의 고유 ID - let dailyReminderNotificationID: String = "DailyReminder" + let DAILY_REMINDER_NOTIFICATION_ID: String = "DailyReminder" let userSettingsRepository: UserSettingsRepositoryProtocol let notificationCenter: UserNotificationCenter @@ -96,7 +96,7 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { } let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) let notificationRequest: UNNotificationRequest = .init( - identifier: self.dailyReminderNotificationID, + identifier: self.DAILY_REMINDER_NOTIFICATION_ID, content: content, trigger: trigger ) @@ -128,17 +128,17 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { } public func removeDailyReminder() { - notificationCenter.removePendingNotificationRequests(withIdentifiers: [dailyReminderNotificationID]) + 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.dailyReminderNotificationID }) + .filter({ $0.identifier == self.DAILY_REMINDER_NOTIFICATION_ID }) .first else { - observer(.failure(UserSettingsUseCaseError.notSetDailyReminder)) + observer(.failure(UserSettingsUseCaseError.noPendingDailyReminder)) return } @@ -189,6 +189,6 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { } enum UserSettingsUseCaseError: Error { - case notSetDailyReminder + case noPendingDailyReminder case noNotificationAuthorization } diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index ed7dd208..3a6ed228 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -60,12 +60,12 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { } public func getDailyReminder() -> RxSwift.Single { - if dailyReminderIsRemoved { - return .error(UserSettingsUseCaseError.notSetDailyReminder) - } - - guard let dailyReminder = _dailyReminder else { - return .error(UserSettingsUseCaseError.notSetDailyReminder) + guard + _authorizationStatus == .authorized, + dailyReminderIsRemoved == false, + let dailyReminder = _dailyReminder + else { + return .error(UserSettingsUseCaseError.noPendingDailyReminder) } return .just(dailyReminder) @@ -73,7 +73,7 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { public func getLatestDailyReminderTime() throws -> DateComponents { guard let trigger = _dailyReminder?.trigger as? UNCalendarNotificationTrigger else { - throw UserSettingsUseCaseError.notSetDailyReminder + throw UserSettingsUseCaseError.noPendingDailyReminder } return trigger.dateComponents From 8b512c13a512063ca14e4d889865521df95212e2 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Wed, 17 Jan 2024 20:12:04 +0900 Subject: [PATCH 20/24] Implement Reactor --- .../UserSettingsUseCaseFake.swift | 16 ++- .../PushNotificationSettingsReactor.swift | 68 +++++++---- .../PushNotificationSettingsTests.swift | 106 +++++++++++++++--- 3 files changed, 153 insertions(+), 37 deletions(-) diff --git a/Sources/DomainTesting/UserSettingsUseCaseFake.swift b/Sources/DomainTesting/UserSettingsUseCaseFake.swift index 3a6ed228..55b74311 100644 --- a/Sources/DomainTesting/UserSettingsUseCaseFake.swift +++ b/Sources/DomainTesting/UserSettingsUseCaseFake.swift @@ -21,10 +21,13 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { ) 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 { currentUserSettings.translationSourceLocale = sourceLocale @@ -79,10 +82,15 @@ public final class UserSettingsUseCaseFake: UserSettingsUseCaseProtocol { return trigger.dateComponents } - /// Test - 인증 상태를 `authorized` 로 설정하고 true 를 방출합니다. + /// Test - 현재 인증 상태를 미리 설정한 예상 인증 상태(`expectedAuthorizationStatus`)로 설정하고 `UNAuthorizationOptions.authorized` 일때만 true 를 방출합니다. public func requestNotificationAuthorization(with options: UNAuthorizationOptions) -> RxSwift.Single { - _authorizationStatus = .authorized - return .just(true) + _authorizationStatus = expectedAuthorizationStatus + + if _authorizationStatus == .authorized { + return .just(true) + } + + return .just(false) } public func getNotificationAuthorizationStatus() -> RxSwift.Single { diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift index 2c7db2fa..3f139bd6 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift @@ -13,9 +13,8 @@ import ReactorKit public final class PushNotificationSettingsReactor: Reactor { public enum Action { - case viewDidLoad - case onDailyReminder - case offDailyReminder + case reactorNeedsUpdate + case tapDailyReminderSwitch case changeReminderTime(Date) } @@ -23,11 +22,15 @@ public final class PushNotificationSettingsReactor: Reactor { 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( @@ -43,40 +46,63 @@ public final class PushNotificationSettingsReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { - case .viewDidLoad: + case .reactorNeedsUpdate: let latestTime = try? userSettingsUseCase.getLatestDailyReminderTime() let setReminderTimeSequence: Observable = latestTime != nil ? .just(.setReminderTime(latestTime!)) : .empty() - let getDailyReminderSequence = userSettingsUseCase.getDailyReminder() + let enableDailyReminderSequence = userSettingsUseCase.getDailyReminder() .asObservable() .catch { _ in return .empty() } .map { _ in Mutation.enableDailyReminder } return .merge([ - getDailyReminderSequence, + enableDailyReminderSequence, setReminderTimeSequence, ]) - case .onDailyReminder: - return userSettingsUseCase.setDailyReminder(at: self.currentState.reminderTime) - .asObservable() - .map { Mutation.enableDailyReminder } + case .tapDailyReminderSwitch: + /// 현재 `DailyReminder` 가 ON 상태이면 OFF 로, OFF 상태이면 ON 으로 변경하는 `Mutation` 을 반환합니다. + func toggleDailyReminder() -> Mutation { + if currentState.isOnDailyReminder { + return .disableDailyReminder + } else { + return .enableDailyReminder + } + } - case .offDailyReminder: - userSettingsUseCase.removeDailyReminder() - return .just(.disableDailyReminder) + 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 .just(toggleDailyReminder()) + } else { + return .merge([ + .just(.disableDailyReminder), + .just(.showNeedAuthAlert), + ]) + } + } + case .authorized: + return .just(toggleDailyReminder()) + default: + return .merge([ + .just(.disableDailyReminder), + .just(.showMoveToAuthSettingAlert), + ]) + } + } case .changeReminderTime(let date): let hourAndMinute = Calendar.current.dateComponents([.hour, .minute], from: date) - - guard let hour = hourAndMinute.hour, let minute = hourAndMinute.minute else { - preconditionFailure("Not included [hour, minute] component in Date.") - } - - return .just(.setReminderTime(.init(hour: hour, minute: minute))) + return .just(.setReminderTime(hourAndMinute)) } } @@ -90,6 +116,10 @@ public final class PushNotificationSettingsReactor: Reactor { state.isOnDailyReminder = false case .setReminderTime(let dateComponents): state.reminderTime = dateComponents + case .showNeedAuthAlert: + state.needAuthAlert = () + case .showMoveToAuthSettingAlert: + state.moveToAuthSettingAlert = () } return state diff --git a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift index 23a173cf..10dd3ad1 100644 --- a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift +++ b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift @@ -11,7 +11,7 @@ final class PushNotificationSettingsTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - sut = .init(userSettingsUseCase: UserSettingsUseCaseFake()) + sut = .init(userSettingsUseCase: UserSettingsUseCaseFake(expectedAuthorizationStatus: .authorized)) } override func tearDownWithError() throws { @@ -20,45 +20,112 @@ final class PushNotificationSettingsTests: XCTestCase { sut = nil } - func test_viewDidLoad_whenDailyReminderIsSet() throws { - // Given - let userSettingsUseCase: UserSettingsUseCaseFake = .init() + 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() - sut = .init(userSettingsUseCase: userSettingsUseCase) - // When - sut.action.onNext(.viewDidLoad) + // when authorized + do { + // Given + sut = .init(userSettingsUseCase: userSettingsUseCase) - // Then - XCTAssertEqual(sut.currentState.isOnDailyReminder, true) - XCTAssertEqual(sut.currentState.reminderTime, .init(hour: 11, minute: 22)) + // 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) + + // When + sut.action.onNext(.reactorNeedsUpdate) + + // Then + XCTAssertEqual(sut.currentState.isOnDailyReminder, false) + XCTAssertEqual(sut.currentState.reminderTime, .init(hour: 11, minute: 22)) + } } - func test_turnOnOffDailyReminder() { + func test_turnOnOffDailyReminder_whenAuthorized() throws { + // Given + sut.action.onNext(.reactorNeedsUpdate) + // On do { // When - sut.action.onNext(.onDailyReminder) + sut.action.onNext(.tapDailyReminderSwitch) // Then XCTAssertEqual(sut.currentState.isOnDailyReminder, true) + XCTAssertNil(sut.currentState.needAuthAlert) + XCTAssertNil(sut.currentState.moveToAuthSettingAlert) } // Off do { // When - sut.action.onNext(.offDailyReminder) + 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) + + 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) + + 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(.onDailyReminder) + sut.action.onNext(.tapDailyReminderSwitch) XCTAssertEqual(sut.currentState.reminderTime, sut.initialState.reminderTime) // When @@ -70,3 +137,14 @@ final class PushNotificationSettingsTests: XCTestCase { } } + +// func test_() { +// // Given +// +// +// // When +// +// +// // Then +// +// } From ec4b6e607bb2bc26877ae428a7b6ebe97fc446dd Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Thu, 18 Jan 2024 16:51:51 +0900 Subject: [PATCH 21/24] Implement PushNotificationSettings --- Changelog/1.3.0.md | 5 ++ QA.md | 3 +- .../Localization/en.lproj/Localizable.strings | 2 + .../Localization/ko.lproj/Localizable.strings | 2 + .../Localization/en.lproj/Localizable.strings | 2 + .../Localization/ko.lproj/Localizable.strings | 2 + .../Domain/Localization/DomainString.swift | 2 + .../Domain/UseCases/UserSettingsUseCase.swift | 4 +- .../Cells/ManualSwitchCell.swift | 62 +++++++++++++++ .../Cells/SwitchCell.swift | 39 ---------- .../PushNotificationSettingsAssembly.swift | 2 +- .../PushNotificationSettingsReactor.swift | 75 +++++++++++++------ .../PushNotificationSettingsView.swift | 10 ++- ...shNotificationSettingsViewController.swift | 57 ++++++++++---- Sources/iOSSupport/Common/GlobalReactor.swift | 3 + .../iOSSupport/Localization/WCString.swift | 2 + Sources/iPhoneDriver/SceneDelegate.swift | 11 +++ .../WordCheckerIntergrationTests.xctestplan | 14 ++++ .../PushNotificationSettingsTests.swift | 36 +++++++-- .../UserSettingsReactorTests.swift | 4 +- .../WordCheckingReactorTests.swift | 2 +- 21 files changed, 250 insertions(+), 89 deletions(-) create mode 100644 Changelog/1.3.0.md create mode 100644 Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift delete mode 100644 Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift 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/QA.md b/QA.md index 52004fa0..489df685 100644 --- a/QA.md +++ b/QA.md @@ -1,5 +1,5 @@ #Version -1.2.1 +1.3.0 ##Common - Localization 적용 확인 @@ -29,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 3f123f58..e22a0b0e 100644 --- a/Resources/iOSSupport/Localization/en.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/en.lproj/Localizable.strings @@ -60,3 +60,5 @@ 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 3c7917a5..2d44d952 100644 --- a/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings +++ b/Resources/iOSSupport/Localization/ko.lproj/Localizable.strings @@ -60,3 +60,5 @@ please_check_your_network_connection = "네트워크 연결 상태를 확인해 daily_reminder = "매일 알림"; time = "시간"; notifications = "알림"; +allow_notifications_is_required = "알림 허용이 필요합니다."; +dailyReminderFooter = "설정한 시각에 매일 푸시 알림을 보냅니다."; 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 8ee3b333..5f70786e 100644 --- a/Sources/Domain/UseCases/UserSettingsUseCase.swift +++ b/Sources/Domain/UseCases/UserSettingsUseCase.swift @@ -91,8 +91,8 @@ public final class UserSettingsUseCase: UserSettingsUseCaseProtocol { public func setDailyReminder(at time: DateComponents) -> Single { let setDailyReminderSequence: Single = .create { observer in let content: UNMutableNotificationContent = .init().then { - // TODO: Content 설정 - $0.title = "Title" + $0.body = DomainString.daily_reminder + $0.sound = .default } let trigger: UNCalendarNotificationTrigger = .init(dateMatching: time, repeats: true) let notificationRequest: UNNotificationRequest = .init( diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift new file mode 100644 index 00000000..833075d5 --- /dev/null +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift @@ -0,0 +1,62 @@ +// +// ManualSwitchCell.swift +// PushNotificationSettings +// +// Created by Jaewon Yun on 11/29/23. +// Copyright © 2023 woin2ee. All rights reserved. +// + +import iOSSupport +import SnapKit +import UIKit + +final class ManualSwitchCell: RxBaseReusableCell { + + struct Model { + let title: String + let isOn: Bool + } + + let leadingLabel: UILabel = .init() + let trailingSwitch: UISwitch = .init().then { + $0.isUserInteractionEnabled = false + } + let wrappingButton: UIButton = .init() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.selectionStyle = .none + setUpSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setUpSubviews() { + self.contentView.addSubview(leadingLabel) + self.contentView.addSubview(trailingSwitch) + self.contentView.addSubview(wrappingButton) + + leadingLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + } + + trailingSwitch.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.centerY.equalToSuperview() + } + + wrappingButton.snp.makeConstraints { make in + make.edges.equalTo(trailingSwitch).inset(-4) + } + } + + func bind(model: Model) { + leadingLabel.text = model.title + trailingSwitch.setOn(model.isOn, animated: true) + } + +} diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift deleted file mode 100644 index 94ba573d..00000000 --- a/Sources/iOSScenes/PushNotificationSettings/Cells/SwitchCell.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// SwitchCell.swift -// PushNotificationSettings -// -// Created by Jaewon Yun on 11/29/23. -// Copyright © 2023 woin2ee. All rights reserved. -// - -import iOSSupport -import UIKit - -final class SwitchCell: RxBaseReusableCell { - - struct Model { - let title: String - let isOn: Bool - } - - let trailingSwitch: UISwitch = .init() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - 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 index 2a7c91d9..67b1a345 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsAssembly.swift @@ -17,7 +17,7 @@ public final class PushNotificationSettingsAssembly: Assembly { public func assemble(container: Container) { container.register(PushNotificationSettingsReactor.self) { resolver in - return .init(userSettingsUseCase: resolver.resolve()) + return .init(userSettingsUseCase: resolver.resolve(), globalAction: .shared) } container.register(PushNotificationSettingsViewController.self) { resolver in diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift index 3f139bd6..659ffe50 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsReactor.swift @@ -8,6 +8,7 @@ import Domain import Foundation +import iOSSupport import ReactorKit public final class PushNotificationSettingsReactor: Reactor { @@ -39,9 +40,19 @@ public final class PushNotificationSettingsReactor: Reactor { ) let userSettingsUseCase: UserSettingsUseCaseProtocol + let globalAction: GlobalAction - init(userSettingsUseCase: UserSettingsUseCaseProtocol) { + 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 { @@ -53,48 +64,39 @@ public final class PushNotificationSettingsReactor: Reactor { ? .just(.setReminderTime(latestTime!)) : .empty() - let enableDailyReminderSequence = userSettingsUseCase.getDailyReminder() + let setDailyReminderSequence = userSettingsUseCase.getDailyReminder() .asObservable() - .catch { _ in return .empty() } .map { _ in Mutation.enableDailyReminder } + .catch { _ in self.removeDailyReminder() } return .merge([ - enableDailyReminderSequence, + setDailyReminderSequence, setReminderTimeSequence, ]) case .tapDailyReminderSwitch: - /// 현재 `DailyReminder` 가 ON 상태이면 OFF 로, OFF 상태이면 ON 으로 변경하는 `Mutation` 을 반환합니다. - func toggleDailyReminder() -> Mutation { - if currentState.isOnDailyReminder { - return .disableDailyReminder - } else { - return .enableDailyReminder - } - } - return userSettingsUseCase.getNotificationAuthorizationStatus() .asObservable() .flatMap { status -> Observable in switch status { - case .notDetermined: + case .notDetermined: // 권한이 결정되어 있지 않은 경우(대부분 첫 알림 설정) return self.userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) .asObservable() .flatMap { hasAuthorization -> Observable in if hasAuthorization { - return .just(toggleDailyReminder()) + return self.toggleDailyReminder() } else { return .merge([ - .just(.disableDailyReminder), + self.removeDailyReminder(), .just(.showNeedAuthAlert), ]) } } - case .authorized: - return .just(toggleDailyReminder()) - default: + case .authorized: // 알림 권한 승인 + return self.toggleDailyReminder() + default: // 알림 권한 거부 return .merge([ - .just(.disableDailyReminder), + self.removeDailyReminder(), .just(.showMoveToAuthSettingAlert), ]) } @@ -102,7 +104,9 @@ public final class PushNotificationSettingsReactor: Reactor { case .changeReminderTime(let date): let hourAndMinute = Calendar.current.dateComponents([.hour, .minute], from: date) - return .just(.setReminderTime(hourAndMinute)) + return userSettingsUseCase.setDailyReminder(at: hourAndMinute) + .asObservable() + .map { Mutation.setReminderTime(hourAndMinute) } } } @@ -126,3 +130,32 @@ public final class PushNotificationSettingsReactor: Reactor { } } + +// 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 index 23e07b57..359b6a29 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsView.swift @@ -6,14 +6,22 @@ // 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(SwitchCell.self) + self.register(ManualSwitchCell.self) self.register(DatePickerCell.self) } diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift index 8eb86e60..87e125f2 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift @@ -16,19 +16,12 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, enum ItemIdentifier { case dailyReminderSwitch case dailyReminderTimeSetter - case dailyReminderFooter } lazy var rootTableView: PushNotificationSettingsView = .init(frame: .zero, style: .insetGrouped).then { $0.delegate = self } - let footerLabel: PaddingLabel = .init(padding: .init(top: 8, left: 20, bottom: 8, right: 20)).then { - $0.text = "Test footer." - $0.font = .preferredFont(forTextStyle: .footnote) - $0.textColor = .secondaryLabel - } - public weak var delegate: PushNotificationSettingsDelegate? lazy var dataSource: UITableViewDiffableDataSource = .init(tableView: rootTableView) { [weak self] tableView, indexPath, itemIdentifier in @@ -37,16 +30,18 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, switch itemIdentifier { case .dailyReminderSwitch: - let cell = tableView.dequeueReusableCell(SwitchCell.self, for: indexPath) + 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.trailingSwitch.rx.isOn - .map { $0 ? Reactor.Action.onDailyReminder : Reactor.Action.offDailyReminder } + 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 guard let date = Calendar.current.date(from: reactor.currentState.reminderTime) else { @@ -60,11 +55,11 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, .bind(to: reactor.action) .disposed(by: cell.disposeBag) return cell - case .dailyReminderFooter: - return nil } } + private var isViewAppeared: Bool = false + init() { super.init(nibName: nil, bundle: nil) initDataSource() @@ -86,6 +81,11 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, setupNavigationBar() } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + isViewAppeared = true + } + public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -109,7 +109,7 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, public func bind(reactor: PushNotificationSettingsReactor) { // Action self.rx.sentMessage(#selector(viewDidLoad)) - .map { _ in Reactor.Action.viewDidLoad } + .map { _ in Reactor.Action.reactorNeedsUpdate } .bind(to: reactor.action) .disposed(by: self.disposeBag) @@ -121,13 +121,24 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, .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() - owner.dataSource.apply(snapshot) + snapshot.reconfigureItems([.dailyReminderSwitch]) + applySnapshot() }) .disposed(by: self.disposeBag) @@ -145,6 +156,20 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, 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) } } @@ -156,11 +181,11 @@ extension PushNotificationSettingsViewController: UITableViewDelegate { } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - return footerLabel + return rootTableView.footerLabel } public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return footerLabel.intrinsicContentSize.height + return rootTableView.footerLabel.intrinsicContentSize.height } } 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 27656ba9..7d6ef8d0 100644 --- a/Sources/iOSSupport/Localization/WCString.swift +++ b/Sources/iOSSupport/Localization/WCString.swift @@ -69,5 +69,7 @@ public struct WCString { 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/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/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/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift index 10dd3ad1..4b3d9077 100644 --- a/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift +++ b/Tests/iOSScenesTests/PushNotificationSettingsTests/PushNotificationSettingsTests.swift @@ -11,7 +11,10 @@ final class PushNotificationSettingsTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - sut = .init(userSettingsUseCase: UserSettingsUseCaseFake(expectedAuthorizationStatus: .authorized)) + sut = .init( + userSettingsUseCase: UserSettingsUseCaseFake(expectedAuthorizationStatus: .authorized), + globalAction: .shared + ) } override func tearDownWithError() throws { @@ -33,7 +36,7 @@ final class PushNotificationSettingsTests: XCTestCase { // when authorized do { // Given - sut = .init(userSettingsUseCase: userSettingsUseCase) + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) // When sut.action.onNext(.reactorNeedsUpdate) @@ -50,7 +53,7 @@ final class PushNotificationSettingsTests: XCTestCase { _ = try userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) .toBlocking() .single() - sut = .init(userSettingsUseCase: userSettingsUseCase) + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) // When sut.action.onNext(.reactorNeedsUpdate) @@ -94,7 +97,7 @@ final class PushNotificationSettingsTests: XCTestCase { _ = try userSettingsUseCase.requestNotificationAuthorization(with: [.alert, .sound]) .toBlocking() .single() - sut = .init(userSettingsUseCase: userSettingsUseCase) + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) XCTAssertEqual(sut.currentState.isOnDailyReminder, false) XCTAssertNil(sut.currentState.moveToAuthSettingAlert) @@ -110,7 +113,7 @@ final class PushNotificationSettingsTests: XCTestCase { func test_turnOnOffDailyReminder_whenNotDetermined() throws { // Given let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .notDetermined) - sut = .init(userSettingsUseCase: userSettingsUseCase) + sut = .init(userSettingsUseCase: userSettingsUseCase, globalAction: .shared) XCTAssertEqual(sut.currentState.isOnDailyReminder, false) XCTAssertNil(sut.currentState.needAuthAlert) @@ -136,6 +139,29 @@ final class PushNotificationSettingsTests: XCTestCase { 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_() { diff --git a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift index 0ae6453e..2680cf5b 100644 --- a/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift +++ b/Tests/iOSScenesTests/UserSettingsTests/UserSettingsReactorTests.swift @@ -22,7 +22,7 @@ final class UserSettingsReactorTests: RxBaseTestCase { try super.setUpWithError() sut = .init( - userSettingsUseCase: UserSettingsUseCaseFake(), + userSettingsUseCase: UserSettingsUseCaseFake(expectedAuthorizationStatus: .authorized), googleDriveUseCase: GoogleDriveUseCaseFake(scheduler: testScheduler), globalAction: .shared ) @@ -108,7 +108,7 @@ final class UserSettingsReactorTests: RxBaseTestCase { func test_viewDidLoad() { // Given - let userSettingsUseCase: UserSettingsUseCaseFake = .init() + let userSettingsUseCase: UserSettingsUseCaseFake = .init(expectedAuthorizationStatus: .authorized) userSettingsUseCase.currentUserSettings = .init(translationSourceLocale: .german, translationTargetLocale: .italian) sut = .init( userSettingsUseCase: userSettingsUseCase, 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, From 514534c22d2a91a442b93cfa28cd130b84d84851 Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Thu, 18 Jan 2024 17:41:54 +0900 Subject: [PATCH 22/24] Fix indent of cell label text --- .../Cells/ManualSwitchCell.swift | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift index 833075d5..187aed38 100644 --- a/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/ManualSwitchCell.swift @@ -7,7 +7,6 @@ // import iOSSupport -import SnapKit import UIKit final class ManualSwitchCell: RxBaseReusableCell { @@ -18,44 +17,27 @@ final class ManualSwitchCell: RxBaseReusableCell { } let leadingLabel: UILabel = .init() - let trailingSwitch: UISwitch = .init().then { - $0.isUserInteractionEnabled = false - } + 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 - setUpSubviews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func setUpSubviews() { - self.contentView.addSubview(leadingLabel) - self.contentView.addSubview(trailingSwitch) - self.contentView.addSubview(wrappingButton) - - leadingLabel.snp.makeConstraints { make in - make.leading.equalToSuperview().inset(20) - make.centerY.equalToSuperview() - } - - trailingSwitch.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(20) - make.centerY.equalToSuperview() - } - - wrappingButton.snp.makeConstraints { make in - make.edges.equalTo(trailingSwitch).inset(-4) - } - } - func bind(model: Model) { - leadingLabel.text = model.title + var config: UIListContentConfiguration = .cell() + config.text = model.title + self.contentConfiguration = config trailingSwitch.setOn(model.isOn, animated: true) } From c723b581ad95454aaf65fa7a464a77f28e5488dc Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Thu, 18 Jan 2024 17:58:20 +0900 Subject: [PATCH 23/24] Set minute interval of DatePicker --- .../PushNotificationSettings/Cells/DatePickerCell.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift index 6834bea8..b1a3b964 100644 --- a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift @@ -16,7 +16,9 @@ final class DatePickerCell: RxBaseReusableCell { let date: Date } - let trailingDatePicker: UIDatePicker = .init() + let trailingDatePicker: UIDatePicker = .init().then { + $0.minuteInterval = 5 + } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) From a8d25c24a8d8c950c2f5c46e6d71f5d59425daae Mon Sep 17 00:00:00 2001 From: Jaewon Yun Date: Thu, 18 Jan 2024 18:20:53 +0900 Subject: [PATCH 24/24] Move code that set attributes of DatePicker to appropriate place --- .../PushNotificationSettings/Cells/DatePickerCell.swift | 4 +--- .../PushNotificationSettingsViewController.swift | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift index b1a3b964..6834bea8 100644 --- a/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift +++ b/Sources/iOSScenes/PushNotificationSettings/Cells/DatePickerCell.swift @@ -16,9 +16,7 @@ final class DatePickerCell: RxBaseReusableCell { let date: Date } - let trailingDatePicker: UIDatePicker = .init().then { - $0.minuteInterval = 5 - } + let trailingDatePicker: UIDatePicker = .init() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) diff --git a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift index 87e125f2..03dfc5cf 100644 --- a/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift +++ b/Sources/iOSScenes/PushNotificationSettings/PushNotificationSettingsViewController.swift @@ -43,6 +43,7 @@ public final class PushNotificationSettingsViewController: RxBaseViewController, 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.")