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")),