diff --git a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift index 02ee9725..b6c36216 100644 --- a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift +++ b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/Dependency+SPM.swift @@ -5,6 +5,7 @@ public extension TargetDependency { } public extension TargetDependency.SPM { + static let Nuke = TargetDependency.external(name: "Nuke") static let Anim = TargetDependency.external(name: "Anim") static let CombineMiniature = TargetDependency.external(name: "CombineMiniature") static let AsyncNeiSwift = TargetDependency.external(name: "AsyncNeiSwift") diff --git a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift index a632773f..7da38bce 100644 --- a/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift +++ b/Plugin/DependencyPlugin/ProjectDescriptionHelpers/ModulePaths.swift @@ -26,6 +26,7 @@ public extension ModulePaths { public extension ModulePaths { enum Feature: String, MicroTargetPathConvertable { case ProposeMusicFeature + case DetailNoticeFeature case MyViolationListFeature case SplashFeature case ConfirmationDialogFeature diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift index 9c130a51..77456d1d 100644 --- a/Projects/App/Sources/Application/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -1,6 +1,7 @@ import AuthDomain import ConfirmationDialogFeature import Database +import DetailNoticeFeature import HomeFeature import IQKeyboardManagerSwift import JwtStore @@ -48,6 +49,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { HomeAssembly(), MyViolationListAssembly(), NoticeAssembly(), + DetailNoticeAssembly(), SelfStudyAssembly(), ConfirmationDialogAssembly(), MassageAssembly(), diff --git a/Projects/Domain/NoticeDomain/Interface/DataSource/RemoteNoticeDataSource.swift b/Projects/Domain/NoticeDomain/Interface/DataSource/RemoteNoticeDataSource.swift index 117dbf51..6f55e8f3 100644 --- a/Projects/Domain/NoticeDomain/Interface/DataSource/RemoteNoticeDataSource.swift +++ b/Projects/Domain/NoticeDomain/Interface/DataSource/RemoteNoticeDataSource.swift @@ -1,3 +1,5 @@ public protocol RemoteNoticeDataSource { func fetchNoticeList() async throws -> [NoticeEntity] + func fetchNotice(id: Int) async throws -> DetailNoticeEntity + func removeNotice(id: Int) async throws } diff --git a/Projects/Domain/NoticeDomain/Interface/Entity/DetailNoticeEntity.swift b/Projects/Domain/NoticeDomain/Interface/Entity/DetailNoticeEntity.swift new file mode 100644 index 00000000..31f352a9 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Interface/Entity/DetailNoticeEntity.swift @@ -0,0 +1,40 @@ +import BaseDomainInterface +import Foundation + +public struct DetailNoticeEntity: Equatable { + public let id: Int + public let title: String + public let content: String + public let role: UserRoleType + public let images: [NoticeImage] + public let createdDate: Date + public let modifiedDate: Date? + + public init( + id: Int, + title: String, + content: String, + role: UserRoleType, + images: [DetailNoticeEntity.NoticeImage], + createdDate: Date, + modifiedDate: Date? = nil + ) { + self.id = id + self.title = title + self.content = content + self.role = role + self.images = images + self.createdDate = createdDate + self.modifiedDate = modifiedDate + } + + public struct NoticeImage: Equatable { + public let id: Int + public let imageURL: String + + public init(id: Int, imageURL: String) { + self.id = id + self.imageURL = imageURL + } + } +} diff --git a/Projects/Domain/NoticeDomain/Interface/Model/DetailNoticeModel.swift b/Projects/Domain/NoticeDomain/Interface/Model/DetailNoticeModel.swift new file mode 100644 index 00000000..2774090c --- /dev/null +++ b/Projects/Domain/NoticeDomain/Interface/Model/DetailNoticeModel.swift @@ -0,0 +1 @@ +public typealias DetailNoticeModel = DetailNoticeEntity diff --git a/Projects/Domain/NoticeDomain/Interface/Repository/NoticeRepository.swift b/Projects/Domain/NoticeDomain/Interface/Repository/NoticeRepository.swift index bcdd8375..cc68009b 100644 --- a/Projects/Domain/NoticeDomain/Interface/Repository/NoticeRepository.swift +++ b/Projects/Domain/NoticeDomain/Interface/Repository/NoticeRepository.swift @@ -1,3 +1,5 @@ public protocol NoticeRepository { func fetchNoticeList() async throws -> [NoticeEntity] + func fetchNotice(id: Int) async throws -> DetailNoticeEntity + func removeNotice(id: Int) async throws } diff --git a/Projects/Domain/NoticeDomain/Interface/UseCase/FetchNoticeUseCase.swift b/Projects/Domain/NoticeDomain/Interface/UseCase/FetchNoticeUseCase.swift new file mode 100644 index 00000000..d6f73516 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Interface/UseCase/FetchNoticeUseCase.swift @@ -0,0 +1,3 @@ +public protocol FetchNoticeUseCase { + func callAsFunction(id: Int) async throws -> DetailNoticeModel +} diff --git a/Projects/Domain/NoticeDomain/Interface/UseCase/RemoveNoticeUseCase.swift b/Projects/Domain/NoticeDomain/Interface/UseCase/RemoveNoticeUseCase.swift new file mode 100644 index 00000000..c4b3e867 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Interface/UseCase/RemoveNoticeUseCase.swift @@ -0,0 +1,3 @@ +public protocol RemoveNoticeUseCase { + func callAsFunction(id: Int) async throws +} diff --git a/Projects/Domain/NoticeDomain/Sources/Assembly/NoticeDomainAssembly.swift b/Projects/Domain/NoticeDomain/Sources/Assembly/NoticeDomainAssembly.swift index 101afd63..4ceffdaf 100644 --- a/Projects/Domain/NoticeDomain/Sources/Assembly/NoticeDomainAssembly.swift +++ b/Projects/Domain/NoticeDomain/Sources/Assembly/NoticeDomainAssembly.swift @@ -18,5 +18,13 @@ public final class NoticeDomainAssembly: Assembly { container.register(FetchNoticeListUseCase.self) { resolver in FetchNoticeListUseCaseImpl(noticeRepository: resolver.resolve(NoticeRepository.self)!) } + + container.register(FetchNoticeUseCase.self) { resolver in + FetchNoticeUseCaseImpl(noticeRepository: resolver.resolve(NoticeRepository.self)!) + } + + container.register(RemoveNoticeUseCase.self) { resolver in + RemoveNoticeUseCaseImpl(noticeRepository: resolver.resolve(NoticeRepository.self)!) + } } } diff --git a/Projects/Domain/NoticeDomain/Sources/DTO/Response/FetchNoticeResponseDTO.swift b/Projects/Domain/NoticeDomain/Sources/DTO/Response/FetchNoticeResponseDTO.swift new file mode 100644 index 00000000..c4a2d396 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Sources/DTO/Response/FetchNoticeResponseDTO.swift @@ -0,0 +1,44 @@ +import BaseDomainInterface +import DateUtility +import Foundation +import NoticeDomainInterface + +struct FetchNoticeResponseDTO: Decodable { + let id: Int + let title: String + let content: String + let role: UserRoleType + let boardImage: [BoardImage] + let createdDate: String + let modifiedDate: String? + + struct BoardImage: Decodable { + let id: Int + let url: String + + init(id: Int, url: String) { + self.id = id + self.url = url + } + } +} + +extension FetchNoticeResponseDTO { + func toDomain() -> DetailNoticeEntity { + DetailNoticeEntity( + id: id, + title: title, + content: content, + role: role, + images: boardImage.map { $0.toDomain() }, + createdDate: createdDate.toDateWithCustomFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"), + modifiedDate: modifiedDate?.toDateWithCustomFormat("yyyy-MM-dd'T'HH:mm:ss.SSS") + ) + } +} + +extension FetchNoticeResponseDTO.BoardImage { + func toDomain() -> DetailNoticeEntity.NoticeImage { + .init(id: id, imageURL: url) + } +} diff --git a/Projects/Domain/NoticeDomain/Sources/DataSource/NoticeEndpoint.swift b/Projects/Domain/NoticeDomain/Sources/DataSource/NoticeEndpoint.swift index 7b26893b..ac5dfc10 100644 --- a/Projects/Domain/NoticeDomain/Sources/DataSource/NoticeEndpoint.swift +++ b/Projects/Domain/NoticeDomain/Sources/DataSource/NoticeEndpoint.swift @@ -3,6 +3,8 @@ import NetworkingInterface enum NoticeEndpoint { case fetchNoticeList + case fetchNotice(id: Int) + case removeNotice(id: Int) } extension NoticeEndpoint: DotoriEndpoint { @@ -14,6 +16,12 @@ extension NoticeEndpoint: DotoriEndpoint { switch self { case .fetchNoticeList: return .get("") + + case let .fetchNotice(id): + return .get("/\(id)") + + case let .removeNotice(id): + return .delete("/\(id)") } } diff --git a/Projects/Domain/NoticeDomain/Sources/DataSource/RemoteNoticeDataSourceImpl.swift b/Projects/Domain/NoticeDomain/Sources/DataSource/RemoteNoticeDataSourceImpl.swift index 50c1c226..2b824561 100644 --- a/Projects/Domain/NoticeDomain/Sources/DataSource/RemoteNoticeDataSourceImpl.swift +++ b/Projects/Domain/NoticeDomain/Sources/DataSource/RemoteNoticeDataSourceImpl.swift @@ -15,4 +15,16 @@ final class RemoteNoticeDataSourceImpl: RemoteNoticeDataSource { ) .toDomain() } + + func fetchNotice(id: Int) async throws -> DetailNoticeEntity { + try await networking.request( + NoticeEndpoint.fetchNotice(id: id), + dto: FetchNoticeResponseDTO.self + ) + .toDomain() + } + + func removeNotice(id: Int) async throws { + try await networking.request(NoticeEndpoint.removeNotice(id: id)) + } } diff --git a/Projects/Domain/NoticeDomain/Sources/Repository/NoticeRepositoryImpl.swift b/Projects/Domain/NoticeDomain/Sources/Repository/NoticeRepositoryImpl.swift index 75e4b7e6..d36785d9 100644 --- a/Projects/Domain/NoticeDomain/Sources/Repository/NoticeRepositoryImpl.swift +++ b/Projects/Domain/NoticeDomain/Sources/Repository/NoticeRepositoryImpl.swift @@ -10,4 +10,12 @@ final class NoticeRepositoryImpl: NoticeRepository { func fetchNoticeList() async throws -> [NoticeEntity] { try await remoteNoticeDataSource.fetchNoticeList() } + + func fetchNotice(id: Int) async throws -> DetailNoticeEntity { + try await remoteNoticeDataSource.fetchNotice(id: id) + } + + func removeNotice(id: Int) async throws { + try await remoteNoticeDataSource.removeNotice(id: id) + } } diff --git a/Projects/Domain/NoticeDomain/Sources/UseCase/FetchNoticeUseCaseImpl.swift b/Projects/Domain/NoticeDomain/Sources/UseCase/FetchNoticeUseCaseImpl.swift new file mode 100644 index 00000000..1788707e --- /dev/null +++ b/Projects/Domain/NoticeDomain/Sources/UseCase/FetchNoticeUseCaseImpl.swift @@ -0,0 +1,13 @@ +import NoticeDomainInterface + +struct FetchNoticeUseCaseImpl: FetchNoticeUseCase { + private let noticeRepository: any NoticeRepository + + init(noticeRepository: any NoticeRepository) { + self.noticeRepository = noticeRepository + } + + func callAsFunction(id: Int) async throws -> DetailNoticeModel { + try await noticeRepository.fetchNotice(id: id) + } +} diff --git a/Projects/Domain/NoticeDomain/Sources/UseCase/RemoveNoticeUseCaseImpl.swift b/Projects/Domain/NoticeDomain/Sources/UseCase/RemoveNoticeUseCaseImpl.swift new file mode 100644 index 00000000..489cdce4 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Sources/UseCase/RemoveNoticeUseCaseImpl.swift @@ -0,0 +1,13 @@ +import NoticeDomainInterface + +struct RemoveNoticeUseCaseImpl: RemoveNoticeUseCase { + private let noticeRepository: any NoticeRepository + + init(noticeRepository: any NoticeRepository) { + self.noticeRepository = noticeRepository + } + + func callAsFunction(id: Int) async throws { + try await noticeRepository.removeNotice(id: id) + } +} diff --git a/Projects/Domain/NoticeDomain/Testing/DataSource/RemoteNoticeDataSourceSpy.swift b/Projects/Domain/NoticeDomain/Testing/DataSource/RemoteNoticeDataSourceSpy.swift index 3a7ce198..db3e91a4 100644 --- a/Projects/Domain/NoticeDomain/Testing/DataSource/RemoteNoticeDataSourceSpy.swift +++ b/Projects/Domain/NoticeDomain/Testing/DataSource/RemoteNoticeDataSourceSpy.swift @@ -1,3 +1,4 @@ +import BaseDomainInterface import NoticeDomainInterface final class RemoteNoticeDataSourceSpy: RemoteNoticeDataSource { @@ -7,4 +8,27 @@ final class RemoteNoticeDataSourceSpy: RemoteNoticeDataSource { fetchNoticeListCallCount += 1 return fetchNoticeListReturn } + + var fetchNoticeCallCount = 0 + var fetchNoticeHandler: (Int) async throws -> DetailNoticeEntity = { _ in + .init( + id: 1, + title: "", + content: "", + role: .member, + images: [], + createdDate: .init() + ) + } + func fetchNotice(id: Int) async throws -> DetailNoticeEntity { + fetchNoticeCallCount += 1 + return try await fetchNoticeHandler(id) + } + + var removeNoticeCallCount = 0 + var removeNoticeHandler: (Int) async throws -> Void = { _ in } + func removeNotice(id: Int) async throws { + removeNoticeCallCount += 1 + try await removeNoticeHandler(id) + } } diff --git a/Projects/Domain/NoticeDomain/Testing/Repository/NoticeRepositorySpy.swift b/Projects/Domain/NoticeDomain/Testing/Repository/NoticeRepositorySpy.swift index aab959ee..8ca120d5 100644 --- a/Projects/Domain/NoticeDomain/Testing/Repository/NoticeRepositorySpy.swift +++ b/Projects/Domain/NoticeDomain/Testing/Repository/NoticeRepositorySpy.swift @@ -7,4 +7,27 @@ final class NoticeRepositorySpy: NoticeRepository { fetchNoticeListCallCount += 1 return fetchNoticeListReturn } + + var fetchNoticeCallCount = 0 + var fetchNoticeHandler: (Int) async throws -> DetailNoticeEntity = { _ in + .init( + id: 1, + title: "", + content: "", + role: .member, + images: [], + createdDate: .init() + ) + } + func fetchNotice(id: Int) async throws -> DetailNoticeEntity { + fetchNoticeCallCount += 1 + return try await fetchNoticeHandler(id) + } + + var removeNoticeCallCount = 0 + var removeNoticeHandler: (Int) async throws -> Void = { _ in } + func removeNotice(id: Int) async throws { + removeNoticeCallCount += 1 + try await removeNoticeHandler(id) + } } diff --git a/Projects/Domain/NoticeDomain/Testing/UseCase/FetchNoticeUseCaseSpy.swift b/Projects/Domain/NoticeDomain/Testing/UseCase/FetchNoticeUseCaseSpy.swift new file mode 100644 index 00000000..219ee923 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Testing/UseCase/FetchNoticeUseCaseSpy.swift @@ -0,0 +1,19 @@ +import NoticeDomainInterface + +final class FetchNoticeUseCaseSpy: FetchNoticeUseCase { + var fetchNoticeCallCount = 0 + var fetchNoticeHandler: (Int) async throws -> DetailNoticeModel = { _ in + .init( + id: 1, + title: "", + content: "", + role: .member, + images: [], + createdDate: .init() + ) + } + func callAsFunction(id: Int) async throws -> DetailNoticeModel { + fetchNoticeCallCount += 1 + return try await fetchNoticeHandler(id) + } +} diff --git a/Projects/Domain/NoticeDomain/Testing/UseCase/RemoveNoticeUseCaseSpy.swift b/Projects/Domain/NoticeDomain/Testing/UseCase/RemoveNoticeUseCaseSpy.swift new file mode 100644 index 00000000..29ea9797 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Testing/UseCase/RemoveNoticeUseCaseSpy.swift @@ -0,0 +1,10 @@ +import NoticeDomainInterface + +final class RemoveNoticeUseCaseSpy: RemoveNoticeUseCase { + var removeNoticeCallCount = 0 + var removeNoticeHandler: (Int) async throws -> Void = { _ in } + func callAsFunction(id: Int) async throws { + removeNoticeCallCount += 1 + try await removeNoticeHandler(id) + } +} diff --git a/Projects/Domain/NoticeDomain/Tests/FetchNoticeUseCaseTests.swift b/Projects/Domain/NoticeDomain/Tests/FetchNoticeUseCaseTests.swift new file mode 100644 index 00000000..b91200bc --- /dev/null +++ b/Projects/Domain/NoticeDomain/Tests/FetchNoticeUseCaseTests.swift @@ -0,0 +1,39 @@ +import NoticeDomainInterface +import XCTest +@testable import NoticeDomain +@testable import NoticeDomainTesting + +final class FetchNoticeUseCaseTests: XCTestCase { + var noticeRepository: NoticeRepositorySpy! + var sut: FetchNoticeUseCaseImpl! + + override func setUp() { + noticeRepository = .init() + sut = .init(noticeRepository: noticeRepository) + } + + override func tearDown() { + noticeRepository = nil + sut = nil + } + + func testCallAsFunction() async throws { + let date = Date() + XCTAssertEqual(noticeRepository.fetchNoticeCallCount, 0) + let expected = DetailNoticeEntity( + id: 1, + title: "b", + content: "aj", + role: .councillor, + images: [], + createdDate: .init(), + modifiedDate: nil + ) + noticeRepository.fetchNoticeHandler = { _ in expected } + + let actual = try await sut(id: 1) + + XCTAssertEqual(noticeRepository.fetchNoticeCallCount, 1) + XCTAssertEqual(actual, expected) + } +} diff --git a/Projects/Domain/NoticeDomain/Tests/NoticeRepositoryTests.swift b/Projects/Domain/NoticeDomain/Tests/NoticeRepositoryTests.swift index 08e808ff..aabe7128 100644 --- a/Projects/Domain/NoticeDomain/Tests/NoticeRepositoryTests.swift +++ b/Projects/Domain/NoticeDomain/Tests/NoticeRepositoryTests.swift @@ -21,7 +21,7 @@ final class NoticeRepositoryTests: XCTestCase { func testFetchNoticeList() async throws { let date = Date() XCTAssertEqual(remoteNoticeDataSource.fetchNoticeListCallCount, 0) - let expected = [NoticeModel(id: 1, title: "title2", content: "contents", roles: .member, createdTime: date)] + let expected = [NoticeEntity(id: 1, title: "title2", content: "contents", roles: .member, createdTime: date)] remoteNoticeDataSource.fetchNoticeListReturn = expected let actual = try await sut.fetchNoticeList() @@ -29,4 +29,24 @@ final class NoticeRepositoryTests: XCTestCase { XCTAssertEqual(remoteNoticeDataSource.fetchNoticeListCallCount, 1) XCTAssertEqual(actual, expected) } + + func testFetchNotice() async throws { + let date = Date() + XCTAssertEqual(remoteNoticeDataSource.fetchNoticeCallCount, 0) + let expected = DetailNoticeEntity(id: 1, title: "title2", content: "contents", role: .member, images: [], createdDate: date) + remoteNoticeDataSource.fetchNoticeHandler = { _ in expected } + + let actual = try await sut.fetchNotice(id: 1) + + XCTAssertEqual(remoteNoticeDataSource.fetchNoticeCallCount, 1) + XCTAssertEqual(actual, expected) + } + + func testRemoveNotice() async throws { + XCTAssertEqual(remoteNoticeDataSource.removeNoticeCallCount, 0) + + try await sut.removeNotice(id: 1) + + XCTAssertEqual(remoteNoticeDataSource.removeNoticeCallCount, 1) + } } diff --git a/Projects/Domain/NoticeDomain/Tests/RemoveNoticeUseCaseTests.swift b/Projects/Domain/NoticeDomain/Tests/RemoveNoticeUseCaseTests.swift new file mode 100644 index 00000000..204f0170 --- /dev/null +++ b/Projects/Domain/NoticeDomain/Tests/RemoveNoticeUseCaseTests.swift @@ -0,0 +1,27 @@ +import NoticeDomainInterface +import XCTest +@testable import NoticeDomain +@testable import NoticeDomainTesting + +final class RemoveNoticeUseCaseTests: XCTestCase { + var noticeRepository: NoticeRepositorySpy! + var sut: RemoveNoticeUseCaseImpl! + + override func setUp() { + noticeRepository = .init() + sut = .init(noticeRepository: noticeRepository) + } + + override func tearDown() { + noticeRepository = nil + sut = nil + } + + func testCallAsFunction() async throws { + XCTAssertEqual(noticeRepository.removeNoticeCallCount, 0) + + try await sut(id: 1) + + XCTAssertEqual(noticeRepository.removeNoticeCallCount, 1) + } +} diff --git a/Projects/Feature/BaseFeature/Project.swift b/Projects/Feature/BaseFeature/Project.swift index 3c64319d..0a58efce 100644 --- a/Projects/Feature/BaseFeature/Project.swift +++ b/Projects/Feature/BaseFeature/Project.swift @@ -9,6 +9,7 @@ let project = Project.module( .SPM.Moordinator, .SPM.Store, .SPM.IQKeyboardManagerSwift, + .SPM.Nuke, .userInterface(target: .DesignSystem), .userInterface(target: .Localization), .shared(target: .GlobalThirdPartyLibrary), diff --git a/Projects/Feature/BaseFeature/Sources/DotoriRoutePath.swift b/Projects/Feature/BaseFeature/Sources/DotoriRoutePath.swift index 50f82ff2..e68984c4 100644 --- a/Projects/Feature/BaseFeature/Sources/DotoriRoutePath.swift +++ b/Projects/Feature/BaseFeature/Sources/DotoriRoutePath.swift @@ -11,7 +11,7 @@ public enum DotoriRoutePath: RoutePath { ) case confirmationDialog( title: String = "", - message: String = "", + description: String = "", confirmAction: () async -> Void ) case dismiss @@ -32,7 +32,7 @@ public enum DotoriRoutePath: RoutePath { // MARK: Notice case notice - case noticeDetail(noticeID: Int) + case detailNotice(noticeID: Int) // MARK: SelfStudy case selfStudy diff --git a/Projects/Feature/DetailNoticeFeature/Demo/Resources/LaunchScreen.storyboard b/Projects/Feature/DetailNoticeFeature/Demo/Resources/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Demo/Resources/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Feature/DetailNoticeFeature/Demo/Sources/AppDelegate.swift b/Projects/Feature/DetailNoticeFeature/Demo/Sources/AppDelegate.swift new file mode 100644 index 00000000..5dd91f23 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Demo/Sources/AppDelegate.swift @@ -0,0 +1,34 @@ +import Inject +import UIKit +@testable import DetailNoticeFeature +@testable import NoticeDomainTesting +@testable import UserDomainTesting + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + window = UIWindow(frame: UIScreen.main.bounds) + let fetchNoticeUseCase = FetchNoticeUseCaseSpy() + let removeNoticeUseCase = RemoveNoticeUseCaseSpy() + let loadCurrentUserRoleUseCase = LoadCurrentUserRoleUseCaseSpy() + loadCurrentUserRoleUseCase.loadCurrentUserRoleReturn = .developer + let store = DetailNoticeStore( + noticeID: 1, + fetchNoticeUseCase: fetchNoticeUseCase, + removeNoticeUseCase: removeNoticeUseCase, + loadCurrentUserRoleUseCase: loadCurrentUserRoleUseCase + ) + let viewController = Inject.ViewControllerHost( + UINavigationController(rootViewController: DetailNoticeViewController(store: store)) + ) + window?.rootViewController = viewController + window?.makeKeyAndVisible() + + return true + } +} diff --git a/Projects/Feature/DetailNoticeFeature/Project.swift b/Projects/Feature/DetailNoticeFeature/Project.swift new file mode 100644 index 00000000..e84fe776 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Project.swift @@ -0,0 +1,24 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.module( + name: ModulePaths.Feature.DetailNoticeFeature.rawValue, + targets: [ + .implements(module: .feature(.DetailNoticeFeature), dependencies: [ + .feature(target: .BaseFeature), + .domain(target: .NoticeDomain, type: .interface), + .domain(target: .UserDomain, type: .interface) + ]), + .tests(module: .feature(.DetailNoticeFeature), dependencies: [ + .feature(target: .DetailNoticeFeature), + .domain(target: .NoticeDomain, type: .testing), + .domain(target: .UserDomain, type: .testing) + ]), + .demo(module: .feature(.DetailNoticeFeature), dependencies: [ + .feature(target: .DetailNoticeFeature), + .domain(target: .NoticeDomain, type: .testing), + .domain(target: .UserDomain, type: .testing) + ]) + ] +) diff --git a/Projects/Feature/DetailNoticeFeature/Sources/Assembly/DetailNoticeAssembly.swift b/Projects/Feature/DetailNoticeFeature/Sources/Assembly/DetailNoticeAssembly.swift new file mode 100644 index 00000000..083b66ee --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Sources/Assembly/DetailNoticeAssembly.swift @@ -0,0 +1,16 @@ +import NoticeDomainInterface +import Swinject +import UserDomainInterface + +public final class DetailNoticeAssembly: Assembly { + public init() {} + public func assemble(container: Container) { + container.register(DetailNoticeFactory.self) { resolver in + DetailNoticeFactoryImpl( + fetchNoticeUseCase: resolver.resolve(FetchNoticeUseCase.self)!, + removeNoticeUseCase: resolver.resolve(RemoveNoticeUseCase.self)!, + loadCurrentUserRoleUseCase: resolver.resolve(LoadCurrentUserRoleUseCase.self)! + ) + } + } +} diff --git a/Projects/Feature/DetailNoticeFeature/Sources/Factory/DetailNoticeFactory.swift b/Projects/Feature/DetailNoticeFeature/Sources/Factory/DetailNoticeFactory.swift new file mode 100644 index 00000000..02b16373 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Sources/Factory/DetailNoticeFactory.swift @@ -0,0 +1,6 @@ +import BaseFeature +import UIKit + +public protocol DetailNoticeFactory { + func makeViewController(noticeID: Int) -> any StoredViewControllable +} diff --git a/Projects/Feature/DetailNoticeFeature/Sources/Factory/DetailNoticeFactoryImpl.swift b/Projects/Feature/DetailNoticeFeature/Sources/Factory/DetailNoticeFactoryImpl.swift new file mode 100644 index 00000000..8f872d30 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Sources/Factory/DetailNoticeFactoryImpl.swift @@ -0,0 +1,30 @@ +import BaseFeature +import NoticeDomainInterface +import UIKit +import UserDomainInterface + +struct DetailNoticeFactoryImpl: DetailNoticeFactory { + private let fetchNoticeUseCase: any FetchNoticeUseCase + private let removeNoticeUseCase: any RemoveNoticeUseCase + private let loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase + + init( + fetchNoticeUseCase: any FetchNoticeUseCase, + removeNoticeUseCase: any RemoveNoticeUseCase, + loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase + ) { + self.fetchNoticeUseCase = fetchNoticeUseCase + self.removeNoticeUseCase = removeNoticeUseCase + self.loadCurrentUserRoleUseCase = loadCurrentUserRoleUseCase + } + + func makeViewController(noticeID: Int) -> any StoredViewControllable { + let store = DetailNoticeStore( + noticeID: noticeID, + fetchNoticeUseCase: fetchNoticeUseCase, + removeNoticeUseCase: removeNoticeUseCase, + loadCurrentUserRoleUseCase: loadCurrentUserRoleUseCase + ) + return DetailNoticeViewController(store: store) + } +} diff --git a/Projects/Feature/DetailNoticeFeature/Sources/Scene/DetailNoticeStore.swift b/Projects/Feature/DetailNoticeFeature/Sources/Scene/DetailNoticeStore.swift new file mode 100644 index 00000000..9ea3aae4 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Sources/Scene/DetailNoticeStore.swift @@ -0,0 +1,140 @@ +import BaseDomainInterface +import BaseFeature +import Combine +import DesignSystem +import Localization +import Moordinator +import NoticeDomainInterface +import Store +import UIKit +import UserDomainInterface + +final class DetailNoticeStore: BaseStore { + var route: PassthroughSubject = .init() + var subscription: Set = .init() + var initialState: State + var stateSubject: CurrentValueSubject + private let noticeID: Int + private let fetchNoticeUseCase: any FetchNoticeUseCase + private let removeNoticeUseCase: any RemoveNoticeUseCase + private let loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase + + init( + noticeID: Int, + fetchNoticeUseCase: any FetchNoticeUseCase, + removeNoticeUseCase: any RemoveNoticeUseCase, + loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase + ) { + self.initialState = .init() + self.stateSubject = .init(initialState) + self.noticeID = noticeID + self.fetchNoticeUseCase = fetchNoticeUseCase + self.removeNoticeUseCase = removeNoticeUseCase + self.loadCurrentUserRoleUseCase = loadCurrentUserRoleUseCase + } + + struct State { + var detailNotice: DetailNoticeModel? + var isLoading = false + var currentUserRole = UserRoleType.member + } + enum Action { + case viewDidLoad + case viewWillAppear + case removeBarButtonDidTap + } + enum Mutation { + case updateDetailNotice(DetailNoticeModel) + case updateIsLoading(Bool) + case updateCurrentUserRole(UserRoleType) + } +} + +extension DetailNoticeStore { + func mutate(state: State, action: Action) -> SideEffect { + switch action { + case .viewDidLoad: + return self.viewDidLoad() + + case .viewWillAppear: + return self.viewWillAppear() + + case .removeBarButtonDidTap: + return self.removeBarButtonDidTap() + } + return .none + } +} + +extension DetailNoticeStore { + func reduce(state: State, mutate: Mutation) -> State { + var newState = state + switch mutate { + case let .updateDetailNotice(detailNotice): + newState.detailNotice = detailNotice + + case let .updateIsLoading(isLoading): + newState.isLoading = isLoading + + case let .updateCurrentUserRole(userRole): + newState.currentUserRole = userRole + } + return newState + } +} + +// MARK: - Mutate +private extension DetailNoticeStore { + func viewDidLoad() -> SideEffect { + return SideEffect + .just(try? loadCurrentUserRoleUseCase()) + .replaceNil(with: .member) + .setFailureType(to: Never.self) + .map(Mutation.updateCurrentUserRole) + .eraseToSideEffect() + } + + func viewWillAppear() -> SideEffect { + let detailNoticeEffect = SideEffect + .tryAsync { [noticeID, fetchNoticeUseCase] in + try await fetchNoticeUseCase(id: noticeID) + } + .catchToNever() + .map(Mutation.updateDetailNotice) + return self.makeLoadingSideEffect(detailNoticeEffect) + } + + func removeBarButtonDidTap() -> SideEffect { + let confirmRoutePath = DotoriRoutePath.confirmationDialog( + title: L10n.Notice.removeNoticeDialogTitle, + description: L10n.Notice.removeNoticeDialogDescription + ) { [removeNoticeUseCase, noticeID, route] in + do { + try await removeNoticeUseCase(id: noticeID) + route.send(DotoriRoutePath.dismiss) + route.send(DotoriRoutePath.pop) + await DotoriToast.makeToast(text: L10n.Notice.completeToRemoveNotice, style: .success) + } catch { + await DotoriToast.makeToast(text: error.localizedDescription, style: .error) + } + } + route.send(confirmRoutePath) + return .none + } +} + +// MARK: - Reusable +private extension DetailNoticeStore { + func makeLoadingSideEffect( + _ publisher: SideEffect + ) -> SideEffect { + let startLoadingPublisher = SideEffect + .just(Mutation.updateIsLoading(true)) + let endLoadingPublisher = SideEffect + .just(Mutation.updateIsLoading(false)) + return startLoadingPublisher + .append(publisher) + .append(endLoadingPublisher) + .eraseToSideEffect() + } +} diff --git a/Projects/Feature/DetailNoticeFeature/Sources/Scene/DetailNoticeViewController.swift b/Projects/Feature/DetailNoticeFeature/Sources/Scene/DetailNoticeViewController.swift new file mode 100644 index 00000000..c0f147e1 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Sources/Scene/DetailNoticeViewController.swift @@ -0,0 +1,160 @@ +import BaseDomainInterface +import BaseFeature +import ConcurrencyUtil +import DateUtility +import DesignSystem +import Localization +import MSGLayout +import NoticeDomainInterface +import Nuke +import UIKit +import UIKitUtil + +final class DetailNoticeViewController: BaseStoredViewController { + private enum Metric { + static let horizontalPadding: CGFloat = 20 + } + private lazy var rewriteBarButton = UIBarButtonItem( + image: .Dotori.pen.tintColor(color: .dotori(.neutral(.n10))) + ) + private lazy var removeBarButton = UIBarButtonItem( + image: .Dotori.trashcan.tintColor(color: .dotori(.sub(.red))) + ) + private let signatureColorView = UIView() + .set(\.cornerRadius, 6) + .set(\.clipsToBounds, true) + private let authorLabel = DotoriLabel(font: .h4) + private let dateLabel = DotoriLabel(textColor: .neutral(.n20), font: .body2) + private let titleLabel = DotoriLabel(textColor: .neutral(.n10), font: .h3) + .set(\.numberOfLines, 0) + private lazy var headerStackView = VStackView(spacing: 8) { + HStackView(spacing: 8) { + signatureColorView + .width(12) + .height(12) + + authorLabel + + SpacerView() + + dateLabel + } + .alignment(.center) + + titleLabel + } + private let contentLabel = DotoriLabel(font: .body1) + .set(\.numberOfLines, 0) + .set(\.clipsToBounds, true) + private lazy var contentStackView = VStackView(spacing: 16) { + contentLabel + } + .margin(.axes(vertical: 12, horizontal: 16)) + .set(\.backgroundColor, .dotori(.background(.bg))) + .set(\.cornerRadius, 8) + + override func setLayout() { + MSGLayout.stackedScrollLayout(self.view) { + VStackView(spacing: 24) { + headerStackView + + contentStackView + } + .margin(.axes(vertical: 16, horizontal: 20)) + } + } + + override func configureViewController() { + self.view.backgroundColor = .dotori(.background(.card)) + } + + override func bindAction() { + viewDidLoadPublisher + .map { Store.Action.viewDidLoad } + .sink(receiveValue: store.send(_:)) + .store(in: &subscription) + + viewWillAppearPublisher + .map { Store.Action.viewWillAppear } + .sink(receiveValue: store.send(_:)) + .store(in: &subscription) + + removeBarButton.tapPublisher + .map { Store.Action.removeBarButtonDidTap } + .sink(receiveValue: store.send(_:)) + .store(in: &subscription) + } + + override func bindState() { + let sharedState = store.state.share() + .receive(on: DispatchQueue.main) + + sharedState + .compactMap(\.detailNotice) + .sink(with: self, receiveValue: { owner, notice in + owner.bindNotice(notice: notice) + }) + .store(in: &subscription) + + sharedState + .map(\.currentUserRole) + .filter { $0 != .member } + .sink(with: self, receiveValue: { owner, _ in + owner.navigationItem.setRightBarButtonItems( + [owner.removeBarButton, owner.rewriteBarButton], + animated: true + ) + }) + .store(in: &subscription) + } +} + +private extension DetailNoticeViewController { + func bindNotice(notice: DetailNoticeModel) { + signatureColorView.backgroundColor = notice.role.toSignatureColor + authorLabel.text = notice.role.toAuthorString + titleLabel.text = notice.title + dateLabel.text = notice.createdDate + .toStringWithCustomFormat(L10n.Notice.detailNoticeDateFormat) + contentLabel.text = notice.content + + contentStackView.removeAllChildren() + contentStackView.addArrangedSubview(contentLabel) + Task { + let imageViews = try await notice.images + .map { image in + ImageRequest( + url: URL(string: image.imageURL), + processors: [ + .gaussianBlur(), + .roundedCorners(radius: 8) + ], + options: .disableDiskCache + ) + } + .concurrentMap { try await ImagePipeline.shared.image(for: $0) } + .map { UIImageView(image: $0) } + self.contentStackView.addArrangedSubviews(views: imageViews) + } + } +} + +private extension UserRoleType { + var toAuthorString: String { + switch self { + case .admin: return L10n.Notice.authorRoleAdmin + case .councillor: return L10n.Notice.authorRoleCouncillor + case .developer: return L10n.Notice.authorRoleDeveloper + case .member: return L10n.Notice.authorRoleMember + } + } + + var toSignatureColor: UIColor { + switch self { + case .admin: return .dotori(.sub(.yellow)) + case .member: return .dotori(.neutral(.n20)) + case .councillor: return .dotori(.sub(.red)) + case .developer: return .dotori(.primary(.p10)) + } + } +} diff --git a/Projects/Feature/DetailNoticeFeature/Tests/DetailNoticeFeatureTest.swift b/Projects/Feature/DetailNoticeFeature/Tests/DetailNoticeFeatureTest.swift new file mode 100644 index 00000000..e0224ce4 --- /dev/null +++ b/Projects/Feature/DetailNoticeFeature/Tests/DetailNoticeFeatureTest.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import DetailNoticeFeature +@testable import NoticeDomainTesting +@testable import UserDomainTesting + +final class DetailNoticeFeatureTests: XCTestCase { + var fetchNoticeUseCase: FetchNoticeUseCaseSpy! + var removeNoticeUseCase: RemoveNoticeUseCaseSpy! + var loadCurrentUserRoleUseCase: LoadCurrentUserRoleUseCaseSpy! + var sut: DetailNoticeStore! + + override func setUp() { + fetchNoticeUseCase = .init() + removeNoticeUseCase = .init() + loadCurrentUserRoleUseCase = .init() + sut = .init( + noticeID: 1, + fetchNoticeUseCase: fetchNoticeUseCase, + removeNoticeUseCase: removeNoticeUseCase, + loadCurrentUserRoleUseCase: loadCurrentUserRoleUseCase + ) + } + + override func tearDown() { + fetchNoticeUseCase = nil + removeNoticeUseCase = nil + loadCurrentUserRoleUseCase = nil + sut = nil + } +} diff --git a/Projects/Feature/HomeFeature/Sources/Moordinator/HomeMoordinator.swift b/Projects/Feature/HomeFeature/Sources/Moordinator/HomeMoordinator.swift index a9f4fca5..8f9c5cb9 100644 --- a/Projects/Feature/HomeFeature/Sources/Moordinator/HomeMoordinator.swift +++ b/Projects/Feature/HomeFeature/Sources/Moordinator/HomeMoordinator.swift @@ -45,18 +45,7 @@ final class HomeMoordinator: Moordinator { return presentToMyViolationList() case let .confirmationDialog(title, description, confirmAction): - let viewController = confirmationDialogFactory.makeViewController( - title: title, - description: description, - confirmAction: confirmAction - ) - self.rootVC.modalPresent(viewController) - return .one( - .contribute( - withNextPresentable: viewController, - withNextRouter: viewController.store - ) - ) + return presentToConfirmationDialog(title: title, description: description, confirmAction: confirmAction) case .dismiss: self.rootVC.presentedViewController?.dismiss(animated: true) @@ -89,6 +78,25 @@ private extension HomeMoordinator { )) } + func presentToConfirmationDialog( + title: String, + description: String, + confirmAction: @escaping () async -> Void + ) -> MoordinatorContributors { + let viewController = confirmationDialogFactory.makeViewController( + title: title, + description: description, + confirmAction: confirmAction + ) + self.rootVC.modalPresent(viewController) + return .one( + .contribute( + withNextPresentable: viewController, + withNextRouter: viewController.store + ) + ) + } + func presentToAlert( title: String?, message: String?, diff --git a/Projects/Feature/HomeFeature/Sources/Scene/Store/HomeStore.swift b/Projects/Feature/HomeFeature/Sources/Scene/Store/HomeStore.swift index 9f460c13..08f47dda 100644 --- a/Projects/Feature/HomeFeature/Sources/Scene/Store/HomeStore.swift +++ b/Projects/Feature/HomeFeature/Sources/Scene/Store/HomeStore.swift @@ -163,7 +163,7 @@ private extension HomeStore { .init(title: L10n.Home.logoutButtonTitle, style: .default) { [route, logoutUseCase] _ in let confirmationDialogRoutePath = DotoriRoutePath.confirmationDialog( title: L10n.Home.logoutTitle, - message: L10n.Home.reallyLogoutTitle + description: L10n.Home.reallyLogoutTitle ) { logoutUseCase() route.send(DotoriRoutePath.signin) @@ -177,15 +177,15 @@ private extension HomeStore { } func applySelfStudyButtonDidTap() { -// guard currentState.currentUserRole == .member else { -// return -// } + guard currentState.currentUserRole == .member else { + return + } #warning("자습 인원 수정 로직 추가") Task.catching { if self.currentState.selfStudyStatus == .applied { let confirmRoutePath = DotoriRoutePath.confirmationDialog( title: "자습 신청 취소", - message: "정말 자습 신청을 취소하시겠습니까?" + description: "정말 자습 신청을 취소하시겠습니까?" ) { [cancelSelfStudyUseCase = self.cancelSelfStudyUseCase] in try? await cancelSelfStudyUseCase() await DotoriToast.makeToast(text: "자습을 취소하였습니다.", style: .success) @@ -202,15 +202,15 @@ private extension HomeStore { } func applyMassageButtonDidTap() { -// guard currentState.currentUserRole == .member else { -// return -// } + guard currentState.currentUserRole == .member else { + return + } #warning("안마 인원 수정 로직 추가") Task.catching { if self.currentState.massageStatus == .applied { let confirmRoutePath = DotoriRoutePath.confirmationDialog( title: "안마의자 신청 취소", - message: "정말 안마의자 신청을 취소하시겠습니까?" + description: "정말 안마의자 신청을 취소하시겠습니까?" ) { [cancelMassageUseCase = self.cancelMassageUseCase] in try? await cancelMassageUseCase() await DotoriToast.makeToast(text: "안마의자를 취소하였습니다.", style: .success) diff --git a/Projects/Feature/MusicFeature/Sources/Scene/MusicViewController.swift b/Projects/Feature/MusicFeature/Sources/Scene/MusicViewController.swift index 056a4786..270b6728 100644 --- a/Projects/Feature/MusicFeature/Sources/Scene/MusicViewController.swift +++ b/Projects/Feature/MusicFeature/Sources/Scene/MusicViewController.swift @@ -66,7 +66,8 @@ final class MusicViewController: BaseStoredViewController { override func configureNavigation() { self.navigationItem.setLeftBarButton(musicNavigationBarLabel, animated: true) - self.navigationItem.setRightBarButtonItems([proposeMusicButton, calendarBarButton], animated: true) + self.navigationItem.setRightBarButtonItems([proposeMusicButton], animated: true) + #warning("날짜 선택 구현") } override func bindAction() { diff --git a/Projects/Feature/NoticeFeature/Project.swift b/Projects/Feature/NoticeFeature/Project.swift index b9820202..c9f0ba24 100644 --- a/Projects/Feature/NoticeFeature/Project.swift +++ b/Projects/Feature/NoticeFeature/Project.swift @@ -7,6 +7,8 @@ let project = Project.module( targets: [ .implements(module: .feature(.NoticeFeature), dependencies: [ .feature(target: .BaseFeature), + .feature(target: .DetailNoticeFeature), + .feature(target: .ConfirmationDialogFeature), .domain(target: .NoticeDomain, type: .interface), .domain(target: .UserDomain, type: .interface) ]), diff --git a/Projects/Feature/NoticeFeature/Sources/Assembly/NoticeAssembly.swift b/Projects/Feature/NoticeFeature/Sources/Assembly/NoticeAssembly.swift index 703a2068..cba6af81 100644 --- a/Projects/Feature/NoticeFeature/Sources/Assembly/NoticeAssembly.swift +++ b/Projects/Feature/NoticeFeature/Sources/Assembly/NoticeAssembly.swift @@ -1,3 +1,5 @@ +import ConfirmationDialogFeature +import DetailNoticeFeature import NoticeDomainInterface import Swinject import UserDomainInterface @@ -8,7 +10,9 @@ public final class NoticeAssembly: Assembly { container.register(NoticeFactory.self) { resolver in NoticeFactoryImpl( fetchNoticeListUseCase: resolver.resolve(FetchNoticeListUseCase.self)!, - loadCurrentUserRoleUseCase: resolver.resolve(LoadCurrentUserRoleUseCase.self)! + loadCurrentUserRoleUseCase: resolver.resolve(LoadCurrentUserRoleUseCase.self)!, + confirmationDialogFactory: resolver.resolve(ConfirmationDialogFactory.self)!, + detailNoticeFactory: resolver.resolve(DetailNoticeFactory.self)! ) } } diff --git a/Projects/Feature/NoticeFeature/Sources/Factory/NoticeFactoryImpl.swift b/Projects/Feature/NoticeFeature/Sources/Factory/NoticeFactoryImpl.swift index 7e15246c..0dfd7e41 100644 --- a/Projects/Feature/NoticeFeature/Sources/Factory/NoticeFactoryImpl.swift +++ b/Projects/Feature/NoticeFeature/Sources/Factory/NoticeFactoryImpl.swift @@ -1,3 +1,5 @@ +import ConfirmationDialogFeature +import DetailNoticeFeature import Moordinator import NoticeDomainInterface import UserDomainInterface @@ -5,13 +7,19 @@ import UserDomainInterface struct NoticeFactoryImpl: NoticeFactory { private let fetchNoticeListUseCase: any FetchNoticeListUseCase private let loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase + private let confirmationDialogFactory: any ConfirmationDialogFactory + private let detailNoticeFactory: any DetailNoticeFactory init( fetchNoticeListUseCase: any FetchNoticeListUseCase, - loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase + loadCurrentUserRoleUseCase: any LoadCurrentUserRoleUseCase, + confirmationDialogFactory: any ConfirmationDialogFactory, + detailNoticeFactory: any DetailNoticeFactory ) { self.fetchNoticeListUseCase = fetchNoticeListUseCase self.loadCurrentUserRoleUseCase = loadCurrentUserRoleUseCase + self.confirmationDialogFactory = confirmationDialogFactory + self.detailNoticeFactory = detailNoticeFactory } func makeMoordinator() -> Moordinator { @@ -20,6 +28,10 @@ struct NoticeFactoryImpl: NoticeFactory { loadCurrentUserRoleUseCase: loadCurrentUserRoleUseCase ) let noticeViewController = NoticeViewController(store: noticeStore) - return NoticeMoordinator(noticeViewController: noticeViewController) + return NoticeMoordinator( + noticeViewController: noticeViewController, + confirmationDialogFactory: confirmationDialogFactory, + detailNoticeFactory: detailNoticeFactory + ) } } diff --git a/Projects/Feature/NoticeFeature/Sources/Moordinator/NoticeMoordinator.swift b/Projects/Feature/NoticeFeature/Sources/Moordinator/NoticeMoordinator.swift index 56e0e95a..d38e52c5 100644 --- a/Projects/Feature/NoticeFeature/Sources/Moordinator/NoticeMoordinator.swift +++ b/Projects/Feature/NoticeFeature/Sources/Moordinator/NoticeMoordinator.swift @@ -1,17 +1,27 @@ import BaseFeature +import ConfirmationDialogFeature import DWebKit -import UIKit +import DetailNoticeFeature import Moordinator +import UIKit final class NoticeMoordinator: Moordinator { private let rootVC = UINavigationController() private let noticeViewController: any StoredViewControllable + private let confirmationDialogFactory: any ConfirmationDialogFactory + private let detailNoticeFactory: any DetailNoticeFactory var root: Presentable { rootVC } - init(noticeViewController: any StoredViewControllable) { + init( + noticeViewController: any StoredViewControllable, + confirmationDialogFactory: any ConfirmationDialogFactory, + detailNoticeFactory: any DetailNoticeFactory + ) { self.noticeViewController = noticeViewController + self.confirmationDialogFactory = confirmationDialogFactory + self.detailNoticeFactory = detailNoticeFactory } func route(to path: RoutePath) -> MoordinatorContributors { @@ -20,6 +30,18 @@ final class NoticeMoordinator: Moordinator { case .notice: return coordinateToNotice() + case let .detailNotice(noticeID): + return navigateToDetailNotice(noticeID: noticeID) + + case let .confirmationDialog(title, description, confirmAction): + return presentToConfirmationDialog(title: title, description: description, confirmAction: confirmAction) + + case .dismiss: + self.rootVC.presentedViewController?.dismiss(animated: true) + + case .pop: + self.rootVC.popViewController(animated: true) + default: return .none } @@ -30,6 +52,41 @@ final class NoticeMoordinator: Moordinator { private extension NoticeMoordinator { func coordinateToNotice() -> MoordinatorContributors { self.rootVC.setViewControllers([self.noticeViewController], animated: true) - return .one(.contribute(withNextPresentable: self.noticeViewController, withNextRouter: self.noticeViewController.store)) + return .one( + .contribute( + withNextPresentable: self.noticeViewController, + withNextRouter: self.noticeViewController.store + ) + ) + } + + func navigateToDetailNotice(noticeID: Int) -> MoordinatorContributors { + let viewController = detailNoticeFactory.makeViewController(noticeID: noticeID) + self.rootVC.pushViewController(viewController, animated: true) + return .one( + .contribute( + withNextPresentable: viewController, + withNextRouter: viewController.store + ) + ) + } + + func presentToConfirmationDialog( + title: String, + description: String, + confirmAction: @escaping () async -> Void + ) -> MoordinatorContributors { + let viewController = confirmationDialogFactory.makeViewController( + title: title, + description: description, + confirmAction: confirmAction + ) + self.rootVC.modalPresent(viewController) + return .one( + .contribute( + withNextPresentable: viewController, + withNextRouter: viewController.store + ) + ) } } diff --git a/Projects/Feature/NoticeFeature/Sources/Scene/NoticeStore.swift b/Projects/Feature/NoticeFeature/Sources/Scene/NoticeStore.swift index f643ced4..539850d2 100644 --- a/Projects/Feature/NoticeFeature/Sources/Scene/NoticeStore.swift +++ b/Projects/Feature/NoticeFeature/Sources/Scene/NoticeStore.swift @@ -41,7 +41,8 @@ final class NoticeStore: BaseStore { case viewDidLoad case fetchNoticeList case editButtonDidTap - case noticeDidTap(Int) + case noticeDidSelect(Int) + case noticeDidDeselect(Int) } enum Mutation { case updateNoticeList([NoticeModel]) @@ -69,8 +70,11 @@ extension NoticeStore { .just(.removeAllSelectedNotice) ) - case let .noticeDidTap(noticeID): - return noticeDidTap(noticeID: noticeID) + case let .noticeDidSelect(noticeID): + return noticeDidSelect(noticeID: noticeID) + + case let .noticeDidDeselect(noticeID): + return noticeDidDeselect(noticeID: noticeID) } return .none } @@ -129,15 +133,16 @@ private extension NoticeStore { .eraseToSideEffect() } - func noticeDidTap(noticeID: Int) -> SideEffect { + func noticeDidSelect(noticeID: Int) -> SideEffect { guard currentState.isEditingMode else { - route.send(DotoriRoutePath.noticeDetail(noticeID: noticeID)) + route.send(DotoriRoutePath.detailNotice(noticeID: noticeID)) return .none } - let mutation = currentState.selectedNotice.contains(noticeID) - ? Mutation.removeSelectedNotice(noticeID) - : Mutation.insertSelectedNotice(noticeID) - return .just(mutation) + return .just(.insertSelectedNotice(noticeID)) + } + + func noticeDidDeselect(noticeID: Int) -> SideEffect { + return .just(.removeSelectedNotice(noticeID)) } } diff --git a/Projects/Feature/NoticeFeature/Sources/Scene/NoticeViewController.swift b/Projects/Feature/NoticeFeature/Sources/Scene/NoticeViewController.swift index cf89d0f4..5b11cdf5 100644 --- a/Projects/Feature/NoticeFeature/Sources/Scene/NoticeViewController.swift +++ b/Projects/Feature/NoticeFeature/Sources/Scene/NoticeViewController.swift @@ -32,7 +32,6 @@ final class NoticeViewController: BaseStoredViewController { .set(\.backgroundColor, .clear) .set(\.separatorStyle, .none) .set(\.sectionHeaderTopPadding, 0) - .set(\.allowsSelection, false) .set(\.allowsMultipleSelection, false) .then { $0.register(cellType: NoticeCell.self) @@ -98,9 +97,14 @@ final class NoticeViewController: BaseStoredViewController { .store(in: &subscription) noticeTableAdapter.modelSelected - .merge(with: noticeTableAdapter.modelDeselected) .map(\.id) - .map(Store.Action.noticeDidTap) + .map(Store.Action.noticeDidSelect) + .sink(receiveValue: store.send(_:)) + .store(in: &subscription) + + noticeTableAdapter.modelDeselected + .map(\.id) + .map(Store.Action.noticeDidDeselect) .sink(receiveValue: store.send(_:)) .store(in: &subscription) @@ -110,6 +114,7 @@ final class NoticeViewController: BaseStoredViewController { .store(in: &subscription) } + // swiftlint: disable function_body_length override func bindState() { let sharedState = store.state.share() .receive(on: DispatchQueue.main) @@ -132,7 +137,6 @@ final class NoticeViewController: BaseStoredViewController { .map { $0.currentUserRole != .member && $0.isEditingMode } .removeDuplicates() .sink(with: noticeTableView, receiveValue: { tableView, isEditing in - tableView.allowsSelection = isEditing tableView.allowsMultipleSelection = isEditing tableView.reloadData() }) @@ -140,6 +144,7 @@ final class NoticeViewController: BaseStoredViewController { sharedState .map(\.currentUserRole) + .removeDuplicates() .map { $0 == .member } .sink { [editButton, writeOrRemoveButton] isMember in editButton.isHidden = isMember @@ -147,18 +152,13 @@ final class NoticeViewController: BaseStoredViewController { } .store(in: &subscription) - sharedState - .map(\.isEditingMode) - .map { $0 ? L10n.Global.cancelButtonTitle : L10n.Notice.editButtonTitle } - .sink(with: editButton, receiveValue: { editButton, title in - editButton.setTitle(title, for: .normal) - }) - .store(in: &subscription) - sharedState .map(\.isEditingMode) .sink(receiveValue: { [weak self] isEditingMode in self?.transformWriteOrRemoveButton(isEditMode: isEditingMode) + + let title = isEditingMode ? L10n.Global.cancelButtonTitle : L10n.Notice.editButtonTitle + self?.editButton.setTitle(title, for: .normal) }) .store(in: &subscription) @@ -171,6 +171,7 @@ final class NoticeViewController: BaseStoredViewController { }) .store(in: &subscription) } + // swiftlint: enable function_body_length } private extension NoticeViewController { diff --git a/Projects/Feature/NoticeFeature/Tests/NoticeFeatureTest.swift b/Projects/Feature/NoticeFeature/Tests/NoticeFeatureTest.swift index cc8ec71d..d8d2e16c 100644 --- a/Projects/Feature/NoticeFeature/Tests/NoticeFeatureTest.swift +++ b/Projects/Feature/NoticeFeature/Tests/NoticeFeatureTest.swift @@ -98,7 +98,7 @@ final class NoticeFeatureTests: XCTestCase { XCTAssertEqual(sut.currentState.isEditingMode, true) } - func test_RouteNoticeDetail_When_NoticeDidTap_And_IsEditingFalse() { + func test_RouteNoticeDetail_When_NoticeDidSelect_And_IsEditingFalse() { let expectedNoticeID = 1 XCTAssertEqual(sut.currentState.isEditingMode, false) let expectation = XCTestExpectation(description: "route expectation") @@ -110,12 +110,12 @@ final class NoticeFeatureTests: XCTestCase { } .store(in: &subscription) - sut.send(.noticeDidTap(expectedNoticeID)) + sut.send(.noticeDidSelect(expectedNoticeID)) wait(for: [expectation], timeout: 1.0) guard let latestRoutePath = latestRoutePath?.asDotori, - case let .noticeDetail(noticeID) = latestRoutePath, + case let .detailNotice(noticeID) = latestRoutePath, noticeID == expectedNoticeID else { XCTFail("latestRoutePath is not DotoriRoutePath.noticeDetail") @@ -123,25 +123,25 @@ final class NoticeFeatureTests: XCTestCase { } } - func test_InsertSelectedNotice_When_NoticeDidTap_And_IsEditingTrue() { + func test_InsertSelectedNotice_When_NoticeDidSelectAndDeselect_And_IsEditingTrue() { sut.send(.editButtonDidTap) XCTAssertEqual(sut.currentState.isEditingMode, true) let noticeIDOne = 1 - sut.send(.noticeDidTap(noticeIDOne)) + sut.send(.noticeDidSelect(noticeIDOne)) XCTAssertEqual([noticeIDOne], sut.currentState.selectedNotice) let noticeIDTwo = 2 - sut.send(.noticeDidTap(noticeIDTwo)) + sut.send(.noticeDidSelect(noticeIDTwo)) XCTAssertEqual([noticeIDOne, noticeIDTwo], sut.currentState.selectedNotice) - sut.send(.noticeDidTap(noticeIDOne)) - XCTAssertEqual([noticeIDTwo], sut.currentState.selectedNotice) - - sut.send(.noticeDidTap(noticeIDOne)) + sut.send(.noticeDidSelect(noticeIDOne)) XCTAssertEqual([noticeIDOne, noticeIDTwo], sut.currentState.selectedNotice) + sut.send(.noticeDidDeselect(noticeIDOne)) + XCTAssertEqual([noticeIDTwo], sut.currentState.selectedNotice) + sut.send(.editButtonDidTap) XCTAssertEqual([], sut.currentState.selectedNotice) } diff --git a/Projects/UserInterface/DesignSystem/Sources/Label/DotoriLabel.swift b/Projects/UserInterface/DesignSystem/Sources/Label/DotoriLabel.swift index 7a217039..17b950e4 100644 --- a/Projects/UserInterface/DesignSystem/Sources/Label/DotoriLabel.swift +++ b/Projects/UserInterface/DesignSystem/Sources/Label/DotoriLabel.swift @@ -1,6 +1,17 @@ +import MSGLayout import UIKit public final class DotoriLabel: UILabel { + public var padding = UIEdgeInsets.all(0) + + public override var intrinsicContentSize: CGSize { + var contentSize = super.intrinsicContentSize + contentSize.height += padding.top + padding.bottom + contentSize.width += padding.left + padding.right + + return contentSize + } + public init( _ title: String = "", textColor: UIColor.DotoriColorSystem = .neutral(.n10), @@ -12,6 +23,10 @@ public final class DotoriLabel: UILabel { self.textColor = .dotori(textColor) } + public override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: padding)) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Projects/UserInterface/Localization/Resources/en.lproj/Notice.strings b/Projects/UserInterface/Localization/Resources/en.lproj/Notice.strings index cab730b2..2a4ac4bd 100644 --- a/Projects/UserInterface/Localization/Resources/en.lproj/Notice.strings +++ b/Projects/UserInterface/Localization/Resources/en.lproj/Notice.strings @@ -6,7 +6,7 @@ Copyright © 2023 com.msg. All rights reserved. */ -"entire_title" = "Entire"; +"entire_title" = "All"; "notice_section_date_format" = "MMM yyyy"; "author_role_admin" = "Dormitory Supervisor"; "author_role_developer" = "Dotori"; @@ -15,3 +15,7 @@ "edit_button_title" = "Edit"; "write_button_title" = "+ Write"; "remove_button_title" = "Remove"; +"detail_notice_date_format" = "MMM dd, yyyy"; +"remove_notice_dialog_title" = "Remove the notice"; +"remove_notice_dialog_description" = "Are you sure you want to delete the notice?"; +"complete_to_remove_notice" = "Complete to remove the notice"; diff --git a/Projects/UserInterface/Localization/Resources/ko.lproj/Notice.strings b/Projects/UserInterface/Localization/Resources/ko.lproj/Notice.strings index 144aac5c..0e57cd35 100644 --- a/Projects/UserInterface/Localization/Resources/ko.lproj/Notice.strings +++ b/Projects/UserInterface/Localization/Resources/ko.lproj/Notice.strings @@ -15,3 +15,7 @@ "edit_button_title" = "편집"; "write_button_title" = "+ 작성"; "remove_button_title" = "삭제"; +"detail_notice_date_format" = "yyyy년 MM월 dd일"; +"remove_notice_dialog_title" = "공지사항 삭제"; +"remove_notice_dialog_description" = "정말로 공지사항을 삭제하시겠습니까?"; +"complete_to_remove_notice" = "공지사항 삭제 완료"; diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift index 78988179..f44a012e 100644 --- a/Tuist/Dependencies.swift +++ b/Tuist/Dependencies.swift @@ -5,6 +5,7 @@ let dependencies = Dependencies( carthage: nil, swiftPackageManager: SwiftPackageManagerDependencies( [ + .remote(url: "https://github.com/kean/Nuke.git", requirement: .exact("12.1.4")), .remote(url: "https://github.com/GSM-MSG/Anim.git", requirement: .exact("1.1.0")), .remote(url: "https://github.com/GSM-MSG/Miniature.git", requirement: .exact("1.3.1")), .remote(url: "https://github.com/baekteun/NeiSwift.git", requirement: .exact("2.0.2")),