Skip to content

Commit

Permalink
Prevent adding duplicate word anywhere
Browse files Browse the repository at this point in the history
  • Loading branch information
woin2ee committed Feb 14, 2024
1 parent 505c02c commit 0afa465
Show file tree
Hide file tree
Showing 12 changed files with 299 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,5 @@ more_menu = "More menu";
memorize_words = "Memorize words";
next_word = "Next word";
previous_word = "Previous word";

duplicate_word = "Duplicate word.";
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,5 @@ more_menu = "메뉴 더보기";
memorize_words = "단어 암기";
next_word = "다음 단어";
previous_word = "이전 단어";

duplicate_word = "중복 단어입니다.";
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,9 @@ public protocol WordUseCaseProtocol {

func getCurrentUnmemorizedWord() -> Single<Word>

/// `word` 파라미터로 전달된 단어가 이미 저장되어 있는 단어인지 검사합니다.
///
/// - Returns: 반환된 Sequence 는 `ture` or `false` 값을 가진 next 이벤트만 방출됩니다. error 이벤트는 방출되지 않습니다.
func isWordDuplicated(_ word: String) -> Single<Bool>

}
18 changes: 18 additions & 0 deletions Sources/Domain/UseCases/WordUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ public final class WordUseCase: WordUseCaseProtocol {
}

public func updateWord(by uuid: UUID, to newWord: Word) -> RxSwift.Single<Void> {
guard let originWord = wordRepository.getWord(by: uuid) else {
return .error(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid)))
}

let allWords = self.wordRepository.getAllWords()
if (originWord.word != newWord.word) && allWords.contains(where: { $0.word.lowercased() == newWord.word.lowercased() }) {
return .error(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: newWord.word)))
}

return .create { single in
let updateTarget: Word = .init(
uuid: uuid,
Expand Down Expand Up @@ -180,4 +189,13 @@ public final class WordUseCase: WordUseCaseProtocol {
return .just(currentWord)
}

public func isWordDuplicated(_ word: String) -> Single<Bool> {
let allWords = self.wordRepository.getAllWords()
if allWords.contains(where: { $0.word.lowercased() == word.lowercased() }) {
return .just(true)
} else {
return .just(false)
}
}

}
42 changes: 39 additions & 3 deletions Sources/DomainTesting/WordUseCaseFake.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ import Utility

public final class WordUseCaseFake: WordUseCaseProtocol {

/// Fake 객체 구현을 위해 사용한 인메모리 단어 저장소
public var _wordList: [Domain.Word] = []

public var _unmemorizedWordList: UnmemorizedWordListRepositorySpy = .init()

public init() {}

public func addNewWord(_ word: Domain.Word) -> Single<Void> {
if _wordList.contains(where: { $0.word.lowercased() == word.word.lowercased() }) {
return .error(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: word.word)))
}

_wordList.append(word)
_unmemorizedWordList.addWord(word)
return .just(())
Expand Down Expand Up @@ -57,10 +62,33 @@ public final class WordUseCaseFake: WordUseCaseProtocol {
}

public func updateWord(by uuid: UUID, to newWord: Domain.Word) -> Single<Void> {
if let index = _wordList.firstIndex(where: { $0.uuid == uuid }) {
_wordList[index] = newWord
guard let index = _wordList.firstIndex(where: { $0.uuid == uuid }) else {
return .error(WordUseCaseError.retrieveFailed(reason: .uuidInvaild(uuid: uuid)))
}

if (newWord.word != _wordList[index].word) && _wordList.contains(where: { $0.word.lowercased() == newWord.word.lowercased() }) {
return .error(WordUseCaseError.saveFailed(reason: .duplicatedWord(word: newWord.word)))
}

let updateTarget: Word = .init(
uuid: uuid,
word: newWord.word,
memorizedState: newWord.memorizedState
)

if _unmemorizedWordList.contains(where: { $0.uuid == updateTarget.uuid }) {
switch updateTarget.memorizedState {
case .memorized:
_unmemorizedWordList.deleteWord(by: uuid)
case .memorizing:
_unmemorizedWordList.replaceWord(where: uuid, with: updateTarget)
}
} else if updateTarget.memorizedState == .memorizing {
_unmemorizedWordList.addWord(updateTarget)
}
_unmemorizedWordList.replaceWord(where: uuid, with: newWord)

_wordList[index] = updateTarget

return .just(())
}

Expand Down Expand Up @@ -97,4 +125,12 @@ public final class WordUseCaseFake: WordUseCaseProtocol {
return .just(currentWord)
}

public func isWordDuplicated(_ word: String) -> Single<Bool> {
if _wordList.contains(where: { $0.word.lowercased() == word.lowercased() }) {
return .just(true)
} else {
return .just(false)
}
}

}
26 changes: 23 additions & 3 deletions Sources/iOSScenes/WordAddition/WordAdditionViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo
$0.borderStyle = .roundedRect
}

let duplicatedWordAlertLabel: UILabel = .init().then {
$0.text = WCString.duplicate_word
$0.textColor = .systemRed
$0.adjustsFontForContentSizeCategory = true
$0.font = .preferredFont(forTextStyle: .footnote)
}

lazy var cancelBarButton: UIBarButtonItem = .init(systemItem: .cancel)

lazy var doneBarButton: UIBarButtonItem = .init(systemItem: .done).then {
Expand All @@ -55,11 +62,17 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo

func setupSubviews() {
self.view.addSubview(wordTextField)
self.view.addSubview(duplicatedWordAlertLabel)

wordTextField.snp.makeConstraints { make in
make.top.equalTo(self.view.safeAreaLayoutGuide).offset(10)
make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(20)
}

duplicatedWordAlertLabel.snp.makeConstraints { make in
make.leading.trailing.equalTo(self.view.safeAreaLayoutGuide).inset(22)
make.top.equalTo(wordTextField.snp.bottom).offset(10)
}
}

func setupNavigationBar() {
Expand All @@ -75,7 +88,7 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo
return
}
let input: WordAdditionViewModel.Input = .init(
wordText: wordTextField.rx.text.orEmpty.asDriver(),
wordText: wordTextField.rx.text.orEmpty.asDriver().distinctUntilChanged(),
saveAttempt: doneBarButton.rx.tap.asSignal(),
dismissAttempt: Signal.merge(
cancelBarButton.rx.tap.asSignal(),
Expand All @@ -89,8 +102,6 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo
.emit(with: self, onNext: { owner, _ in
owner.delegate?.viewControllerMustBeDismissed(owner)
}),
output.wordTextIsNotEmpty
.drive(doneBarButton.rx.isEnabled),
output.reconfirmDismiss
.emit(with: self, onNext: { owner, _ in
owner.presentDismissActionSheet {
Expand All @@ -101,6 +112,15 @@ final class WordAdditionViewController: RxBaseViewController, WordAdditionViewCo
.emit(with: self, onNext: { owner, _ in
owner.delegate?.viewControllerMustBeDismissed(owner)
}),
output.enteredWordIsDuplicated
.distinctUntilChanged()
.map { !$0 }
.drive(duplicatedWordAlertLabel.rx.isHidden),
Driver.zip([
output.wordTextIsNotEmpty,
output.enteredWordIsDuplicated,
]).map { $0[0] && !$0[1] }
.drive(doneBarButton.rx.isEnabled),
]
.forEach { $0.disposed(by: disposeBag) }
}
Expand Down
14 changes: 12 additions & 2 deletions Sources/iOSScenes/WordAddition/WordAdditionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class WordAdditionViewModel: ViewModelType {
}

func transform(input: Input) -> Output {
let initialWordText = ""
let initialWordText = "" // 새 단어를 추가할때는 초기 단어가 없으므로

let hasChanges = input.wordText.map { $0 != initialWordText }

Expand Down Expand Up @@ -53,11 +53,18 @@ final class WordAdditionViewModel: ViewModelType {
.mapToVoid()
.asSignalOnErrorJustComplete()

let enteredWordIsDuplicated = input.wordText
.flatMapLatest { word in
return self.wordUseCase.isWordDuplicated(word)
.asDriverOnErrorJustComplete()
}

return .init(
saveComplete: saveComplete,
wordTextIsNotEmpty: wordTextIsNotEmpty,
reconfirmDismiss: reconfirmDismiss,
dismissComplete: dismissComplete
dismissComplete: dismissComplete,
enteredWordIsDuplicated: enteredWordIsDuplicated
)
}

Expand All @@ -81,10 +88,13 @@ extension WordAdditionViewModel {

let wordTextIsNotEmpty: Driver<Bool>

/// Dismiss 해야하는지 재확인이 필요할때 next 이벤트가 방출됩니다.
let reconfirmDismiss: Signal<Void>

let dismissComplete: Signal<Void>

/// 입력되어 있는 단어가 중복된 단어인지 여부
var enteredWordIsDuplicated: Driver<Bool>
}

}
52 changes: 47 additions & 5 deletions Sources/iOSScenes/WordDetail/WordDetailReactor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,45 @@ final class WordDetailReactor: Reactor {
case viewDidLoad
case beginEditing
case doneEditing
case editWord(String)

/// 현재 입력된 단어
case enteredWord(String)

case changeMemorizedState(MemorizedState)
}

enum Mutation {
case updateWord(Word)
case markAsEditing

/// 현재 입력된 단어를 중복된 단어로 표시할지 결정하는 Mutation
case setDuplicated(Bool)

/// 현재 입력된 단어의 비어있음 상태를 결정하는 Mutation
case setEmpty(Bool)
}

struct State {

/// 현재 입력된 단어
var word: Word

/// 현재 화면에서 변경사항이 발생했는지 여부를 나타내는 값
var hasChanges: Bool

/// 입력되어 있는 단어가 중복된 단어인지 여부를 나타내는 값
var enteredWordIsDuplicated: Bool

/// 현재 입력된 단어가 비어있는지 여부를 나타내는 값
var enteredWordIsEmpty: Bool
}

var initialState: State = State(word: .empty, hasChanges: false)
var initialState: State = State(
word: .empty,
hasChanges: false,
enteredWordIsDuplicated: false,
enteredWordIsEmpty: false // Detail 화면에서는 항상 초기 단어가 있으므로
)

/// 현재 보여지고 있는 단어의 UUID 입니다.
let uuid: UUID
Expand Down Expand Up @@ -73,10 +97,24 @@ final class WordDetailReactor: Reactor {
.asObservable()
.flatMap { _ -> Observable<Mutation> in return .empty() }

case .editWord(let word):
self.currentState.word.word = word
case .enteredWord(let enteredWord):
let setDuplicatedMutation = wordUseCase.isWordDuplicated(enteredWord)
.asObservable()
.map { [weak self] isWordDuplicated in
if isWordDuplicated && (enteredWord != self?.originWord) { // 원래 단어와 달라야 중복이므로
return Mutation.setDuplicated(true)
} else {
return Mutation.setDuplicated(false)
}
}

self.currentState.word.word = enteredWord

return .just(.updateWord(self.currentState.word))
return .merge([
.just(.updateWord(self.currentState.word)),
setDuplicatedMutation,
.just(.setEmpty(enteredWord.isEmpty)),
])

case .changeMemorizedState(let state):
self.currentState.word.memorizedState = state
Expand All @@ -96,6 +134,10 @@ final class WordDetailReactor: Reactor {
state.word = word
case .markAsEditing:
state.hasChanges = true
case .setDuplicated(let enteredWordIsDuplicated):
state.enteredWordIsDuplicated = enteredWordIsDuplicated
case .setEmpty(let enteredWordIsEmpty):
state.enteredWordIsEmpty = enteredWordIsEmpty
}

return state
Expand Down
Loading

0 comments on commit 0afa465

Please sign in to comment.