diff --git a/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift b/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift index f9239674..4486b9f3 100644 --- a/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift +++ b/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift @@ -398,7 +398,7 @@ extension APIEndpoints { query["cursor"] = cursor } - return try Endpoint(path: "comment/\(postId)", + return try Endpoint(path: "comment/post/\(postId)", authenticationType: .accessToken, method: .get, headerParameters: HTTPHeader.getReceiveJsonHeader(), @@ -408,7 +408,7 @@ extension APIEndpoints { static func createComment(postId: Int, comment: String, mentions: [Int]) throws -> Endpoint { - return try Endpoint(path: "comment/\(postId)", + return try Endpoint(path: "comment/post/\(postId)", authenticationType: .accessToken, method: .post, headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(), @@ -439,7 +439,7 @@ extension APIEndpoints { commentId: Int, comment: String, mentions: [Int]) throws -> Endpoint { - return try Endpoint(path: "comment/\(postId)/\(commentId)", + return try Endpoint(path: "comment/post/\(postId)/\(commentId)", authenticationType: .accessToken, method: .post, headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(), @@ -456,7 +456,7 @@ extension APIEndpoints { cursorQuery["cursor"] = nextCursor } - return try Endpoint(path: "comment/\(postId)/\(commentId)", + return try Endpoint(path: "comment/post/\(postId)/\(commentId)", authenticationType: .accessToken, method: .get, headerParameters: HTTPHeader.getReceiveJsonHeader(), @@ -465,7 +465,7 @@ extension APIEndpoints { // MARK: - Like static func likeComment(commentId: Int) throws -> Endpoint { - return try Endpoint(path: "comment/\(commentId)/likes", + return try Endpoint(path: "comment/post/\(commentId)/likes", authenticationType: .accessToken, method: .post, headerParameters: HTTPHeader.getReceiveJsonHeader()) @@ -600,7 +600,7 @@ extension APIEndpoints { static func subscribeMeetNotify(type: SubscribeType, isSubscribe: Bool) throws -> Endpoint { - + let path = isSubscribe ? "notification/subscribe" : "notification/unsubscribe" return try Endpoint(path: path, authenticationType: .accessToken, @@ -609,3 +609,103 @@ extension APIEndpoints { bodyParameters: ["topics": ["\(type.rawValue)"]]) } } + +// MARK: - 공지 (Notice) +extension APIEndpoints { + // 공지 리스트 — GET /notice/list/{meetId} + static func fetchNoticeList(meetId: Int, + size: Int?, + cursor: String?) throws -> Endpoint> { + var query: [String: Any] = [:] + if let size { query["size"] = size } + if let cursor, !cursor.isEmpty { query["cursor"] = cursor } + + return try Endpoint(path: "notice/list/\(meetId)", + authenticationType: .accessToken, + method: .get, + headerParameters: HTTPHeader.getReceiveJsonHeader(), + queryParameters: query) + } + + // 공지 단건 상세 — GET /notice/detail/{noticeId} + static func fetchNoticeDetail(noticeId: Int) throws -> Endpoint { + return try Endpoint(path: "notice/detail/\(noticeId)", + authenticationType: .accessToken, + method: .get, + headerParameters: HTTPHeader.getReceiveJsonHeader()) + } + + // 공지 생성 — POST /notice/create + static func createNotice(meetId: Int, + content: String) throws -> Endpoint { + return try Endpoint(path: "notice/create", + authenticationType: .accessToken, + method: .post, + headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(), + bodyParameters: ["meetId": meetId, + "content": content]) + } + + // 공지 수정 — PATCH /notice/update/{noticeId} + static func updateNotice(noticeId: Int, + meetId: Int, + content: String) throws -> Endpoint { + return try Endpoint(path: "notice/update/\(noticeId)", + authenticationType: .accessToken, + method: .patch, + headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(), + bodyParameters: ["meetId": meetId, + "content": content]) + } + + // 공지 삭제 — DELETE /notice/{noticeId} + static func deleteNotice(noticeId: Int) throws -> Endpoint { + return try Endpoint(path: "notice/\(noticeId)", + authenticationType: .accessToken, + method: .delete, + headerParameters: HTTPHeader.getReceiveAllHeader()) + } + + // 공지 고정 — PATCH /notice/pin/{noticeId} + static func pinNotice(noticeId: Int) throws -> Endpoint { + return try Endpoint(path: "notice/pin/\(noticeId)", + authenticationType: .accessToken, + method: .patch, + headerParameters: HTTPHeader.getReceiveJsonHeader()) + } + + // 공지 고정 해제 — DELETE /notice/pin/{noticeId} + static func unpinNotice(noticeId: Int) throws -> Endpoint { + return try Endpoint(path: "notice/pin/\(noticeId)", + authenticationType: .accessToken, + method: .delete, + headerParameters: HTTPHeader.getReceiveJsonHeader()) + } + + // 공지 댓글 조회 — GET /comment/notice/{noticeId} + static func fetchNoticeCommentList(noticeId: Int, + size: Int?, + cursor: String?) throws -> Endpoint> { + var query: [String: Any] = [:] + if let size { query["size"] = size } + if let cursor, !cursor.isEmpty { query["cursor"] = cursor } + + return try Endpoint(path: "comment/notice/\(noticeId)", + authenticationType: .accessToken, + method: .get, + headerParameters: HTTPHeader.getReceiveJsonHeader(), + queryParameters: query) + } + + // 공지 댓글 생성 — POST /comment/notice/{noticeId} + static func createNoticeComment(noticeId: Int, + content: String, + mentions: [Int]) throws -> Endpoint { + return try Endpoint(path: "comment/notice/\(noticeId)", + authenticationType: .accessToken, + method: .post, + headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(), + bodyParameters: ["contents": content, + "mentions": mentions]) + } +} diff --git a/Modules/Data/Sources/Network/Protocols/NetworkService.swift b/Modules/Data/Sources/Network/Protocols/NetworkService.swift index 8a58c46f..8ada22e9 100644 --- a/Modules/Data/Sources/Network/Protocols/NetworkService.swift +++ b/Modules/Data/Sources/Network/Protocols/NetworkService.swift @@ -19,6 +19,7 @@ public protocol NetworkSessionManager { public protocol NetworkErrorLogger { func log(request: URLRequest) + func log(response: HTTPURLResponse, data: Data?) func log(responseData data: Data?) func log(error: Error) } diff --git a/Modules/Data/Sources/Network/Response/Notice/NoticeCommentResponse.swift b/Modules/Data/Sources/Network/Response/Notice/NoticeCommentResponse.swift new file mode 100644 index 00000000..17a1d286 --- /dev/null +++ b/Modules/Data/Sources/Network/Response/Notice/NoticeCommentResponse.swift @@ -0,0 +1,40 @@ +// +// NoticeCommentResponse.swift +// Data +// +// Created by CatSlave on 5/26/26. +// +// 서버 NoticeCommentClientResponse — 공지 댓글 객체. +// Post 댓글과 달리 likes/replies/mentions 정보가 응답에 없어 Comment 도메인의 일부만 채운다. +// + +import Foundation +import Domain + +struct NoticeCommentResponse: Decodable { + let commentId: Int? + let version: Int? + let content: String? + let parentId: Int? + let writer: UserInfoResponse? + let time: String? + let noticeId: Int? +} + +extension NoticeCommentResponse { + func toDomain() -> Comment { + let date = DateManager.parseServerFullDate(string: time) + return .init(id: commentId, + postId: noticeId, + parentId: parentId, + writerId: writer?.userId, + writerName: writer?.nickname, + writerThumbnailPath: writer?.image, + comment: content, + createdDate: date, + isLiked: false, + likeCount: 0, + replyCount: 0, + mentions: []) + } +} diff --git a/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift b/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift index 95d92b8f..25e1ecab 100644 --- a/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift +++ b/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift @@ -17,6 +17,7 @@ struct NoticeResponse: Decodable { let meetId: Int? let type: String? let content: String? + let writer: UserInfoResponse? // 공지 작성자 (isExistBadgeCount는 응답에 없어도 옵셔널이라 디코딩 OK) let pinned: Bool? let createdAt: String? } @@ -29,6 +30,7 @@ extension NoticeResponse { meetId: meetId, type: NoticeType(rawValue: type ?? ""), content: content, + writer: writer?.toDomain(), isPinned: pinned ?? false, createdAt: DateManager.parseServerFullDate(string: createdAt) ) diff --git a/Modules/Data/Sources/PersistentStorages/UserDefaults/ReviewSuggestionStorage.swift b/Modules/Data/Sources/PersistentStorages/UserDefaults/ReviewSuggestionStorage.swift new file mode 100644 index 00000000..f1c6e206 --- /dev/null +++ b/Modules/Data/Sources/PersistentStorages/UserDefaults/ReviewSuggestionStorage.swift @@ -0,0 +1,31 @@ +// +// ReviewSuggestionStorage.swift +// Mople +// +// Created by CatSlave on 6/12/26. +// + +import Foundation + +private enum UserDefaultsKey: String { + case suggestedReviewPostIds +} + +/// 후기 작성 추천 알럿의 노출 이력을 게시글 단위로 관리한다. +/// - 같은 후기 게시글에 다시 들어와도 알럿이 매번 뜨지 않고, 게시글마다 최초 1회만 노출되도록 한다. +public extension UserDefaults { + + /// 해당 게시글에서 후기 추천 알럿을 이미 노출했는지 여부 + static func hasSuggestedReview(postId: Int) -> Bool { + let shownIds = UserDefaults.standard.array(forKey: UserDefaultsKey.suggestedReviewPostIds.rawValue) as? [Int] ?? [] + return shownIds.contains(postId) + } + + /// 해당 게시글의 후기 추천 알럿 노출 이력을 기록한다. + static func markSuggestedReview(postId: Int) { + var shownIds = UserDefaults.standard.array(forKey: UserDefaultsKey.suggestedReviewPostIds.rawValue) as? [Int] ?? [] + guard !shownIds.contains(postId) else { return } + shownIds.append(postId) + UserDefaults.standard.set(shownIds, forKey: UserDefaultsKey.suggestedReviewPostIds.rawValue) + } +} diff --git a/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift b/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift new file mode 100644 index 00000000..83ba415a --- /dev/null +++ b/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift @@ -0,0 +1,99 @@ +// +// DefaultNoticeRepo.swift +// Data +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain + +public final class DefaultNoticeRepo: BaseRepositories, NoticeRepo { + + // MARK: - 공지 CRUD + public func fetchNoticeList(meetId: Int, + size: Int?, + cursor: String?) async throws -> Page { + let response: PageResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.fetchNoticeList(meetId: meetId, + size: size, + cursor: cursor) + } + return Page(totalCount: response.totalCount ?? 0, + content: response.content.map { $0.toDomain() }, + info: response.page?.toDomain()) + } + + public func fetchNoticeDetail(noticeId: Int) async throws -> Notice { + let response: NoticeResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.fetchNoticeDetail(noticeId: noticeId) + } + return response.toDomain() + } + + public func createNotice(meetId: Int, + content: String) async throws -> Notice { + let response: NoticeResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.createNotice(meetId: meetId, + content: content) + } + return response.toDomain() + } + + public func updateNotice(noticeId: Int, + meetId: Int, + content: String) async throws -> Notice { + let response: NoticeResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.updateNotice(noticeId: noticeId, + meetId: meetId, + content: content) + } + return response.toDomain() + } + + public func deleteNotice(noticeId: Int) async throws { + return try await self.networkService.authenticatedRequest { + try APIEndpoints.deleteNotice(noticeId: noticeId) + } + } + + // MARK: - 고정 토글 + public func pinNotice(noticeId: Int) async throws -> Notice { + let response: NoticeResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.pinNotice(noticeId: noticeId) + } + return response.toDomain() + } + + public func unpinNotice(noticeId: Int) async throws -> Notice { + let response: NoticeResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.unpinNotice(noticeId: noticeId) + } + return response.toDomain() + } + + // MARK: - 공지 댓글 + public func fetchNoticeCommentList(noticeId: Int, + size: Int?, + cursor: String?) async throws -> Page { + let response: PageResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.fetchNoticeCommentList(noticeId: noticeId, + size: size, + cursor: cursor) + } + return Page(totalCount: response.totalCount ?? 0, + content: response.content.map { $0.toDomain() }, + info: response.page?.toDomain()) + } + + public func createNoticeComment(noticeId: Int, + content: String, + mentions: [Int]) async throws -> Comment { + let response: NoticeCommentResponse = try await self.networkService.authenticatedRequest { + try APIEndpoints.createNoticeComment(noticeId: noticeId, + content: content, + mentions: mentions) + } + return response.toDomain() + } +} diff --git a/Modules/Domain/Sources/Entities/Meet/Meet.swift b/Modules/Domain/Sources/Entities/Meet/Meet.swift index 67487163..d19c2686 100644 --- a/Modules/Domain/Sources/Entities/Meet/Meet.swift +++ b/Modules/Domain/Sources/Entities/Meet/Meet.swift @@ -35,3 +35,20 @@ public struct Meet { self.pinnedNotice = pinnedNotice } } + +public extension Meet { + // pinnedNotice는 let 이라 부분 갱신 불가 — copy with replacement 헬퍼. + // 공지 핀 토글 알림 수신 시 모임상세 상태를 새 인스턴스로 갱신할 때 사용. + func with(pinnedNotice: Notice?) -> Meet { + return Meet( + isCreator: isCreator, + meetSummary: meetSummary, + sinceDays: sinceDays, + creatorId: creatorId, + memberCount: memberCount, + firstPlanDate: firstPlanDate, + version: version, + pinnedNotice: pinnedNotice + ) + } +} diff --git a/Modules/Domain/Sources/Entities/Notice/Notice.swift b/Modules/Domain/Sources/Entities/Notice/Notice.swift index bbe5228c..e35cd312 100644 --- a/Modules/Domain/Sources/Entities/Notice/Notice.swift +++ b/Modules/Domain/Sources/Entities/Notice/Notice.swift @@ -15,6 +15,7 @@ public struct Notice: Hashable { public let meetId: Int? public let type: NoticeType? public let content: String? + public let writer: UserInfo? // 공지 작성자 (백엔드 writer 응답) public let isPinned: Bool public let createdAt: Date? @@ -23,6 +24,7 @@ public struct Notice: Hashable { meetId: Int? = nil, type: NoticeType? = nil, content: String? = nil, + writer: UserInfo? = nil, isPinned: Bool = false, createdAt: Date? = nil) { self.noticeId = noticeId @@ -30,6 +32,7 @@ public struct Notice: Hashable { self.meetId = meetId self.type = type self.content = content + self.writer = writer self.isPinned = isPinned self.createdAt = createdAt } diff --git a/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift b/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift index 5ab9b6e4..944c0eea 100644 --- a/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift +++ b/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift @@ -19,6 +19,9 @@ public protocol NoticeRepo { size: Int?, cursor: String?) async throws -> Page + // 단건 상세 조회 — 상세 화면 진입/새로고침 시 fresh한 공지를 받기 위함 + func fetchNoticeDetail(noticeId: Int) async throws -> Notice + func createNotice(meetId: Int, content: String) async throws -> Notice func updateNotice(noticeId: Int, diff --git a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift index 3d856876..34da8575 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift @@ -14,13 +14,26 @@ public protocol FetchMeetDetail { public final class FetchMeetDetailUseCase: FetchMeetDetail { private let repo: MeetRepo + private let session: UserSessionProvider - public init(repo: MeetRepo) { + public init(repo: MeetRepo, session: UserSessionProvider) { self.repo = repo + self.session = session } public func execute(meetId: Int) async throws -> Meet { - return try await repo.fetchMeetDetail(meetId: meetId) + var meet = try await repo.fetchMeetDetail(meetId: meetId) + verifyCreator(with: &meet) + return meet + } + + // 서버 MeetResponse엔 isCreator 필드가 없어 default false로 들어온다. + // FetchMeetPage와 동일한 패턴으로 currentUserId와 creatorId를 비교해 isCreator를 채움. + // 이 호출이 빠지면 모임장이어도 isCreator=false로 남아 작성 툴팁/연필 버튼 등 모임장 전용 UI가 노출되지 않는다. + private func verifyCreator(with meet: inout Meet) { + guard let ownerId = meet.creatorId, + session.currentUserId == ownerId else { return } + meet.isCreator = true } } diff --git a/Modules/Domain/Sources/UseCases/Meet/Write/CreateMeet.swift b/Modules/Domain/Sources/UseCases/Meet/Write/CreateMeet.swift index 89a8ee49..51cbd890 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Write/CreateMeet.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Write/CreateMeet.swift @@ -14,13 +14,26 @@ public protocol CreateMeet { public final class CreateMeetUseCase: CreateMeet { let createMeetRepo: MeetRepo + private let session: UserSessionProvider - public init(createMeetRepo: MeetRepo) { + public init(createMeetRepo: MeetRepo, session: UserSessionProvider) { self.createMeetRepo = createMeetRepo + self.session = session } public func execute(requset: CreateMeetRequest) async throws -> Meet { - return try await self.createMeetRepo.createMeet(reqeust: requset) + var meet = try await self.createMeetRepo.createMeet(reqeust: requset) + verifyCreator(with: &meet) + return meet + } + + // 서버 MeetResponse엔 isCreator 필드가 없어 default false로 들어온다. + // 모임을 만든 본인이 곧 모임장이지만, 단일 진실 소스(meet.isCreator)를 정상화하기 위해 + // FetchMeetPage/FetchMeetDetail과 동일한 verifyCreator 패턴 적용. + private func verifyCreator(with meet: inout Meet) { + guard let ownerId = meet.creatorId, + session.currentUserId == ownerId else { return } + meet.isCreator = true } } diff --git a/Modules/Domain/Sources/UseCases/Meet/Write/EditMeet.swift b/Modules/Domain/Sources/UseCases/Meet/Write/EditMeet.swift index 65ce61c2..ecd98009 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Write/EditMeet.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Write/EditMeet.swift @@ -15,14 +15,25 @@ public protocol EditMeet { public final class EditMeetUseCase: EditMeet { let repo: MeetRepo + private let session: UserSessionProvider - public init(repo: MeetRepo) { + public init(repo: MeetRepo, session: UserSessionProvider) { self.repo = repo + self.session = session } public func execute(id: Int, request: CreateMeetRequest) async throws -> Meet { - return try await repo.editMeet(id: id, reqeust: request) + var meet = try await repo.editMeet(id: id, reqeust: request) + verifyCreator(with: &meet) + return meet + } + + // FetchMeet*/CreateMeet과 동일한 verifyCreator 패턴. + private func verifyCreator(with meet: inout Meet) { + guard let ownerId = meet.creatorId, + session.currentUserId == ownerId else { return } + meet.isCreator = true } } diff --git a/Modules/Domain/Sources/UseCases/Notice/CreateNotice.swift b/Modules/Domain/Sources/UseCases/Notice/CreateNotice.swift new file mode 100644 index 00000000..d82da06d --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/CreateNotice.swift @@ -0,0 +1,45 @@ +// +// CreateNotice.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol CreateNotice { + func execute(meetId: Int, content: String) async throws -> Notice +} + +public final class CreateNoticeUseCase: CreateNotice { + + private let repo: NoticeRepo + + public init(repo: NoticeRepo) { + self.repo = repo + } + + public func execute(meetId: Int, content: String) async throws -> Notice { + return try await repo.createNotice(meetId: meetId, content: content) + } +} + +#if DEV +public final class MockCreateNoticeUseCase: CreateNotice { + public init() {} + + public func execute(meetId: Int, content: String) async throws -> Notice { + print("✅ [Mock] CreateNotice - meetId: \(meetId), content: \(content)") + try await Task.sleep(nanoseconds: 800_000_000) + return Notice( + noticeId: Int.random(in: 1000...9999), + version: 1, + meetId: meetId, + type: .custom, + content: content, + isPinned: false, + createdAt: Date() + ) + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/CreateNoticeComment.swift b/Modules/Domain/Sources/UseCases/Notice/CreateNoticeComment.swift new file mode 100644 index 00000000..7a39c490 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/CreateNoticeComment.swift @@ -0,0 +1,57 @@ +// +// CreateNoticeComment.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol CreateNoticeComment { + func execute(noticeId: Int, + content: String, + mentions: [Int]) async throws -> Comment +} + +public final class CreateNoticeCommentUseCase: CreateNoticeComment { + + private let repo: NoticeRepo + private let session: UserSessionProvider + + public init(repo: NoticeRepo, session: UserSessionProvider) { + self.repo = repo + self.session = session + } + + public func execute(noticeId: Int, + content: String, + mentions: [Int]) async throws -> Comment { + var comment = try await repo.createNoticeComment(noticeId: noticeId, + content: content, + mentions: mentions) + comment.verifyWriter(session.currentUserId) + return comment + } +} + +#if DEV +public final class MockCreateNoticeCommentUseCase: CreateNoticeComment { + public init() {} + + public func execute(noticeId: Int, + content: String, + mentions: [Int]) async throws -> Comment { + print("✅ [Mock] CreateNoticeComment - noticeId: \(noticeId), content: \(content)") + try await Task.sleep(nanoseconds: 500_000_000) + var c = Comment() + c.id = Int.random(in: 1000...9999) + c.postId = noticeId + c.writerId = 1 + c.writerName = "Matthew" + c.comment = content + c.createdDate = Date() + c.isWriter = true + return c + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/DeleteNotice.swift b/Modules/Domain/Sources/UseCases/Notice/DeleteNotice.swift new file mode 100644 index 00000000..fdc07eb7 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/DeleteNotice.swift @@ -0,0 +1,36 @@ +// +// DeleteNotice.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol DeleteNotice { + func execute(noticeId: Int) async throws +} + +public final class DeleteNoticeUseCase: DeleteNotice { + + private let repo: NoticeRepo + + public init(repo: NoticeRepo) { + self.repo = repo + } + + public func execute(noticeId: Int) async throws { + try await repo.deleteNotice(noticeId: noticeId) + } +} + +#if DEV +public final class MockDeleteNoticeUseCase: DeleteNotice { + public init() {} + + public func execute(noticeId: Int) async throws { + print("✅ [Mock] DeleteNotice - noticeId: \(noticeId)") + try await Task.sleep(nanoseconds: 400_000_000) + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/FetchNotice.swift b/Modules/Domain/Sources/UseCases/Notice/FetchNotice.swift new file mode 100644 index 00000000..d322c5b2 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/FetchNotice.swift @@ -0,0 +1,47 @@ +// +// FetchNotice.swift +// Domain +// +// Created by CatSlave on 6/12/26. +// + +import Foundation + +// 공지 단건 상세 조회. +// 상세 화면 진입/새로고침 시 목록에서 건네받은(=stale 가능) Notice 대신 fresh한 값을 받기 위함. +public protocol FetchNotice { + func execute(noticeId: Int) async throws -> Notice +} + +public final class FetchNoticeUseCase: FetchNotice { + + private let repo: NoticeRepo + + public init(repo: NoticeRepo) { + self.repo = repo + } + + public func execute(noticeId: Int) async throws -> Notice { + return try await repo.fetchNoticeDetail(noticeId: noticeId) + } +} + +// MARK: - Mock UseCase +#if DEV +public final class MockFetchNoticeUseCase: FetchNotice { + public init() {} + + public func execute(noticeId: Int) async throws -> Notice { + print("✅ [Mock] FetchNotice - noticeId: \(noticeId)") + try await Task.sleep(nanoseconds: 300_000_000) + return Notice(noticeId: noticeId, + version: 1, + meetId: 1, + type: .custom, + content: "[단건조회] 새로고침된 공지 내용 (#\(noticeId))", + writer: UserInfo(id: 1, name: "모임장 매튜", imagePath: nil), + isPinned: false, + createdAt: Date()) + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/FetchNoticeCommentList.swift b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeCommentList.swift new file mode 100644 index 00000000..8a7d37fb --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeCommentList.swift @@ -0,0 +1,70 @@ +// +// FetchNoticeCommentList.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol FetchNoticeCommentList { + func execute(noticeId: Int, + size: Int?, + cursor: String?) async throws -> Page +} + +public final class FetchNoticeCommentListUseCase: FetchNoticeCommentList { + + private let repo: NoticeRepo + private let session: UserSessionProvider + + public init(repo: NoticeRepo, session: UserSessionProvider) { + self.repo = repo + self.session = session + } + + public func execute(noticeId: Int, + size: Int?, + cursor: String?) async throws -> Page { + let page = try await repo.fetchNoticeCommentList(noticeId: noticeId, + size: size, + cursor: cursor) + var checked = page + checked.content = checked.content.map { + var c = $0 + c.verifyWriter(session.currentUserId) + return c + } + return checked + } +} + +#if DEV +public final class MockFetchNoticeCommentListUseCase: FetchNoticeCommentList { + public init() {} + + public func execute(noticeId: Int, + size: Int?, + cursor: String?) async throws -> Page { + print("✅ [Mock] FetchNoticeCommentList - noticeId: \(noticeId)") + + let now = Date() + let mocks: [Comment] = (1...3).map { idx in + var c = Comment() + c.id = idx + c.postId = noticeId + c.writerId = idx + c.writerName = ["자바중앙의대총장", "최조르방", "Matthew"][idx - 1] + c.comment = ["네 확인했습니다", "어이 나한테는 너 무지 친절하구나", "감사합니다!"][idx - 1] + c.createdDate = now.addingTimeInterval(Double(-idx) * 3600) + c.isWriter = idx == 3 + return c + } + + try await Task.sleep(nanoseconds: 500_000_000) + return Page(totalCount: mocks.count, + content: mocks, + info: PageInfo(nextCursor: nil, hasNext: false, size: 20)) + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift new file mode 100644 index 00000000..c8951905 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift @@ -0,0 +1,76 @@ +// +// FetchNoticeList.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol FetchNoticeList { + func execute(meetId: Int, + size: Int?, + cursor: String?) async throws -> Page +} + +public final class FetchNoticeListUseCase: FetchNoticeList { + + private let repo: NoticeRepo + + public init(repo: NoticeRepo) { + self.repo = repo + } + + public func execute(meetId: Int, + size: Int?, + cursor: String?) async throws -> Page { + return try await repo.fetchNoticeList(meetId: meetId, + size: size, + cursor: cursor) + } +} + +// MARK: - Mock UseCase +#if DEV +public final class MockFetchNoticeListUseCase: FetchNoticeList { + public init() {} + + public func execute(meetId: Int, + size: Int?, + cursor: String?) async throws -> Page { + print("✅ [Mock] FetchNoticeList - meetId: \(meetId), size: \(size ?? -1), cursor: \(cursor ?? "nil")") + + // 모임공지 3개 + 시스템 2개 + let now = Date() + var notices: [Notice] = [] + for index in 1...3 { + notices.append(Notice( + noticeId: index, + version: 1, + meetId: meetId, + type: .custom, + content: "11/28일 모임 18:00 → 20:00 변경되었습니다. 날씨이슈로 인해서 부득이하게 변경했습니다! (#\(index))", + writer: UserInfo(id: 1, name: "모임장 매튜", imagePath: nil), + isPinned: index == 1, + createdAt: now.addingTimeInterval(Double(-index) * 86400) + )) + } + for index in 4...5 { + notices.append(Notice( + noticeId: index, + version: 1, + meetId: meetId, + type: .system, + content: "[시스템 공지] 새 멤버가 참여했어요. (#\(index))", + isPinned: false, + createdAt: now.addingTimeInterval(Double(-index) * 86400) + )) + } + + try await Task.sleep(nanoseconds: 500_000_000) + return Page(totalCount: notices.count, + content: notices, + info: PageInfo(nextCursor: nil, hasNext: false, size: 20)) + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/TogglePinNotice.swift b/Modules/Domain/Sources/UseCases/Notice/TogglePinNotice.swift new file mode 100644 index 00000000..e9d2dc49 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/TogglePinNotice.swift @@ -0,0 +1,50 @@ +// +// TogglePinNotice.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol TogglePinNotice { + // 현재 고정 상태를 받아서, 반대로 토글된 결과 Notice를 반환. + func execute(noticeId: Int, isCurrentlyPinned: Bool) async throws -> Notice +} + +public final class TogglePinNoticeUseCase: TogglePinNotice { + + private let repo: NoticeRepo + + public init(repo: NoticeRepo) { + self.repo = repo + } + + public func execute(noticeId: Int, isCurrentlyPinned: Bool) async throws -> Notice { + if isCurrentlyPinned { + return try await repo.unpinNotice(noticeId: noticeId) + } else { + return try await repo.pinNotice(noticeId: noticeId) + } + } +} + +#if DEV +public final class MockTogglePinNoticeUseCase: TogglePinNotice { + public init() {} + + public func execute(noticeId: Int, isCurrentlyPinned: Bool) async throws -> Notice { + print("✅ [Mock] TogglePinNotice - noticeId: \(noticeId), nowPinned: \(isCurrentlyPinned)") + try await Task.sleep(nanoseconds: 400_000_000) + return Notice( + noticeId: noticeId, + version: 2, + meetId: 0, + type: .custom, + content: "Mock 공지 본문", + isPinned: !isCurrentlyPinned, + createdAt: Date() + ) + } +} +#endif diff --git a/Modules/Domain/Sources/UseCases/Notice/UpdateNotice.swift b/Modules/Domain/Sources/UseCases/Notice/UpdateNotice.swift new file mode 100644 index 00000000..976219b1 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/UpdateNotice.swift @@ -0,0 +1,47 @@ +// +// UpdateNotice.swift +// Domain +// +// Created by CatSlave on 5/26/26. +// + +import Foundation + +public protocol UpdateNotice { + func execute(noticeId: Int, meetId: Int, content: String) async throws -> Notice +} + +public final class UpdateNoticeUseCase: UpdateNotice { + + private let repo: NoticeRepo + + public init(repo: NoticeRepo) { + self.repo = repo + } + + public func execute(noticeId: Int, meetId: Int, content: String) async throws -> Notice { + return try await repo.updateNotice(noticeId: noticeId, + meetId: meetId, + content: content) + } +} + +#if DEV +public final class MockUpdateNoticeUseCase: UpdateNotice { + public init() {} + + public func execute(noticeId: Int, meetId: Int, content: String) async throws -> Notice { + print("✅ [Mock] UpdateNotice - noticeId: \(noticeId), content: \(content)") + try await Task.sleep(nanoseconds: 600_000_000) + return Notice( + noticeId: noticeId, + version: 2, + meetId: meetId, + type: .custom, + content: content, + isPinned: false, + createdAt: Date() + ) + } +} +#endif diff --git a/Mople/Application/DIContainer/AppDIContainer.swift b/Mople/Application/DIContainer/AppDIContainer.swift index 89fc0f18..6eb341f1 100644 --- a/Mople/Application/DIContainer/AppDIContainer.swift +++ b/Mople/Application/DIContainer/AppDIContainer.swift @@ -48,7 +48,8 @@ final class AppDIContainer { return DefaultAppNetWorkService(dataTransferService: transferService) }() - lazy var commonDIContainer = ViewDIContainer(appNetworkService: appNetworkService) + lazy var commonDIContainer = ViewDIContainer(appNetworkService: appNetworkService, + userSession: userSession) /// Domain UseCase에 주입할 사용자 세션 프로바이더 lazy var userSession: UserSessionProvider = DefaultUserSessionProvider() diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift index 3b9d0b05..88041c1a 100644 --- a/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift @@ -81,9 +81,9 @@ extension MeetDetailSceneDIContainer { private func makeFetchMeetDetailUseCase(repo: MeetRepo) -> FetchMeetDetail { #if DEV - return MockDataManager.resolve(FetchMeetDetailUseCase(repo: repo) as FetchMeetDetail, mock: MockFetchMeetDetailUseCase()) + return MockDataManager.resolve(FetchMeetDetailUseCase(repo: repo, session: userSession) as FetchMeetDetail, mock: MockFetchMeetDetailUseCase()) #else - return FetchMeetDetailUseCase(repo: repo) + return FetchMeetDetailUseCase(repo: repo, session: userSession) #endif } diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift index c7a36f2b..02b72872 100644 --- a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift @@ -14,8 +14,14 @@ protocol NoticeSceneDependencies { func makeNoticeListViewController(meetId: Int, isCreator: Bool, coordinator: NoticeFlowCoordination) -> UIViewController - func makeNoticeDetailViewController(noticeId: Int) -> UIViewController - func makeNoticeComposeViewController(meetId: Int) -> UIViewController + func makeNoticeDetailViewController(notice: Notice, + isCreator: Bool, + coordinator: NoticeFlowCoordination) -> UIViewController + func makeNoticeComposeViewController(mode: NoticeComposeViewModel.Mode, + coordinator: NoticeFlowCoordination) -> UIViewController + // 프로필 이미지 확대 — PostDetail의 PhotoBookViewController 패턴 재사용 + func makeProfileImageViewController(title: String?, + imagePath: String?) -> UIViewController } final class NoticeSceneDIContainer: BaseContainer, NoticeSceneDependencies { @@ -39,6 +45,126 @@ final class NoticeSceneDIContainer: BaseContainer, NoticeSceneDependencies { } } +// MARK: - Repo & UseCases +private extension NoticeSceneDIContainer { + + func makeNoticeRepo() -> NoticeRepo { + return DefaultNoticeRepo(networkService: appNetworkService) + } + + // 댓글 수정/삭제는 일반 CommentRepo의 endpoint(/comment/{commentId})를 그대로 사용 가능 + func makeCommentRepo() -> CommentRepo { + return DefaultCommentRepo(networkService: appNetworkService) + } + + func makeReportRepo() -> ReportRepo { + return DefaultReportRepo(networkService: appNetworkService) + } + + // MARK: Notice UseCases + func makeFetchNoticeListUseCase(repo: NoticeRepo) -> FetchNoticeList { + #if DEV + return MockDataManager.resolve(FetchNoticeListUseCase(repo: repo) as FetchNoticeList, + mock: MockFetchNoticeListUseCase()) + #else + return FetchNoticeListUseCase(repo: repo) + #endif + } + + func makeFetchNoticeUseCase(repo: NoticeRepo) -> FetchNotice { + #if DEV + return MockDataManager.resolve(FetchNoticeUseCase(repo: repo) as FetchNotice, + mock: MockFetchNoticeUseCase()) + #else + return FetchNoticeUseCase(repo: repo) + #endif + } + + func makeTogglePinNoticeUseCase(repo: NoticeRepo) -> TogglePinNotice { + #if DEV + return MockDataManager.resolve(TogglePinNoticeUseCase(repo: repo) as TogglePinNotice, + mock: MockTogglePinNoticeUseCase()) + #else + return TogglePinNoticeUseCase(repo: repo) + #endif + } + + func makeCreateNoticeUseCase(repo: NoticeRepo) -> CreateNotice { + #if DEV + return MockDataManager.resolve(CreateNoticeUseCase(repo: repo) as CreateNotice, + mock: MockCreateNoticeUseCase()) + #else + return CreateNoticeUseCase(repo: repo) + #endif + } + + func makeUpdateNoticeUseCase(repo: NoticeRepo) -> UpdateNotice { + #if DEV + return MockDataManager.resolve(UpdateNoticeUseCase(repo: repo) as UpdateNotice, + mock: MockUpdateNoticeUseCase()) + #else + return UpdateNoticeUseCase(repo: repo) + #endif + } + + func makeDeleteNoticeUseCase(repo: NoticeRepo) -> DeleteNotice { + #if DEV + return MockDataManager.resolve(DeleteNoticeUseCase(repo: repo) as DeleteNotice, + mock: MockDeleteNoticeUseCase()) + #else + return DeleteNoticeUseCase(repo: repo) + #endif + } + + func makeFetchNoticeCommentListUseCase(repo: NoticeRepo) -> FetchNoticeCommentList { + #if DEV + return MockDataManager.resolve(FetchNoticeCommentListUseCase(repo: repo, session: userSession) as FetchNoticeCommentList, + mock: MockFetchNoticeCommentListUseCase()) + #else + return FetchNoticeCommentListUseCase(repo: repo, session: userSession) + #endif + } + + func makeCreateNoticeCommentUseCase(repo: NoticeRepo) -> CreateNoticeComment { + #if DEV + return MockDataManager.resolve(CreateNoticeCommentUseCase(repo: repo, session: userSession) as CreateNoticeComment, + mock: MockCreateNoticeCommentUseCase()) + #else + return CreateNoticeCommentUseCase(repo: repo, session: userSession) + #endif + } + + // MARK: Comment UseCases (수정/삭제는 일반 댓글 패턴 재사용) + func makeEditCommentUseCase(repo: CommentRepo) -> EditComment { + #if DEV + return MockDataManager.resolve(EditCommentUseCase(repo: repo, session: userSession) as EditComment, + mock: MockEditCommentUseCase()) + #else + return EditCommentUseCase(repo: repo, session: userSession) + #endif + } + + func makeDeleteCommentUseCase(repo: CommentRepo) -> DeleteComment { + #if DEV + return MockDataManager.resolve(DeleteCommentUseCase(repo: repo) as DeleteComment, + mock: MockDeleteCommentUseCase()) + #else + return DeleteCommentUseCase(repo: repo) + #endif + } + + // MARK: Report UseCase + func makeReportUseCase() -> ReportPost { + let repo = makeReportRepo() + #if DEV + return MockDataManager.resolve(ReportPostUseCase(repo: repo) as ReportPost, + mock: MockReportPostUseCase()) + #else + return ReportPostUseCase(repo: repo) + #endif + } +} + // MARK: - View Factories (SwiftUI + UIHostingController) extension NoticeSceneDIContainer { @@ -46,25 +172,57 @@ extension NoticeSceneDIContainer { func makeNoticeListViewController(meetId: Int, isCreator: Bool, coordinator: NoticeFlowCoordination) -> UIViewController { - let view = NoticeListView( + let repo = makeNoticeRepo() + let viewModel = NoticeListViewModel( meetId: meetId, isCreator: isCreator, - onComposeTap: { [weak coordinator] in - coordinator?.pushComposeView() - } + fetchListUseCase: makeFetchNoticeListUseCase(repo: repo), + togglePinUseCase: makeTogglePinNoticeUseCase(repo: repo), + coordinator: coordinator ) - return UIHostingController(rootView: view) + return UIHostingController(rootView: NoticeListView(viewModel: viewModel)) } @MainActor - func makeNoticeDetailViewController(noticeId: Int) -> UIViewController { - let view = NoticeDetailView(noticeId: noticeId) - return UIHostingController(rootView: view) + func makeNoticeDetailViewController(notice: Notice, + isCreator: Bool, + coordinator: NoticeFlowCoordination) -> UIViewController { + let noticeRepo = makeNoticeRepo() + let commentRepo = makeCommentRepo() + let viewModel = NoticeDetailViewModel( + notice: notice, + isCreator: isCreator, + fetchNoticeUseCase: makeFetchNoticeUseCase(repo: noticeRepo), + fetchCommentsUseCase: makeFetchNoticeCommentListUseCase(repo: noticeRepo), + createCommentUseCase: makeCreateNoticeCommentUseCase(repo: noticeRepo), + editCommentUseCase: makeEditCommentUseCase(repo: commentRepo), + deleteCommentUseCase: makeDeleteCommentUseCase(repo: commentRepo), + reportUseCase: makeReportUseCase(), + deleteNoticeUseCase: makeDeleteNoticeUseCase(repo: noticeRepo), + coordinator: coordinator + ) + return InteractivePopHostingController(rootView: NoticeDetailView(viewModel: viewModel)) + } + + @MainActor + func makeNoticeComposeViewController(mode: NoticeComposeViewModel.Mode, + coordinator: NoticeFlowCoordination) -> UIViewController { + let repo = makeNoticeRepo() + let viewModel = NoticeComposeViewModel( + mode: mode, + createUseCase: makeCreateNoticeUseCase(repo: repo), + updateUseCase: makeUpdateNoticeUseCase(repo: repo), + coordinator: coordinator + ) + return InteractivePopHostingController(rootView: NoticeComposeView(viewModel: viewModel)) } @MainActor - func makeNoticeComposeViewController(meetId: Int) -> UIViewController { - let view = NoticeComposeView(meetId: meetId) - return UIHostingController(rootView: view) + func makeProfileImageViewController(title: String?, + imagePath: String?) -> UIViewController { + let imagePaths = [imagePath].compactMap { $0 } + return commonViewFactory.makePhotoViewController(title: title, + imagePath: imagePaths, + defaultImageType: .user) } } diff --git a/Mople/Application/DIContainer/ViewControllerDI/ViewDependencies.swift b/Mople/Application/DIContainer/ViewControllerDI/ViewDependencies.swift index 5b958edb..92536258 100644 --- a/Mople/Application/DIContainer/ViewControllerDI/ViewDependencies.swift +++ b/Mople/Application/DIContainer/ViewControllerDI/ViewDependencies.swift @@ -23,11 +23,14 @@ protocol ViewDependencies { } final class ViewDIContainer: ViewDependencies { - + private let appNetworkService: AppNetworkService - - init(appNetworkService: AppNetworkService) { + private let userSession: UserSessionProvider + + init(appNetworkService: AppNetworkService, + userSession: UserSessionProvider) { self.appNetworkService = appNetworkService + self.userSession = userSession } } @@ -77,17 +80,17 @@ extension ViewDIContainer { private func makeCreateMeetUseCase(repo: MeetRepo) -> CreateMeet { #if DEV - return MockDataManager.resolve(CreateMeetUseCase(createMeetRepo: repo) as CreateMeet, mock: MockCreateMeetUseCase()) + return MockDataManager.resolve(CreateMeetUseCase(createMeetRepo: repo, session: userSession) as CreateMeet, mock: MockCreateMeetUseCase()) #else - return CreateMeetUseCase(createMeetRepo: repo) + return CreateMeetUseCase(createMeetRepo: repo, session: userSession) #endif } private func makeEditMeetUseCase(repo: MeetRepo) -> EditMeet { #if DEV - return MockDataManager.resolve(EditMeetUseCase(repo: repo) as EditMeet, mock: MockEditMeetUseCase()) + return MockDataManager.resolve(EditMeetUseCase(repo: repo, session: userSession) as EditMeet, mock: MockEditMeetUseCase()) #else - return EditMeetUseCase(repo: repo) + return EditMeetUseCase(repo: repo, session: userSession) #endif } diff --git a/Mople/CommonUI/SwiftUI/CustomNavigationBar.swift b/Mople/CommonUI/SwiftUI/CustomNavigationBar.swift index d4575c06..0a15465b 100644 --- a/Mople/CommonUI/SwiftUI/CustomNavigationBar.swift +++ b/Mople/CommonUI/SwiftUI/CustomNavigationBar.swift @@ -9,17 +9,18 @@ import SwiftUI import Domain // MARK: - Custom Navigation Bar (UIKit TitleNaviBar 스타일) -struct CustomNavigationBar: View { +struct CustomNavigationBar: View { let title: String let isLoading: Bool let onBackTapped: () -> Void - + let trailing: Trailing + var body: some View { VStack(spacing: 0) { // Status Bar 영역 (notchView) Color(uiColor: .bgPrimary) .frame(height: UIScreen.getTopNotchSize()) - + // Navigation Bar (TitleNaviBar 스타일, 56pt 높이) HStack(spacing: 0) { // Left Button Container (40pt) @@ -31,16 +32,16 @@ struct CustomNavigationBar: View { .frame(width: 40, height: 40) .padding(.leading, 20) // mainStackView horizontalEdges inset 20 .disabled(isLoading) // 로딩 중에는 뒤로가기 비활성화 - + Spacer() Text(title) .font(.custom(FontFamily.Pretendard.bold, size: FontStyle.Size.title2)) .foregroundColor(Color(uiColor: .text01)) - + Spacer() - - // Right Button Container (40pt) - 빈 공간으로 타이틀 센터 정렬 - Color.clear + + // Right Button Container (40pt) - 호출 측에서 옵셔널 View 주입 + trailing .frame(width: 40, height: 40) .padding(.trailing, 20) // mainStackView horizontalEdges inset 20 } @@ -51,6 +52,15 @@ struct CustomNavigationBar: View { } } +extension CustomNavigationBar where Trailing == Color { + init(title: String, isLoading: Bool, onBackTapped: @escaping () -> Void) { + self.init(title: title, + isLoading: isLoading, + onBackTapped: onBackTapped, + trailing: Color.clear) + } +} + // MARK: - Loading View /// UIKit DefaultViewController의 indicator와 동일한 스타일 @@ -73,22 +83,27 @@ struct LoadingView: View { // MARK: - Custom Navigation Bar Modifier //onBack 클로저 추가: 커스텀 뒤로가기 동작 지원 (coordinator dismiss 등) -struct CustomNavigationBarModifier: ViewModifier { +//trailing 슬롯 추가: 우상단 액션 버튼 옵셔널 주입 +struct CustomNavigationBarModifier: ViewModifier { let title: String let isLoading: Bool let onBack: (() -> Void)? + let trailing: Trailing @Environment(\.dismiss) private var dismiss func body(content: Content) -> some View { ZStack { VStack(spacing: 0) { - CustomNavigationBar(title: title, isLoading: isLoading) { - if let onBack { - onBack() - } else { - dismiss() - } - } + CustomNavigationBar(title: title, + isLoading: isLoading, + onBackTapped: { + if let onBack { + onBack() + } else { + dismiss() + } + }, + trailing: trailing) content .allowsHitTesting(!isLoading) // 로딩 중 콘텐츠 터치 차단 @@ -107,23 +122,38 @@ struct CustomNavigationBarModifier: ViewModifier { extension View { - func customNavigationBar(title: String, isLoading: Bool = false, onBack: (() -> Void)? = nil) -> some View { - modifier(CustomNavigationBarModifier(title: title, isLoading: isLoading, onBack: onBack)) + func customNavigationBar(title: String, + isLoading: Bool = false, + onBack: (() -> Void)? = nil) -> some View { + modifier(CustomNavigationBarModifier(title: title, + isLoading: isLoading, + onBack: onBack, + trailing: Color.clear)) + } + + func customNavigationBar(title: String, + isLoading: Bool = false, + onBack: (() -> Void)? = nil, + @ViewBuilder trailing: () -> Trailing) -> some View { + modifier(CustomNavigationBarModifier(title: title, + isLoading: isLoading, + onBack: onBack, + trailing: trailing())) } } // MARK: - Preview #Preview { - CustomNavigationBar(title: "타이틀", isLoading: false) { - print("Back button tapped") - } + CustomNavigationBar(title: "타이틀", + isLoading: false, + onBackTapped: { print("Back button tapped") }) } #Preview("With Content") { VStack(spacing: 0) { - CustomNavigationBar(title: "설정", isLoading: false) { - print("Back button tapped") - } - + CustomNavigationBar(title: "설정", + isLoading: false, + onBackTapped: { print("Back button tapped") }) + ScrollView { VStack(spacing: 20) { ForEach(0..<20) { index in diff --git a/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift b/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift new file mode 100644 index 00000000..7fd742e3 --- /dev/null +++ b/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift @@ -0,0 +1,63 @@ +// +// InteractivePopHostingController.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SwiftUI + +// SwiftUI 자식 화면을 AppNaviViewController에 push할 때 사용하는 베이스. +// +// 두 가지 책임: +// +// 1) Edge swipe pop 활성화 +// AppNaviViewController는 navigationBar.isHidden = true 이므로 기본 동작상 +// interactivePopGestureRecognizer가 비활성/동작 불가 상태가 된다. +// viewDidAppear 시점에 명시적으로 활성화하여 좌측 edge swipe로 표준 pop이 동작하도록 보장한다. +// +// 2) 화면 탭 시 키보드 dismiss +// SwiftUI의 .background + .onTapGesture는 자식(TextEditor 등)이 차지한 영역에서는 +// 이벤트가 안 잡혀서 간헐적으로 동작하지 않는 문제가 있다. +// UIKit gesture recognizer를 root view에 달면 더 신뢰성 있게 입력 외 영역의 탭을 +// 잡을 수 있다. cancelsTouchesInView = false라 SwiftUI Button 등 컨트롤은 함께 동작. +// delegate로 UITextView/UITextField 위 탭은 제외(해당 입력 컴포넌트의 포커싱 방해 방지). +// 제네릭 클래스는 @objc 프로토콜 채택을 extension에 두지 못해 본체에서 UIGestureRecognizerDelegate를 채택. +final class InteractivePopHostingController: UIHostingController, UIGestureRecognizerDelegate { + + override func viewDidLoad() { + super.viewDidLoad() + setupDismissKeyboardTap() + } + + // 입력 컴포넌트 위 탭은 키보드 dismiss 비활성 — 해당 컴포넌트가 포커스/커서 처리를 정상 수행하도록. + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch) -> Bool { + guard let touchedView = touch.view else { return true } + if touchedView is UITextView { return false } + if touchedView is UITextField { return false } + return true + } + + private func setupDismissKeyboardTap() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleDismissKeyboardTap)) + tap.cancelsTouchesInView = false + tap.delegate = self + view.addGestureRecognizer(tap) + } + + @objc private func handleDismissKeyboardTap() { + view.endEditing(true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // navi root(첫 화면)일 때는 AppTransition의 modal dismiss 제스처가 처리하므로 + // navigationController.viewControllers.count > 1 일 때만 표준 pop 동작 활성화 + guard let navi = navigationController, navi.viewControllers.count > 1 else { return } + navi.interactivePopGestureRecognizer?.isEnabled = true + navi.interactivePopGestureRecognizer?.delegate = nil + } +} + diff --git a/Mople/CommonUI/Uikit/Modal/DefaultPickerView.swift b/Mople/CommonUI/Uikit/Modal/DefaultPickerView.swift index 1da1af63..f335f2c8 100644 --- a/Mople/CommonUI/Uikit/Modal/DefaultPickerView.swift +++ b/Mople/CommonUI/Uikit/Modal/DefaultPickerView.swift @@ -77,7 +77,7 @@ extension DefaultPickerView { return (view as? UILabel) ?? { let newLabel = UILabel() newLabel.textAlignment = .center - newLabel.textColor = .black + newLabel.textColor = .tertiaryText newLabel.font = FontStyle.Title2.semiBold return newLabel }() diff --git a/Mople/Infrastructure/Network/Service/NetworkService.swift b/Mople/Infrastructure/Network/Service/NetworkService.swift index 93ac2ab4..f4a3ab29 100644 --- a/Mople/Infrastructure/Network/Service/NetworkService.swift +++ b/Mople/Infrastructure/Network/Service/NetworkService.swift @@ -39,6 +39,9 @@ final class DefaultNetworkService { let statusCode = result.response.statusCode let data = result.data + // 응답 상태코드 + 데이터 로깅 (Rx→async 리팩토링 때 누락됐던 부분 복원) + logger.log(response: result.response, data: data) + switch statusCode { case 200...299: return data @@ -118,6 +121,11 @@ final class DefaultNetworkErrorLogger: NetworkErrorLogger { } } + func log(response: HTTPURLResponse, data: Data?) { + print("response: \(response.statusCode) \(response.url?.absoluteString ?? "")") + log(responseData: data) + } + func log(responseData data: Data?) { guard let data = data else { return } if let dataDict = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { diff --git a/Mople/Infrastructure/Notification/NotificationManager.swift b/Mople/Infrastructure/Notification/NotificationManager.swift index d75da00b..510695cc 100644 --- a/Mople/Infrastructure/Notification/NotificationManager.swift +++ b/Mople/Infrastructure/Notification/NotificationManager.swift @@ -13,6 +13,7 @@ import Data typealias MeetPayload = NotificationManager.Payload typealias PlanPayload = NotificationManager.Payload typealias ReviewPayload = NotificationManager.Payload +typealias NoticePayload = NotificationManager.Payload final class NotificationManager { @@ -26,6 +27,7 @@ final class NotificationManager { case is Meet.Type: return .meet case is Plan.Type: return .plan case is Review.Type: return .review + case is Notice.Type: return .notice default: return .init("Default") } } @@ -70,6 +72,10 @@ final class NotificationManager { func addReviewObservable() -> Observable { return makeObservable(name: .review) } + + func addNoticeObservable() -> Observable { + return makeObservable(name: .notice) + } // MARK: - Meet Participation func postParticipating(_ payload: ParticipationPayload, from sender: Any) { @@ -127,6 +133,9 @@ extension Notification.Name { /// 후기 static let review = Notification.Name(String(describing: Review.self)) + + /// 공지 + static let notice = Notification.Name(String(describing: Notice.self)) /// 일정 참여 static let participating = Notification.Name("participating") diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift index 7bf89c71..99b35382 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift @@ -24,7 +24,7 @@ protocol MeetDetailCoordination: AnyObject { type: PostType) func pushMemberListView() func presentNoticeListView(meetId: Int, isCreator: Bool) - func presentNoticeDetailView(noticeId: Int) + func presentNoticeDetailView(notice: Notice, isCreator: Bool) func endFlow() } @@ -164,9 +164,9 @@ extension MeetDetailSceneCoordinator { } // 공지 미리보기 카드 → 공지 상세 (modal present) - func presentNoticeDetailView(noticeId: Int) { + func presentNoticeDetailView(notice: Notice, isCreator: Bool) { let coordinator = dependencies.makeNoticeFlowCoordinator( - entry: .detail(noticeId: noticeId) + entry: .detail(notice: notice, isCreator: isCreator) ) start(coordinator: coordinator) self.present(coordinator.navigationController) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/SubView/MeetSetup/MeetSetupViewReactor.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/SubView/MeetSetup/MeetSetupViewReactor.swift index 094321fd..f867cb35 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/SubView/MeetSetup/MeetSetupViewReactor.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/SubView/MeetSetup/MeetSetupViewReactor.swift @@ -120,12 +120,12 @@ final class MeetSetupViewReactor: Reactor, LifeCycleLoggable { // MARK: - Set Meet extension MeetSetupViewReactor { /// 모임 정보 설정 및 호스트 여부 확인 + /// meet.isCreator는 FetchMeetDetail / CreateMeet / EditMeet UseCase에서 + /// verifyCreator로 채워지므로 단일 진실 소스를 그대로 사용한다 + /// (예전엔 UserInfoStorage를 들춰서 직접 비교했음 — 중복 로직 제거). private func setMeetInfo(_ meet: Meet) -> Observable { - let userID = UserInfoStorage.shared.userInfo?.id - - let checkHost = Observable.just(Mutation.checkHost(userID == meet.creatorId)) + let checkHost = Observable.just(Mutation.checkHost(meet.isCreator)) let updateMeet = Observable.just(Mutation.updateMeet(meet)) - return Observable.concat([checkHost, updateMeet]) } } diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 65e9f6f8..6148e661 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -34,11 +34,11 @@ final class MeetDetailViewController: TitleNaviViewController, View { private let naviTitleView = MeetDetailNaviTitleView() // 네비 우측 확성기 버튼 (rightButton(햄버거) 왼쪽에 배치) + // SF Symbol에서 디자이너가 export한 .meetMegaphone 에셋으로 교체 private let megaphoneButton: UIButton = { let btn = UIButton() - let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold) - btn.setImage(UIImage(systemName: "megaphone.fill", withConfiguration: config), for: .normal) - btn.tintColor = .text01 + btn.setImage(.meetMegaphone, for: .normal) + btn.imageView?.contentMode = .scaleAspectFit return btn }() @@ -359,6 +359,12 @@ extension MeetDetailViewController { .bind(to: reactor.action) .disposed(by: disposeBag) + // 공지 핀 토글 알림 → 모임상세 pinnedNotice 갱신 + NotificationManager.shared.addNoticeObservable() + .map { Reactor.Action.applyNotice($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + NotificationManager.shared.addObservable(name: .midnightUpdate) .map { _ in Reactor.Action.refresh } .bind(to: reactor.action) @@ -446,11 +452,17 @@ extension MeetDetailViewController { // 헤더 높이가 바뀔 수 있으므로 sticky 상태도 재계산 applyHide(currentHideAmount) - // 확성기 파란 점 배지 - megaphoneBadge.isHidden = !hasNotice + // 확성기 파란 점 배지 — 향후 개발 예정 (서버에 unread 신호가 들어오면 활성화) + megaphoneBadge.isHidden = true // 모임장 + 공지 없음 → 작성 유도 툴팁 - composeTooltipView.isHidden = !(meet.isCreator && !hasNotice) + // 단 모임별로 "첫 진입 1회"만 표시 (UserDefaults에 seen 플래그 기록). + let shouldShowTooltip = meet.isCreator && !hasNotice + && !NoticeTooltipMemory.hasSeen(meetId: meet.meetSummary?.id) + composeTooltipView.isHidden = !shouldShowTooltip + if shouldShowTooltip { + NoticeTooltipMemory.markSeen(meetId: meet.meetSummary?.id) + } } // MARK: - Sticky Header diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift index fc11d9f3..52038766 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift @@ -45,6 +45,7 @@ final class MeetDetailViewReactor: Reactor, LifeCycleLoggable { case flow(Flow) case loading(Loading) case editMeet(MeetPayload) + case applyNotice(NoticePayload) case catchError(MeetDetailError) } @@ -111,6 +112,8 @@ final class MeetDetailViewReactor: Reactor, LifeCycleLoggable { return fetchMeetInfo() case let .editMeet(payload): return handleMeetPayload(with: payload) + case let .applyNotice(payload): + return handleNoticePayload(with: payload) case .refresh: return resetPost() case let .loading(action): @@ -198,8 +201,11 @@ extension MeetDetailViewReactor { isCreator: meet.isCreator) case .openNoticeDetail: // 미리보기 카드 → 공지 상세 진입 (pinnedNotice가 있을 때만) - guard let noticeId = currentState.meet?.pinnedNotice?.noticeId else { return .empty() } - coordinator?.presentNoticeDetailView(noticeId: noticeId) + guard let meet = currentState.meet, + let notice = meet.pinnedNotice, + notice.noticeId != nil else { return .empty() } + coordinator?.presentNoticeDetailView(notice: notice, + isCreator: meet.isCreator) } return .empty() @@ -213,7 +219,25 @@ extension MeetDetailViewReactor { guard case .updated(let meet) = payload else { return .empty() } return .just(.setMeetInfo(meet: meet)) } - + + /// 공지 핀 토글 알림 수신 — 모임상세 화면의 pinnedNotice 갱신. + /// - 핀(isPinned == true): 기존 pinnedNotice 유무 무관하게 무조건 새 공지로 교체 + /// - 해제(isPinned == false): + /// · 메인의 pinnedNotice가 해제된 공지와 동일하면 → nil 처리 + /// · 다른 공지면 → 변경 없음 (다른 공지가 메인을 차지하고 있는 상태 유지) + private func handleNoticePayload(with payload: NoticePayload) -> Observable { + guard case .updated(let notice) = payload, + let meet = currentState.meet else { return .empty() } + + if notice.isPinned { + return .just(.setMeetInfo(meet: meet.with(pinnedNotice: notice))) + } else { + // 해제: 메인 공지가 같은 noticeId일 때만 nil 처리 + guard meet.pinnedNotice?.noticeId == notice.noticeId else { return .empty() } + return .just(.setMeetInfo(meet: meet.with(pinnedNotice: nil))) + } + } + private func resetPost() -> Observable { return fetchMeetInfo() } diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/NoticeTooltipMemory.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/NoticeTooltipMemory.swift new file mode 100644 index 00000000..6bc9a914 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/NoticeTooltipMemory.swift @@ -0,0 +1,25 @@ +// +// NoticeTooltipMemory.swift +// Mople +// +// Created by CatSlave on 6/11/26. +// + +import Foundation + +// 모임 상세의 "공지를 작성해보세요" 작성 유도 툴팁 표시 이력 저장소. +// 모임별로 1회만 노출되도록 UserDefaults에 meetId별 seen 플래그 기록. +enum NoticeTooltipMemory { + + private static let keyPrefix = "com.mople.noticeTooltipSeen." + + static func hasSeen(meetId: Int?) -> Bool { + guard let meetId else { return false } + return UserDefaults.standard.bool(forKey: keyPrefix + String(meetId)) + } + + static func markSeen(meetId: Int?) { + guard let meetId else { return } + UserDefaults.standard.set(true, forKey: keyPrefix + String(meetId)) + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift index 7696e4dd..0f46f8ba 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift @@ -12,12 +12,21 @@ import Domain // MeetDetail에서 두 가지 진입점(미리보기 카드 → 상세, 확성기 → 리스트)을 가지며 // 리스트 안에서 작성 화면(모임장 전용)으로 push. enum NoticeFlowEntry { - case detail(noticeId: Int) + // 공지 GET 단건 API가 없어 진입 시 Notice 객체 스냅샷을 함께 넘긴다. + // isCreator는 page menu(수정/삭제 vs 신고) 분기에 사용. + case detail(notice: Notice, isCreator: Bool) case list(meetId: Int, isCreator: Bool) } protocol NoticeFlowCoordination: AnyObject { + func pushNoticeDetail(notice: Notice, isCreator: Bool) func pushComposeView() + // 공지 수정 화면 진입. Compose 화면을 .edit 모드로 재사용. + func pushEditView(notice: Notice) + // 프로필 이미지 확대 (PhotoBookViewController) — present modal. + func presentProfileImage(name: String?, imagePath: String?) + // 현재 push된 화면 한 단계 pop (Compose 작성/수정 완료 후, Detail 삭제 후 등) + func popCurrentView() func endFlow() } @@ -37,8 +46,10 @@ final class NoticeFlowCoordinator: BaseCoordinator, NoticeFlowCoordination { override func start() { switch entry { - case .detail(let noticeId): - let vc = dependencies.makeNoticeDetailViewController(noticeId: noticeId) + case .detail(let notice, let isCreator): + let vc = dependencies.makeNoticeDetailViewController(notice: notice, + isCreator: isCreator, + coordinator: self) self.pushWithTracking(vc, animated: false) case .list(let meetId, let isCreator): @@ -50,13 +61,47 @@ final class NoticeFlowCoordinator: BaseCoordinator, NoticeFlowCoordination { } } -// MARK: - Compose Flow (리스트 → 작성) +// MARK: - Push Flows extension NoticeFlowCoordinator { + // 리스트 셀 탭 → 상세 + func pushNoticeDetail(notice: Notice, isCreator: Bool) { + let vc = dependencies.makeNoticeDetailViewController(notice: notice, + isCreator: isCreator, + coordinator: self) + self.pushWithTracking(vc, animated: true) + } + + // 리스트 작성 버튼 → 작성 (create 모드) func pushComposeView() { guard case .list(let meetId, _) = entry else { return } - let vc = dependencies.makeNoticeComposeViewController(meetId: meetId) + let vc = dependencies.makeNoticeComposeViewController(mode: .create(meetId: meetId), + coordinator: self) self.pushWithTracking(vc, animated: true) } + + // 상세 메뉴 "공지 수정" → 작성 화면 재사용 (edit 모드, content prefill) + func pushEditView(notice: Notice) { + let vc = dependencies.makeNoticeComposeViewController(mode: .edit(notice: notice), + coordinator: self) + self.pushWithTracking(vc, animated: true) + } +} + +// MARK: - Present +extension NoticeFlowCoordinator { + // 프로필 이미지 확대 — PhotoBookViewController modal present + func presentProfileImage(name: String?, imagePath: String?) { + let vc = dependencies.makeProfileImageViewController(title: name, + imagePath: imagePath) + self.presentWithTracking(vc) + } +} + +// MARK: - Pop +extension NoticeFlowCoordinator { + func popCurrentView() { + self.navigationController.popViewController(animated: true) + } } // MARK: - End Flow diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift index 76bf5be1..dece6ff2 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift @@ -6,23 +6,98 @@ // import SwiftUI +import Domain -// Phase 1 placeholder. 공지 리스트 화면의 작성 버튼에서만 진입. 모임장만. +// 공지 작성 화면. 모임장만 진입 가능 (리스트의 우상단 연필 아이콘). +// 키보드 처리: +// - 화면 빈 영역 탭 시 키보드 내림 (포커스 해제) +// - 키보드 올라오면 "작성 완료" 버튼이 키보드 위로 따라옴 (SwiftUI 기본 keyboard avoidance) +// - TextEditor는 남는 공간 전체를 차지하며 콘텐츠가 길어지면 자체 스크롤 +// - 본문 길이는 500자에서 컷 (붙여넣기로 들어와도 prefix로 자름) struct NoticeComposeView: View { - let meetId: Int + @StateObject private var viewModel: NoticeComposeViewModel + @FocusState private var editorFocused: Bool + + init(viewModel: NoticeComposeViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } var body: some View { - VStack(spacing: 12) { - Text("공지 작성 화면 (구현 예정)") - .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) - .foregroundColor(Color(uiColor: .text01)) - Text("meetId: \(meetId)") - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) - .foregroundColor(Color(uiColor: .text03)) + VStack(spacing: 0) { + editor + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 16) + submitButton } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(uiColor: .bgPrimary)) - .customNavigationBar(title: "공지 작성") + // 키보드 dismiss는 InteractivePopHostingController의 UIKit tap recognizer가 담당. + // SwiftUI .background+.onTapGesture는 자식 영역에서 간헐적으로 안 잡히는 문제가 있어 사용 안 함. + .customNavigationBar(title: viewModel.navigationTitle, + isLoading: viewModel.isSubmitting, + onBack: { viewModel.dismissFlow() }) + .onAppear { editorFocused = true } + .alert("오류", isPresented: $viewModel.showError) { + Button("확인", role: .cancel) {} + } message: { + Text(viewModel.errorMessage) + } + } + + private var editor: some View { + ZStack(alignment: .topLeading) { + // 입력 박스 배경 + RoundedRectangle(cornerRadius: 8) + .fill(Color(uiColor: .bgInput)) + + // Placeholder — 입력이 비었을 때만 표시. allowsHitTesting(false)로 TextEditor 탭 방해 금지. + if viewModel.content.isEmpty { + Text("멤버들에게 공지를 남겨보세요.") + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text03)) + .padding(.horizontal, 16) + .padding(.top, 16) + .allowsHitTesting(false) + } + + // 실제 입력. maxHeight: .infinity로 두면 키보드 avoidance에 따라 자동으로 줄어듦. + TextEditor(text: $viewModel.content) + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text01)) + .focused($editorFocused) + .scrollContentBackground(.hidden) + .scrollIndicators(.visible) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .onChange(of: viewModel.content) { newValue in + // 500자 초과 시 자름 (붙여넣기 대응) + if newValue.count > viewModel.maxLength { + viewModel.content = String(newValue.prefix(viewModel.maxLength)) + } + } + } + // editor가 가용 공간 전체를 채우게 → button이 키보드 따라 올라오면 editor가 자동 축소 + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var submitButton: some View { + Button(action: { + editorFocused = false + viewModel.submit() + }) { + Text(viewModel.submitButtonTitle) + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: viewModel.canSubmit ? .primaryText : .disableText)) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(Color(uiColor: viewModel.canSubmit ? .appPrimary : .disablePrimary)) + .cornerRadius(8) + } + .disabled(!viewModel.canSubmit) + .padding(.horizontal, 20) + // 키보드 안 떴을 때 safeArea 아래 약간 띄움, 떴을 때는 avoidance가 위로 밀어줌 + .padding(.bottom, 8) } } diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift new file mode 100644 index 00000000..972da848 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift @@ -0,0 +1,118 @@ +// +// NoticeComposeViewModel.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain + +// 공지 작성/수정 공용 화면. +// mode에 따라 create vs edit 분기, 등록 시점에 다른 UseCase 호출. +@MainActor +final class NoticeComposeViewModel: ObservableObject { + + // MARK: - Mode + enum Mode { + case create(meetId: Int) + case edit(notice: Notice) + } + + // MARK: - State + @Published var content: String = "" + @Published var isSubmitting = false + @Published var showError = false + @Published var errorMessage = "" + @Published var didSubmit = false + + let mode: Mode + let maxLength: Int = 500 + + var canSubmit: Bool { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty && trimmed.count <= maxLength && !isSubmitting + } + + // 화면 타이틀 / 등록 버튼 텍스트는 mode 분기 + var navigationTitle: String { + switch mode { + case .create: return "공지 작성하기" + case .edit: return "공지 수정" + } + } + var submitButtonTitle: String { + switch mode { + case .create: return "작성 완료" + case .edit: return "수정 완료" + } + } + + // MARK: - Dependencies + private let createUseCase: CreateNotice + private let updateUseCase: UpdateNotice + private weak var coordinator: NoticeFlowCoordination? + + init(mode: Mode, + createUseCase: CreateNotice, + updateUseCase: UpdateNotice, + coordinator: NoticeFlowCoordination?) { + self.mode = mode + self.createUseCase = createUseCase + self.updateUseCase = updateUseCase + self.coordinator = coordinator + + // edit 모드는 기존 본문을 prefill + if case .edit(let notice) = mode { + self.content = notice.content ?? "" + } + } + + // MARK: - Submit + func submit() { + let text = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSubmitting else { return } + + isSubmitting = true + Task { [weak self] in + guard let self else { return } + defer { self.isSubmitting = false } + do { + // 수정 시 갱신된 Notice를 상세 화면에 전달하기 위해 결과를 보관 + var updatedNotice: Notice? + switch self.mode { + case .create(let meetId): + _ = try await self.createUseCase.execute(meetId: meetId, content: text) + case .edit(let notice): + guard let noticeId = notice.noticeId, let meetId = notice.meetId else { return } + updatedNotice = try await self.updateUseCase.execute(noticeId: noticeId, + meetId: meetId, + content: text) + } + self.didSubmit = true + // 리스트 reload 트리거 + (수정 시) 상세 본문 즉시 갱신용 payload 전달 + NotificationCenter.default.post( + name: .noticeUpdated, + object: nil, + userInfo: updatedNotice.map { ["notice": $0] } + ) + // 작성/수정 완료 후엔 modal 전체 종료가 아니라 한 단계 pop만 (list/detail 복귀) + self.coordinator?.popCurrentView() + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } + + // 뒤로가기 — Compose는 항상 push로 들어왔으므로 pop만 (modal 전체 종료가 아님) + func dismissFlow() { + coordinator?.popCurrentView() + } +} + +// MARK: - Notification.Name +// 공지 생성/수정/삭제 후 발행. 리스트 화면이 구독하여 reload. +extension Notification.Name { + static let noticeUpdated = Notification.Name("com.mople.noticeUpdated") +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift index a0454403..d4384516 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -6,23 +6,437 @@ // import SwiftUI +import Domain +import Kingfisher -// Phase 1 placeholder. 미리보기 카드에서 진입. +// 공지 상세. type에 따라 두 모드: +// - .custom (모임공지): 본문 카드 + 댓글 섹션 + 하단 입력바 +// - .system (시스템): 본문만 — 작성자 = "Mople" + 앱 로고, 댓글/입력바 없음 +// 좋아요 기능은 백엔드 API 미구현이라 UI에서 제외. +// 우상단 점 세개 메뉴: 모임장이면 "공지 수정/삭제", 모임원이면 "신고하기". struct NoticeDetailView: View { - let noticeId: Int + @StateObject private var viewModel: NoticeDetailViewModel + @FocusState private var inputFocused: Bool + // 현재 키보드 높이 — 입력바를 직접 올리기 위해 추적 + @State private var keyboardHeight: CGFloat = 0 + // iOS 18: 스크롤 위치를 offset 단위로 직접 제어 (규칙2 보정/원복용) + @State private var scrollPosition = ScrollPosition() + // iOS 18 onScrollGeometryChange로 신뢰성 있게 측정한 값들 + @State private var contentHeight: CGFloat = 0 // 콘텐츠 전체 높이 + @State private var fullContainerHeight: CGFloat = 0 // 키보드 내려가 있을 때의 뷰포트 높이 + @State private var currentOffsetY: CGFloat = 0 // 현재 스크롤 위치 + @State private var savedOffsetY: CGFloat? // 규칙2로 올리기 전 위치 (내릴 때 원복) + + // 입력바를 키보드 위 10pt 띄우기 위한 하단 inset (홈 인디케이터 safe area 보정) + // 일정/리뷰의 threshold(10pt) 간격과 동일. + private var keyboardBottomInset: CGFloat { + guard keyboardHeight > 0 else { return 0 } + return max(0, keyboardHeight - UIScreen.getNotchSize() + 10) + } + + init(viewModel: NoticeDetailViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + private var isSystem: Bool { viewModel.notice.type == .system } + private var navigationTitle: String { isSystem ? "시스템" : "상세보기" } var body: some View { - VStack(spacing: 12) { - Text("공지 상세 화면 (구현 예정)") - .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + // 입력바를 스크롤뷰 '바깥' 형제로 두는 게 핵심 — + // safeAreaInset/스크롤 내부에 두면 포커스 시 SwiftUI가 자동 스크롤해 위 뷰가 사라진다. + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + noticeBody + if !isSystem { + Color(uiColor: .bgSecondary) + .frame(height: 8) + commentSection + } + } + } + .scrollPosition($scrollPosition) + // iOS 18: 콘텐츠/뷰포트/현재 오프셋을 정확히 측정 (규칙1·2 판단/보정용) + .onScrollGeometryChange(for: ScrollMetrics.self) { geo in + ScrollMetrics(content: geo.contentSize.height, + container: geo.containerSize.height, + offsetY: geo.contentOffset.y) + } action: { _, m in + contentHeight = m.content + currentOffsetY = m.offsetY + // 키보드가 내려가 있을 때의 '전체' 뷰포트 높이를 기준값으로 저장 + if keyboardHeight == 0 { fullContainerHeight = m.container } + } + // 당겨서 새로고침 — 공지 본문 + 댓글 둘 다 fresh하게 (병렬). + // ⚠️ isLoading을 켜면 그 @Published 변경이 뷰 body를 재평가시켜 .refreshable task가 취소되고, + // 취소된 요청이 에러 alert로 표시된다. → 새로고침은 전체 로딩 오버레이(isLoading)를 끈다. + .refreshable { + async let notice: Void = viewModel.loadNotice() + async let comments: Void = viewModel.loadComments(showLoadingIndicator: false) + _ = await (notice, comments) + } + + if !isSystem { + inputBar + } + } + // SwiftUI 기본 키보드 회피(자동 스크롤/축소)를 끄고, 입력바만 키보드 위 10pt로 직접 올린다. + // → 입력바가 스크롤 밖이라 포커스해도 위 콘텐츠가 스크롤되지 않음(규칙1: 작으면 그대로). + .padding(.bottom, keyboardBottomInset) + .background(Color(uiColor: .bgPrimary)) + .ignoresSafeArea(.keyboard, edges: .bottom) + // 키보드 dismiss는 InteractivePopHostingController의 UIKit tap recognizer가 담당 + .customNavigationBar(title: navigationTitle, + isLoading: viewModel.isLoading, + trailing: { trailingMenuButton }) + .alert("오류", isPresented: $viewModel.showError) { + Button("확인", role: .cancel) {} + } message: { + Text(viewModel.errorMessage) + } + // 수정 진입 시 자동으로 입력창 포커스 — 일정/리뷰처럼 키보드/전송버튼 즉시 노출 + .onChange(of: viewModel.writeMode) { _, mode in + if case .edit = mode { inputFocused = true } + } + // 입력창 바깥 탭으로 키보드가 내려가면 수정 모드 취소 — 일정/리뷰의 '바깥 탭 취소'와 동일 + // (전송 중에는 취소하지 않아 수정 제출 흐름과 충돌하지 않음) + .onChange(of: inputFocused) { _, focused in + if !focused, viewModel.isEditingComment, !viewModel.isSubmitting { + viewModel.cancelCommentEdit() + } + } + // 키보드 등장: 입력바를 올림(공통). 콘텐츠가 뷰포트보다 클 때만(규칙2) 스크롤도 키보드만큼 올린다. + // 측정값(contentHeight/fullContainerHeight)이 iOS 18 onScrollGeometryChange 기반이라 작은 콘텐츠에서 오발사 없음(규칙1). + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { note in + guard let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let inset = max(0, frame.height - UIScreen.getNotchSize() + 10) + let isScrollable = contentHeight > fullContainerHeight + 1 + + withAnimation(.easeOut(duration: 0.25)) { keyboardHeight = frame.height } + + guard isScrollable else { return } // 규칙1: 콘텐츠 작으면 스크롤 그대로 + if savedOffsetY == nil { savedOffsetY = currentOffsetY } + let target = (savedOffsetY ?? currentOffsetY) + inset + // 뷰포트가 줄어든(=maxY가 커진) 뒤에 스크롤해야 끝까지 도달한다. + // 같은 타이밍에 호출하면 '줄기 전' 작은 maxY에 clamp돼 덜 올라가서 마지막 댓글이 잘린다 → 한 틱 미룸. + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.25)) { scrollPosition.scrollTo(y: target) } + } + } + // 키보드 숨김: 입력바 원위치 + (규칙2로 올렸던 경우) 저장한 스크롤 위치로 원복 + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + withAnimation(.easeOut(duration: 0.25)) { keyboardHeight = 0 } + if let saved = savedOffsetY { + withAnimation(.easeOut(duration: 0.25)) { scrollPosition.scrollTo(y: saved) } + savedOffsetY = nil + } + } + } + + // MARK: - 우상단 메뉴 버튼 (시스템 공지에는 메뉴 노출 X) + @ViewBuilder + private var trailingMenuButton: some View { + if !isSystem { + Button(action: { viewModel.showPageMenu() }) { + Image(.blackMenu) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .frame(width: 40, height: 40) + } + } + + // MARK: - 본문 (Header + Content) + private var noticeBody: some View { + VStack(alignment: .leading, spacing: 16) { + authorHeader + Text(viewModel.notice.content ?? "") + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) .foregroundColor(Color(uiColor: .text01)) - Text("noticeId: \(noticeId)") - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .bgPrimary)) + } + + private var authorHeader: some View { + HStack(spacing: 8) { + authorAvatar + VStack(alignment: .leading, spacing: 2) { + Text(authorName) + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text01)) + HStack(spacing: 4) { + if let date = viewModel.notice.createdAt { + Text(NoticeDateFormatter.absolute(from: date)) + Text("·") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) + Text(NoticeDateFormatter.relative(from: date)) + } + } + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body2)) .foregroundColor(Color(uiColor: .text03)) + } + Spacer(minLength: 0) + } + } + + // 시스템 공지는 Mople 앱 로고. 일반 공지는 백엔드 writer의 프로필 이미지를 Kingfisher로 로딩. + // writer 이미지가 없으면 기본 프로필(.defaultUser)로 폴백. + @ViewBuilder + private var authorAvatar: some View { + if isSystem { + Image(.logo) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Group { + if let path = viewModel.notice.writer?.imagePath, let url = URL(string: path) { + KFImage(url) + .placeholder { Image(.defaultUser).resizable().scaledToFill() } + .resizable() + .scaledToFill() + } else { + Image(.defaultUser).resizable().scaledToFill() + } + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + } + } + + // 시스템 공지는 "Mople", 일반 공지는 작성자 닉네임(없으면 "모임공지" 폴백) + private var authorName: String { + if isSystem { return "Mople" } + return viewModel.notice.writer?.name ?? "모임공지" + } + + // MARK: - 댓글 섹션 (custom 전용) + private var commentSection: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("댓글") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text01)) + Spacer() + Text("\(viewModel.totalCount)개") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text03)) + } + .padding(.horizontal, 20) + .padding(.top, 28) + .padding(.bottom, 8) + + // 새 댓글 작성 중 로딩 표시 (일정/리뷰의 로딩 셀과 동일하게 상단에 노출, 수정 중에는 표시 안 함) + if viewModel.isSubmitting && !viewModel.isEditingComment { + submittingRow + } + + ForEach(viewModel.comments, id: \.id) { comment in + NoticeCommentRow( + comment: comment, + onProfileTap: { + viewModel.tapProfile(name: comment.writerName, + imagePath: comment.writerThumbnailPath) + }, + onMenuTap: { + viewModel.showCommentMenu(for: comment) + } + ) + // 마지막(가장 오래된) 댓글이 보이면 다음 페이지 로드 + .onAppear { viewModel.loadMoreIfNeeded(currentItem: comment) } + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(uiColor: .bgPrimary)) - .customNavigationBar(title: "공지 상세") + } + + // 댓글 작성 중 로딩 셀 + private var submittingRow: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 20) + } + + // MARK: - 입력바 (ChatingTextFieldView 레이아웃 SwiftUI 재구현, 멘션 제외) + // 박스형 입력(.bgInput, cornerRadius 8) + 우측 52pt 폭의 send 영역 + (edit 모드 시) 상단 라벨. + // ChatingTextFieldView와 동일하게 키보드 떠있을 때(inputFocused == true)만 send 버튼 노출. + private var inputBar: some View { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isEditingComment { + editLabelRow + } + + HStack(alignment: .bottom, spacing: 0) { + inputBox + if inputFocused { + sendButton + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + // 입력바는 자기 콘텐츠 높이로 고정(hug) — 내부 TextEditor가 greedy해서 세로로 늘어나는 것을 막는다. + // 이렇게 해야 남는 세로 공간을 ScrollView가 가져가 입력바 위 빈 공간이 안 생긴다. + .fixedSize(horizontal: false, vertical: true) + // 하단은 ChatingTextFieldView와 동일하게 safeArea만 — 추가 padding 없음 + .background(Color(uiColor: .bgPrimary)) + } + + // "댓글 수정중" 뱃지 (UIKit editLabel: 25x70, cornerRadius 4, bgSecondary, Body2.medium, text03) + // 일정/리뷰 댓글과 동일하게 라벨만 노출 — 취소는 입력창 바깥 탭으로 처리(디자이너 스펙에 취소 버튼 없음) + private var editLabelRow: some View { + HStack(spacing: 8) { + Text("댓글 수정중") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text03)) + .frame(width: 70, height: 25) + .background(Color(uiColor: .bgSecondary)) + .cornerRadius(4) + Spacer(minLength: 0) + } + } + + // DefaultTextView 박스: bgInput + cornerRadius 8 + 내부 padding (좌우 8, 상하 18) + // TextEditor는 UIKit UITextView 기반이라 ChatingTextFieldView와 동일하게 + // 줄바꿈/자동 높이 확장/초과 시 자체 스크롤 동작이 매칭됨. + // 높이는 minHeight(1줄) ~ maxHeight(4줄) 범위에서 콘텐츠에 맞춰 자동. + private var inputBox: some View { + // Body1.regular(=14pt) lineHeight ≈ 14 * 1.4 = 19.6pt 가정. + // ChatingTextFieldView의 maxTextLine 4 매핑 → 4줄까지 늘어나고 그 이상은 내부 스크롤. + let lineHeight: CGFloat = 20 + let minHeight: CGFloat = lineHeight + let maxHeight: CGFloat = lineHeight * 4 + + return ZStack(alignment: .topLeading) { + if viewModel.inputText.isEmpty { + Text("댓글을 입력해주세요") + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text04)) + .padding(.horizontal, 8) + .padding(.vertical, 18) + .allowsHitTesting(false) + } + TextEditor(text: $viewModel.inputText) + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text01)) + .tint(Color(uiColor: .text02)) + .focused($inputFocused) + .scrollContentBackground(.hidden) + .frame(minHeight: minHeight, maxHeight: maxHeight) + .fixedSize(horizontal: false, vertical: true) + // TextEditor 내부 inset(약 ~5pt)을 빼고 ChatingTextFieldView padding(8/18)에 맞춤 + .padding(.horizontal, 3) + .padding(.vertical, 13) + } + .background(Color(uiColor: .bgInput)) + .cornerRadius(8) + } + + // sendButton: ChatingTextFieldView 정확 매핑 + // - 전체 폭 52 + // - 아이콘 영역: leading 12, trailing 0, bottom 6, height = width (1:1 정사각형 = 40x40) + private var sendButton: some View { + Button(action: { + inputFocused = false + viewModel.submitComment() + }) { + Color.clear + .overlay(alignment: .bottom) { + Image(viewModel.canSubmit ? .sendArrowCircle : .sendArrowCircleDisable) + .resizable() + .scaledToFit() + .padding(.leading, 12) + .padding(.bottom, 6) + } + } + .frame(width: 52) + .disabled(!viewModel.canSubmit) } } + +// MARK: - 스크롤 측정값 (iOS 18 onScrollGeometryChange용) +private struct ScrollMetrics: Equatable { + let content: CGFloat + let container: CGFloat + let offsetY: CGFloat +} + +// MARK: - Comment Row +// 피그마 3849-4280: 32 avatar + (이름 SemiBold 14 + 시간 Regular 12 + more) + 본문 Medium 14 +// 셀 하단 hairline (.appStroke). 프로필 탭 → 큰 이미지 view, more 탭 → 메뉴 시트. +private struct NoticeCommentRow: View { + let comment: Comment + let onProfileTap: () -> Void + let onMenuTap: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + avatar + .onTapGesture { onProfileTap() } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(comment.writerName ?? L10n.nonName) + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text01)) + Text(relativeTime) + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text03)) + Spacer(minLength: 0) + Button(action: onMenuTap) { + Image(.menu) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(Color(uiColor: .text03)) + } + } + Text(comment.comment ?? "") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text02)) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(20) + .frame(maxWidth: .infinity) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color(uiColor: .appStroke)) + .frame(height: 1) + } + } + + private var avatar: some View { + Group { + if let path = comment.writerThumbnailPath, let url = URL(string: path) { + // 일정/리뷰와 동일하게 Kingfisher로 캐싱 로딩 + KFImage(url) + .placeholder { Image(.defaultUser).resizable().scaledToFill() } + .resizable() + .scaledToFill() + } else { + Image(.defaultUser).resizable().scaledToFill() + } + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + } + + private var relativeTime: String { + guard let date = comment.createdDate else { return "" } + return NoticeDateFormatter.relative(from: date) + } +} + diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift new file mode 100644 index 00000000..86c0c80d --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift @@ -0,0 +1,355 @@ +// +// NoticeDetailViewModel.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain +import Data // DataRequestError.isHandledError 사용 (취소/이미 처리된 에러 필터) + +// 공지 상세 화면의 상태 머신. +// 진입 시점에 Notice 객체를 받아 본문을 즉시 그리고, 댓글만 별도 API로 로드한다. +// (서버에 공지 단건 GET이 없어 List 응답을 그대로 넘겨받는 구조) +@MainActor +final class NoticeDetailViewModel: ObservableObject { + + // MARK: - Comment Write Mode + // PostDetail의 WriteMode와 동일 패턴. .edit 모드면 입력바 위에 "댓글 수정중" 라벨 표시. + enum WriteMode: Equatable { + case basic + case edit(commentId: Int) + } + + // MARK: - State + @Published private(set) var notice: Notice + @Published var comments: [Comment] = [] + // 댓글 수는 일정/리뷰와 동일하게 서버 totalCount를 사용 (로컬 로드 수가 아님) + @Published private(set) var totalCount: Int = 0 + @Published var inputText: String = "" + @Published var writeMode: WriteMode = .basic + @Published var isLoading = false + @Published var isSubmitting = false + @Published var showError = false + @Published var errorMessage = "" + + let isCreator: Bool + + var hasComments: Bool { !comments.isEmpty } + var canSubmit: Bool { + !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSubmitting + } + var isEditingComment: Bool { + if case .edit = writeMode { return true } else { return false } + } + + // MARK: - Dependencies + private let fetchNoticeUseCase: FetchNotice + private let fetchCommentsUseCase: FetchNoticeCommentList + private let createCommentUseCase: CreateNoticeComment + private let editCommentUseCase: EditComment + private let deleteCommentUseCase: DeleteComment + private let reportUseCase: ReportPost + private let deleteNoticeUseCase: DeleteNotice + private weak var coordinator: NoticeFlowCoordination? + private var pageInfo: PageInfo? + private var isLoadingMore = false + // 블록 기반 옵저버 토큰 (deinit에서 해제) + private var noticeUpdateToken: NSObjectProtocol? + + init(notice: Notice, + isCreator: Bool, + fetchNoticeUseCase: FetchNotice, + fetchCommentsUseCase: FetchNoticeCommentList, + createCommentUseCase: CreateNoticeComment, + editCommentUseCase: EditComment, + deleteCommentUseCase: DeleteComment, + reportUseCase: ReportPost, + deleteNoticeUseCase: DeleteNotice, + coordinator: NoticeFlowCoordination?) { + self.notice = notice + self.isCreator = isCreator + self.fetchNoticeUseCase = fetchNoticeUseCase + self.fetchCommentsUseCase = fetchCommentsUseCase + self.createCommentUseCase = createCommentUseCase + self.editCommentUseCase = editCommentUseCase + self.deleteCommentUseCase = deleteCommentUseCase + self.reportUseCase = reportUseCase + self.deleteNoticeUseCase = deleteNoticeUseCase + self.coordinator = coordinator + + // 진입 시 공지 본문을 fresh하게 다시 받는다 (목록에서 건네받은 값은 stale일 수 있음) + Task { await self.loadNotice() } + + // 시스템 공지는 댓글이 없으니 fetch 스킵 + if notice.type != .system { + Task { await self.loadComments() } + } + + // Compose 화면에서 공지 수정 완료 시 본문 갱신을 받기 위해 notification 구독 + observeNoticeUpdate() + } + + // MARK: - Notification (공지 수정 완료 시 본문 갱신) + // Compose에서 수정 완료 시 userInfo["notice"]에 갱신된 Notice를 실어 발행한다. + // 같은 공지면 본문을 즉시 교체해 stale 표시를 막는다 (일정/리뷰의 수정 즉시 반영과 동일). + private func observeNoticeUpdate() { + noticeUpdateToken = NotificationCenter.default.addObserver( + forName: .noticeUpdated, + object: nil, + queue: .main + ) { [weak self] notification in + let updated = notification.userInfo?["notice"] as? Notice + Task { @MainActor [weak self] in + guard let self, + let updated, + updated.noticeId == self.notice.noticeId else { return } + self.notice = updated + } + } + } + + deinit { + if let token = noticeUpdateToken { + NotificationCenter.default.removeObserver(token) + } + } + + // MARK: - Notice (단건 본문 fresh 조회) + // 진입/새로고침 시 호출. 실패해도 이미 들고 있는 notice로 표시를 유지(조용히 무시). + func loadNotice() async { + guard let noticeId = notice.noticeId else { return } + do { + let fresh = try await fetchNoticeUseCase.execute(noticeId: noticeId) + self.notice = fresh + } catch { + // 취소/이미 처리된 에러는 무시. 그 외 본문 갱신 실패도 화면을 막지 않도록 조용히 둔다. + guard !DataRequestError.isHandledError(err: error) else { return } + } + } + + // MARK: - Comments + // showLoadingIndicator: 전체 화면 로딩 오버레이(customNavigationBar isLoading) 표시 여부. + // 초기 로드는 true, 당겨서 새로고침은 false(.refreshable 자체 스피너가 있어 오버레이가 겹치면 화면이 깨져 보임). + func loadComments(showLoadingIndicator: Bool = true) async { + guard let noticeId = notice.noticeId else { return } + if showLoadingIndicator { isLoading = true } + defer { + if showLoadingIndicator { isLoading = false } + } + + do { + let page = try await fetchCommentsUseCase.execute(noticeId: noticeId, + size: nil, + cursor: nil) + self.comments = page.content + self.pageInfo = page.info + // 서버 totalCount 우선, 미제공(0) 시 로드된 개수로 폴백 + self.totalCount = max(page.totalCount, page.content.count) + } catch { + // 이미 처리된(.handled) 에러(요청 취소 등)는 alert를 띄우지 않는다 (기존 Reactor와 동일 패턴). + guard !DataRequestError.isHandledError(err: error) else { return } + self.errorMessage = error.localizedDescription + self.showError = true + } + } + + // MARK: - Pagination + // 일정/리뷰 댓글과 동일하게 스크롤 하단 도달 시 다음 페이지를 이어 로드한다. + func loadMoreIfNeeded(currentItem: Comment) { + guard let pageInfo, pageInfo.hasNext, !isLoadingMore else { return } + // 마지막(가장 오래된) 댓글이 화면에 등장하면 다음 페이지 요청 + guard comments.last?.id == currentItem.id else { return } + Task { await loadMore() } + } + + private func loadMore() async { + guard let noticeId = notice.noticeId, + let cursor = pageInfo?.nextCursor, + !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let page = try await fetchCommentsUseCase.execute(noticeId: noticeId, + size: nil, + cursor: cursor) + self.comments.append(contentsOf: page.content) + self.pageInfo = page.info + if page.totalCount > 0 { self.totalCount = page.totalCount } + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + + // 댓글 작성/수정 통합 진입점 — writeMode에 따라 분기 + func submitComment() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSubmitting else { return } + + switch writeMode { + case .basic: + createComment(text: text) + case .edit(let commentId): + editComment(commentId: commentId, text: text) + } + } + + private func createComment(text: String) { + guard let noticeId = notice.noticeId else { return } + isSubmitting = true + Task { [weak self] in + guard let self else { return } + defer { self.isSubmitting = false } + do { + let created = try await self.createCommentUseCase.execute(noticeId: noticeId, + content: text, + mentions: []) + // 댓글은 최신순 — 새 댓글을 상단에 삽입 (일정/리뷰와 동일) + self.comments.insert(created, at: 0) + self.totalCount += 1 + self.inputText = "" + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } + + private func editComment(commentId: Int, text: String) { + isSubmitting = true + Task { [weak self] in + guard let self else { return } + defer { self.isSubmitting = false } + do { + let updated = try await self.editCommentUseCase.execute(id: commentId, + text: text, + mentions: []) + if let idx = self.comments.firstIndex(where: { $0.id == updated.id }) { + self.comments[idx] = updated + } + self.cancelCommentEdit() + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } + + func cancelCommentEdit() { + writeMode = .basic + inputText = "" + } + + // MARK: - Comment Menu (시트 액션에서 호출) + func startEditComment(_ comment: Comment) { + guard let commentId = comment.id else { return } + writeMode = .edit(commentId: commentId) + inputText = comment.comment ?? "" + } + + func deleteComment(_ comment: Comment) { + guard let commentId = comment.id else { return } + Task { [weak self] in + guard let self else { return } + do { + try await self.deleteCommentUseCase.execute(commentId: commentId) + self.comments.removeAll { $0.id == commentId } + self.totalCount = max(0, self.totalCount - 1) + // 편집 중이던 댓글을 지운 경우 입력바 초기화 + if case .edit(let editId) = self.writeMode, editId == commentId { + self.cancelCommentEdit() + } + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } + + func reportComment(_ comment: Comment) { + guard let commentId = comment.id else { return } + Task { [weak self] in + guard let self else { return } + do { + try await self.reportUseCase.execute(type: .comment(id: commentId), reason: nil) + ToastManager.shared.presentToast(text: L10n.Report.completed) + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } + + // 댓글 셀의 menu 버튼 → 시트 표시 + func showCommentMenu(for comment: Comment) { + // 시트 액션 enum 분기를 View가 들고있지 않게 ViewModel에서 조립. + if comment.isWriter { + let editAction = DefaultSheetAction(text: L10n.Comment.edit, image: .editComment) { [weak self] in + self?.startEditComment(comment) + } + let deleteAction = DefaultSheetAction(text: L10n.Comment.delete, image: .delete) { [weak self] in + self?.deleteComment(comment) + } + SheetManager.shared.showSheet(actions: [editAction, deleteAction]) + } else { + let reportAction = DefaultSheetAction(text: L10n.Report.comment, image: .report) { [weak self] in + self?.reportComment(comment) + } + SheetManager.shared.showSheet(actions: [reportAction]) + } + } + + // MARK: - Page Menu (우상단 점 세 개) + // 모임장: "공지 수정" + "공지 삭제" + // 모임원: "신고하기" (공지 신고 API 미구현 — placeholder Toast) + func showPageMenu() { + if isCreator { + let editAction = DefaultSheetAction(text: L10n.Notice.edit, image: .editPlan) { [weak self] in + guard let self else { return } + self.coordinator?.pushEditView(notice: self.notice) + } + let deleteAction = DefaultSheetAction(text: L10n.Notice.delete, image: .delete) { [weak self] in + self?.deleteNotice() + } + SheetManager.shared.showSheet(actions: [editAction, deleteAction]) + } else { + let reportAction = DefaultSheetAction(text: L10n.Notice.report, image: .report) { [weak self] in + self?.reportNoticePlaceholder() + } + SheetManager.shared.showSheet(actions: [reportAction]) + } + } + + private func deleteNotice() { + guard let noticeId = notice.noticeId else { return } + Task { [weak self] in + guard let self else { return } + do { + try await self.deleteNoticeUseCase.execute(noticeId: noticeId) + NotificationCenter.default.post(name: .noticeUpdated, object: nil) + self.coordinator?.popCurrentView() + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } + + // TODO: 백엔드에 /notice/report 추가되면 ReportType.notice(id:) 분기로 교체 + private func reportNoticePlaceholder() { + ToastManager.shared.presentToast(text: L10n.Report.completed) + } + + // MARK: - Profile Tap + func tapProfile(name: String?, imagePath: String?) { + coordinator?.presentProfileImage(name: name, imagePath: imagePath) + } + + // MARK: - Navigation + func dismissFlow() { + coordinator?.endFlow() + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift index 9b7edef5..f4cd7d24 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -6,43 +6,245 @@ // import SwiftUI +import Domain -// Phase 1 placeholder. 본 화면은 후속 PR에서 SwiftUI + @Observable + async/await 으로 구현. -// 확성기 버튼에서 진입. 작성 진입점은 isCreator일 때만 노출 예정. +// 확성기 버튼에서 진입하는 공지 리스트. +// 상단 세그먼트(전체/모임공지/시스템)는 로컬 필터링이며 API는 1회 호출. +// 우상단 작성(연필) 아이콘은 모임장에게만 노출. +// 모임장은 셀을 leading swipe하면 파란 핀 버튼이 드러나며 탭하면 pin/unpin API 호출. +// 모임원에게는 swipe action 자체가 노출되지 않는다. struct NoticeListView: View { - let meetId: Int - let isCreator: Bool - let onComposeTap: () -> Void + @StateObject private var viewModel: NoticeListViewModel + // 세그먼트 언더라인이 탭 사이를 슬라이드 이동하도록 matchedGeometryEffect 네임스페이스 + @Namespace private var segmentNamespace + + init(viewModel: NoticeListViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } var body: some View { - VStack(spacing: 12) { - Text("공지 리스트 화면 (구현 예정)") - .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) - .foregroundColor(Color(uiColor: .text01)) - Text("meetId: \(meetId)") - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) - .foregroundColor(Color(uiColor: .text03)) - Text("isCreator: \(String(isCreator))") - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) - .foregroundColor(Color(uiColor: .text03)) - - if isCreator { - Button(action: onComposeTap) { - Text("공지 작성하기") - .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.body1)) - .foregroundColor(Color(uiColor: .primaryText)) - .frame(maxWidth: .infinity) - .frame(height: 48) - .background(Color(uiColor: .appPrimary)) - .cornerRadius(8) + VStack(spacing: 0) { + filterSegment + content + } + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지사항", + isLoading: viewModel.isLoading, + onBack: { viewModel.dismissFlow() }, + trailing: { + if viewModel.isCreator { + Button(action: { viewModel.tapCompose() }) { + Image(.pencil) + .resizable() + .scaledToFit() + } + .frame(width: 40, height: 40) + } + }) + .alert("오류", isPresented: $viewModel.showError) { + Button("확인", role: .cancel) {} + } message: { + Text(viewModel.errorMessage) + } + } + + // MARK: - 세그먼트 (밑줄 스타일 + 슬라이드 애니메이션) + // matchedGeometryEffect로 선택된 탭의 언더라인을 하나만 두고 SwiftUI가 자동으로 위치 보간 + private var filterSegment: some View { + HStack(spacing: 0) { + ForEach(NoticeListViewModel.Filter.allCases, id: \.rawValue) { filter in + let isSelected = viewModel.selectedFilter == filter + Text(filter.title) + .font(.custom(isSelected ? FontFamily.Pretendard.semiBold : FontFamily.Pretendard.regular, + size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text02)) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay(alignment: .bottom) { + // 선택된 탭에만 underline을 두고 같은 id로 matchedGeometryEffect 연결 + // → 다른 탭을 탭하면 SwiftUI가 위치를 보간해 검은 바가 슬라이드 이동 + if isSelected { + Rectangle() + .fill(Color(uiColor: .appSecondary)) + .frame(height: 2) + .matchedGeometryEffect(id: "segmentUnderline", + in: segmentNamespace) + } + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.22)) { + viewModel.selectedFilter = filter + } + } + } + Spacer(minLength: 0) + } + .padding(.top, 8) + .background(Color(uiColor: .bgPrimary)) + } + + // MARK: - 본문 + @ViewBuilder + private var content: some View { + if viewModel.filteredNotices.isEmpty && !viewModel.isLoading { + Color(uiColor: .bgPrimary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // List를 쓰는 이유: SwiftUI `.swipeActions`가 List 셀에서만 동작. + // 디자인은 카드형이 아니라 흰 row + hairline이라 listRowInsets/Separator를 직접 제어. + List { + ForEach(viewModel.filteredNotices, id: \.noticeId) { notice in + NoticeListRow(notice: notice) + .contentShape(Rectangle()) + .onTapGesture { viewModel.selectNotice(notice) } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color(uiColor: .bgPrimary)) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + if viewModel.isCreator { + Button { + viewModel.togglePin(notice) + } label: { + // 라벨에 직접 .doAnchor를 못 받아서 systemImage placeholder를 두고 + // tint로 배경색만 분기. (실제 이미지는 placeholder가 보임) + // 정확한 .doAnchor 이미지 + 배경색 적용을 위해 Label 대신 VStack 사용 가능하지만, + // SwiftUI .swipeActions는 systemImage만 정식 지원이라 placeholder 유지 + tint 분기. + Label(notice.isPinned ? "고정해제" : "고정", + image: ImageResource.doAnchor) + } + // 고정 요청 시 .appPrimary, 해제 시 .appRed + .tint(notice.isPinned ? Color(uiColor: .appRed) : Color(uiColor: .appPrimary)) + } + } } - .padding(.horizontal, 20) - .padding(.top, 24) } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color(uiColor: .bgPrimary)) + // 당겨서 새로고침은 블로킹 로더(isLoading)를 끈다 — 켜면 터치 차단이 refresh 제스처를 취소시킴. + .refreshable { + await viewModel.loadInitial(showLoadingIndicator: false) + } + } + } +} + +// MARK: - Row (피그마 4206-3611 기준) +// 흰 배경 + 하단 hairline. +// 좌측 아이콘 구성: +// - 고정(pinned) 공지: anchor(24×24) + 타입 아이콘 +// - 그 외: 타입 아이콘만 +// 타입 아이콘: .custom → megaphone, .system → system +// 모임장은 leading swipe로 핀 토글 가능. +private struct NoticeListRow: View { + let notice: Notice + + var body: some View { + HStack(alignment: .center, spacing: 0) { + leadingIcons + textColumn + .padding(.leading, 8) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) .background(Color(uiColor: .bgPrimary)) - .customNavigationBar(title: "공지사항") + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color(uiColor: .appStroke)) + .frame(height: 1) + } + } + + @ViewBuilder + private var leadingIcons: some View { + if notice.isPinned { + Image(.anchor) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + Image(typeIconResource) + .resizable() + .scaledToFit() + // 피그마: 24 컨테이너에 20 아이콘 + .frame(width: 20, height: 20) + .frame(width: 24, height: 24) + } + + private var typeIconResource: ImageResource { + switch notice.type { + case .system: return .system + case .custom, .none: return .megaphone + } + } + + private var textColumn: some View { + VStack(alignment: .leading, spacing: 4) { + // 본문 (1줄 말줄임) + Text(notice.content ?? "") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text02)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + // 메타 라인: 절대시간 · 상대시간 (· 읽음수는 데이터 들어올 때만) + HStack(spacing: 4) { + ForEach(Array(metaParts.enumerated()), id: \.offset) { idx, part in + if idx > 0 { + Text("·") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text03)) + } + Text(part) + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text03)) + } + Spacer(minLength: 0) + } + } + } + + private var metaParts: [String] { + var parts: [String] = [] + if let date = notice.createdAt { + parts.append(NoticeDateFormatter.absolute(from: date)) + parts.append(NoticeDateFormatter.relative(from: date)) + } + // "N명 읽음" — 서버 응답에 readCount가 추가되면 여기서 append + return parts + } +} + +// MARK: - Date Helpers +// 공지 리스트 메타 라인 전용. 피그마 표기 ("11월 28일 오후 2:00", "34분 전") 매칭. +enum NoticeDateFormatter { + private static let absoluteFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "ko_KR") + f.dateFormat = "M월 d일 a h:mm" + return f + }() + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.locale = Locale(identifier: "ko_KR") + f.unitsStyle = .short + return f + }() + + static func absolute(from date: Date) -> String { + return absoluteFormatter.string(from: date) + } + + static func relative(from date: Date, now: Date = Date()) -> String { + // RelativeDateTimeFormatter는 미래 기준이면 "후"가 붙는데 + // 공지는 무조건 과거이므로 음수 시간으로 강제 전달 + let interval = date.timeIntervalSince(now) + let safeInterval = min(interval, -1) // 미래일 경우 1초 전으로 클램프 + let raw = relativeFormatter.localizedString(fromTimeInterval: safeInterval) + return raw } } diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift new file mode 100644 index 00000000..177c729b --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift @@ -0,0 +1,184 @@ +// +// NoticeListViewModel.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain +import Data // DataRequestError.isHandledError 사용 (취소/이미 처리된 에러 필터) + +// 공지 리스트 화면의 상태 머신. +// API 1회 호출로 전체 공지를 모두 받아두고, 탭 전환은 로컬 필터링으로만 처리한다 (명세 요구사항). +@MainActor +final class NoticeListViewModel: ObservableObject { + + // MARK: - Filter Tab + enum Filter: Int, CaseIterable { + case all + case custom + case system + + var title: String { + switch self { + case .all: return "전체" + case .custom: return "모임공지" + case .system: return "시스템" + } + } + } + + // MARK: - Published State + @Published var notices: [Notice] = [] + @Published var selectedFilter: Filter = .all + @Published var isLoading = false + @Published var showError = false + @Published var errorMessage = "" + + // MARK: - Derived + // 선택된 탭 기준으로 노출할 공지 리스트. 고정 공지는 항상 상단. + var filteredNotices: [Notice] { + let filtered: [Notice] + switch selectedFilter { + case .all: + filtered = notices + case .custom: + filtered = notices.filter { $0.type == .custom } + case .system: + filtered = notices.filter { $0.type == .system } + } + return filtered.sorted { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned } + let l = lhs.createdAt ?? .distantPast + let r = rhs.createdAt ?? .distantPast + return l > r + } + } + + let meetId: Int + let isCreator: Bool + + // MARK: - Dependencies + private let fetchListUseCase: FetchNoticeList + private let togglePinUseCase: TogglePinNotice + private weak var coordinator: NoticeFlowCoordination? + private var pageInfo: PageInfo? + private var isFetching = false + + init(meetId: Int, + isCreator: Bool, + fetchListUseCase: FetchNoticeList, + togglePinUseCase: TogglePinNotice, + coordinator: NoticeFlowCoordination?) { + self.meetId = meetId + self.isCreator = isCreator + self.fetchListUseCase = fetchListUseCase + self.togglePinUseCase = togglePinUseCase + self.coordinator = coordinator + + Task { await self.loadInitial() } + + // 공지 생성/수정/삭제 후 발행되는 알림을 받아 리스트 reload + // (Closure observer는 self를 capture하지 않고 토큰만 보관) + self.updateToken = NotificationCenter.default.addObserver( + forName: .noticeUpdated, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.loadInitial() + } + } + } + + deinit { + if let token = updateToken { + NotificationCenter.default.removeObserver(token) + } + } + + // 알림 구독 토큰 (deinit에서 해제) + private var updateToken: NSObjectProtocol? + + // MARK: - Loading + // showLoadingIndicator: 전체화면 블로킹 로더(customNavigationBar isLoading) 표시 여부. + // isFetching(중복 요청 guard)은 항상 동작. 당겨서 새로고침은 false — 블로킹 로더가 터치를 막아 + // 진행 중인 refresh 제스처를 취소시키는 것을 방지(상세 화면과 동일 패턴). + func loadInitial(showLoadingIndicator: Bool = true) async { + guard !isFetching else { return } + isFetching = true + if showLoadingIndicator { isLoading = true } + defer { + isFetching = false + if showLoadingIndicator { isLoading = false } + } + + do { + let page = try await fetchListUseCase.execute(meetId: meetId, + size: nil, + cursor: nil) + self.notices = page.content + self.pageInfo = page.info + } catch { + // 이미 처리된(.handled) 에러(요청 취소 등)는 alert를 띄우지 않는다. + guard !DataRequestError.isHandledError(err: error) else { return } + self.errorMessage = error.localizedDescription + self.showError = true + } + } + + // MARK: - Actions + func selectNotice(_ notice: Notice) { + guard notice.noticeId != nil else { return } + coordinator?.pushNoticeDetail(notice: notice, isCreator: isCreator) + } + + func tapCompose() { + coordinator?.pushComposeView() + } + + func dismissFlow() { + coordinator?.endFlow() + } + + // MARK: - Pin Toggle + // 모임장이 swipe action으로 호출. + // togglePin은 의미상 isPinned/version 외 필드(type/content/createdAt 등)를 바꾸지 않으므로 + // 응답을 통째로 덮어쓰지 않고 기존 Notice에 isPinned/version만 머지한다. + // 이렇게 하면: + // - Mock UseCase가 type을 항상 .custom으로 반환하는 한계를 회피 + // - 실제 서버 응답이 일부 필드를 누락해도 안전 + // - 의미상 옳음(토글 액션의 책임 범위 밖 필드는 건드리지 않음) + func togglePin(_ notice: Notice) { + guard isCreator, let noticeId = notice.noticeId else { return } + Task { [weak self] in + guard let self else { return } + do { + let updated = try await self.togglePinUseCase.execute( + noticeId: noticeId, + isCurrentlyPinned: notice.isPinned + ) + if let idx = self.notices.firstIndex(where: { $0.noticeId == updated.noticeId }) { + let existing = self.notices[idx] + let merged = Notice( + noticeId: existing.noticeId, + version: updated.version ?? existing.version, + meetId: existing.meetId, + type: existing.type, + content: existing.content, + writer: existing.writer, + isPinned: updated.isPinned, + createdAt: existing.createdAt + ) + self.notices[idx] = merged + // 모임상세 화면이 구독해서 pinnedNotice를 갱신할 수 있도록 알림 발행 + NotificationManager.shared.postItem(NoticePayload.updated(merged), from: self) + } + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/PostDetail/PostDetailFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/PostDetail/PostDetailFlowCoordinator.swift index 1de9bf94..febd8938 100644 --- a/Mople/Presentation/MainScene/Sub/PostDetail/PostDetailFlowCoordinator.swift +++ b/Mople/Presentation/MainScene/Sub/PostDetail/PostDetailFlowCoordinator.swift @@ -29,6 +29,8 @@ protocol CommentListCoordination: NavigationCloseable { func pushReplyPage(parentComment: Comment, meetId: Int) func deleteParentComment(id: Int) + func updateReplyCount(parentId: Int, increment: Bool) + func updateParentComment(_ comment: Comment) } final class PostDetailFlowCoordinator: BaseCoordinator, PostDetailCoordination { @@ -87,6 +89,16 @@ extension PostDetailFlowCoordinator: CommentListCoordination { postVC?.commentVC.deletedComment(id: id) self.pop() } + + /// 답글 페이지의 답글 생성/삭제를 메인 댓글 페이지의 부모 댓글 '답글 N개'에 반영한다. + func updateReplyCount(parentId: Int, increment: Bool) { + postVC?.commentVC.updateReplyCount(parentId: parentId, increment: increment) + } + + /// 답글 페이지에서 부모 댓글의 좋아요 등 변경을 메인 댓글 페이지에 반영한다. + func updateParentComment(_ comment: Comment) { + postVC?.commentVC.updateParentComment(comment) + } func presentPhotoView(title: String?, index: Int, diff --git a/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewController.swift b/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewController.swift index 3e8ace02..a2c3c8b6 100644 --- a/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewController.swift @@ -55,6 +55,8 @@ final class CommentListViewController: TitleNaviViewController, View, ScrollKeyb private let fetchComment: PublishSubject = .init() private let fetchNextPage: PublishSubject = .init() private let reply: PublishSubject = .init() + private let updateReplyCount: PublishSubject<(parentId: Int, increment: Bool)> = .init() + private let updateParentComment: PublishSubject = .init() // MARK: - UI Components @@ -328,13 +330,35 @@ extension CommentListViewController { .compactMap({ $0 }) .bind(to: reactor.action) .disposed(by: disposeBag) + + // 답글 페이지는 자체 새로고침 바인딩이 필요하다. + // (메인 댓글 리스트의 당김 새로고침은 PostDetail이 처리하므로 child 타입에서만 바인딩) + if case .child = reactor.type { + self.rx.refresh + .map { Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: disposeBag) + } + + updateReplyCount + .map { Reactor.Action.updateReplyCount(parentId: $0.parentId, + increment: $0.increment) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + updateParentComment + .map { Reactor.Action.updateParentComment($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) writeComment .observe(on: MainScheduler.asyncInstance) .compactMap({ self.handleWriteComment(text: $0.text, mentionIds: $0.mentionIds) }) - .do(onNext: { _ in - self.changeWriteMode(.basic) + .do(onNext: { [weak self] _ in + self?.changeWriteMode(.basic) + // 댓글 전송 후 입력창에서 포커스를 해제해 키보드를 내린다. + self?.chatingTextFieldView.textView.rx.isResign.onNext(true) }) .bind(to: reactor.action) .disposed(by: disposeBag) @@ -437,12 +461,23 @@ extension CommentListViewController { } .disposed(by: disposeBag) + // 부모 댓글 목록 조회/새로고침 시 메인 카운트를 부모 댓글 수로 동기화 (대댓글 제외) + reactor.pulse(\.$commentCount) + .asDriver(onErrorJustReturn: nil) + .compactMap({ $0 }) + .drive(with: self, onNext: { vc, count in + vc.totalCount = count + vc.updateHeaderCount() + }) + .disposed(by: disposeBag) + // 댓글 추가/삭제 시 총 댓글 수 업데이트 reactor.pulse(\.$adjustCommentCount) .asDriver(onErrorJustReturn: nil) .compactMap({ $0 }) .drive(with: self, onNext: { vc, increment in vc.totalCount += increment ? 1 : -1 + vc.updateHeaderCount() }) .disposed(by: disposeBag) @@ -586,10 +621,26 @@ extension CommentListViewController { guard let commentsCount = reactor?.currentState.comments.count else { return false } return commentsCount == (index + 1) } + + /// 섹션 헤더의 댓글 수 표시를 현재 totalCount로 즉시 갱신한다. + private func updateHeaderCount() { + guard let header = tableView.headerView(forSection: 0) as? CommentSectionHeader else { return } + header.updateCount(totalCount) + } public func deletedComment(id: Int) { deletedComment.onNext(id) } + + /// 답글 페이지에서 전달받은 답글 수 변동을 메인 댓글 리스트의 부모 댓글에 반영한다. + public func updateReplyCount(parentId: Int, increment: Bool) { + updateReplyCount.onNext((parentId, increment)) + } + + /// 답글 페이지에서 부모 댓글의 좋아요 등 변경을 메인 댓글 리스트에 반영한다. + public func updateParentComment(_ comment: Comment) { + updateParentComment.onNext(comment) + } } // MARK: - Handle Loading diff --git a/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewReactor.swift b/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewReactor.swift index 9d029659..6365ed58 100644 --- a/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewReactor.swift +++ b/Mople/Presentation/MainScene/Sub/PostDetail/View/ChildView/CommentListView/CommentListViewReactor.swift @@ -22,6 +22,7 @@ enum LoadMode { enum CommentEdit { case add(comments: [Comment]) + case create(comments: [Comment]) case edit(comment: Comment) case delete(id: Int) case refresh(comments: [Comment]) @@ -38,12 +39,17 @@ final class CommentListViewReactor: Reactor, LifeCycleLoggable { enum Action { case fetchPage(postId: Int, isRefresh: Bool) case fetchNextPage + case refresh case createComment(content: String, mentions: [Int]) case editComment(id: Int, content: String, mentions: [Int]) case likeComment(id: Int) case deleteComment(id: Int) case deletedComment(id: Int) case reportComment(id: Int) + // 답글 페이지에서 답글 생성/삭제 시, 메인 댓글 페이지의 해당 부모 댓글 '답글 N개'를 갱신하기 위한 액션 + case updateReplyCount(parentId: Int, increment: Bool) + // 답글 페이지에서 부모 댓글의 좋아요 등 변경을 메인 댓글 페이지에 반영하기 위한 액션 + case updateParentComment(Comment) case showWriterImage(name: String?, imagePath: String?) case showReply(parentComment: Comment, meetId: Int) case endFlow @@ -52,6 +58,8 @@ final class CommentListViewReactor: Reactor, LifeCycleLoggable { enum Mutation { case updateComment([Comment]) case adjustCommentCount(increment: Bool) + // 부모 댓글 목록 API의 totalCount(=부모 댓글 수)로 메인 카운트를 갱신 + case setCommentCount(Int) case reportedComment case updateLoadingState(Bool) case catchError(Error) @@ -60,6 +68,7 @@ final class CommentListViewReactor: Reactor, LifeCycleLoggable { struct State { @Pulse var comments: [Comment] = [] @Pulse var adjustCommentCount: Bool? + @Pulse var commentCount: Int? @Pulse var reportedComment: Void? @Pulse var loadState: (isLoad: Bool, mode: LoadMode) = (false, .none) @Pulse var error: Error? @@ -137,6 +146,13 @@ final class CommentListViewReactor: Reactor, LifeCycleLoggable { mentions: mentions) case .fetchNextPage: return fetchNextPage() + case .refresh: + return resetPage() + case let .updateReplyCount(parentId, increment): + return .just(updateParentReplyCount(parentId: parentId, increment: increment)) + case let .updateParentComment(comment): + let updated = currentState.comments.map { $0.id == comment.id ? comment : $0 } + return .just(.updateComment(updated)) case let .editComment(id, text, mentions): guard let comment = currentState.comments.first(where: { $0.id == id }) else { return .empty() } return editComment(comment: comment, @@ -167,16 +183,18 @@ final class CommentListViewReactor: Reactor, LifeCycleLoggable { switch action { case let .fetchPage(_, isRefersh): loadMode = isRefersh ? .refresh : .more + case .refresh: + loadMode = .refresh case .fetchNextPage: loadMode = .more default: loadMode = .none } } - + private func shouldAction(_ action: Action) -> Bool { switch action { - case .showWriterImage, .showReply: + case .showWriterImage, .showReply, .updateReplyCount, .updateParentComment: return true default: return !isLoading @@ -214,6 +232,8 @@ final class CommentListViewReactor: Reactor, LifeCycleLoggable { newState.comments = comments case let .adjustCommentCount(increment): newState.adjustCommentCount = increment + case let .setCommentCount(count): + newState.commentCount = count case .reportedComment: newState.reportedComment = () case let .updateLoadingState(isLoad): @@ -244,6 +264,8 @@ extension CommentListViewReactor { self.page = commentPage.info let editCase: CommentEdit = isRefresh ? .refresh(comments: commentPage.content) : .add(comments: commentPage.content) let mutation = self.updateCommentList(editCase: editCase) + // 메인 카운트는 부모 댓글 수(totalCount)만 사용 — 대댓글은 제외 + observer.onNext(.setCommentCount(commentPage.totalCount)) observer.onNext(mutation) observer.onCompleted() } catch { @@ -301,7 +323,7 @@ extension CommentListViewReactor { .execute(postId: postId, comment: comment, mentions: mentions) - let mutation = self.updateCommentList(editCase: .add(comments: [newComment])) + let mutation = self.updateCommentList(editCase: .create(comments: [newComment])) observer.onNext(mutation) observer.onCompleted() } catch { @@ -334,7 +356,7 @@ extension CommentListViewReactor { parentId: parentId, comment: comment, mentions: mentions) - let mutation = self.updateCommentList(editCase: .add(comments: [newReply])) + let mutation = self.updateCommentList(editCase: .create(comments: [newReply])) observer.onNext(mutation) observer.onCompleted() } catch { @@ -470,6 +492,9 @@ extension CommentListViewReactor { observer.onNext(.updateComment( self?.currentState.comments.map { $0.id == id ? updatedComment : $0 } ?? [] )) + // 답글 페이지에서 상단 고정된 '부모(메인) 댓글'에 좋아요를 누른 경우, + // 메인 댓글 페이지에도 좋아요 상태를 반영한다. + self?.syncParentCommentToMain(updatedComment) } observer.onCompleted() } catch { @@ -480,12 +505,22 @@ extension CommentListViewReactor { } } + /// 답글 페이지에서 상단 고정된 부모 댓글의 변경(좋아요 등)을 메인 댓글 페이지로 전파한다. + /// - 답글 페이지(.child)에서, 변경된 댓글이 해당 부모 댓글일 때만 동작한다. + private func syncParentCommentToMain(_ comment: Comment) { + guard case let .child(parent, _) = type, + comment.id == parent.id else { return } + coordinator?.updateParentComment(comment) + } + // MARK: - 댓글 리스트 편집 private func updateCommentList(editCase: CommentEdit) -> Mutation { var comments = currentState.comments switch editCase { case .add(let commentList): setAddCommnetCommentList(comments: commentList, list: &comments) + case .create(let commentList): + setCreatedCommentList(comments: commentList, list: &comments) case .edit(let comment): editComment(comments: &comments, comment: comment) @@ -525,13 +560,33 @@ extension CommentListViewReactor { } } + // MARK: - 새로 작성한 댓글 추가 + /// 새로 작성한 댓글을 목록에 반영한다. + /// - 부모 댓글: 목록이 최신순이므로 상단에 삽입한다 (페이지네이션 append와 구분). + /// - 대댓글: 부모 우선 + 날짜 오름차순 정렬이므로 기존 add 로직(하단 추가 후 정렬)을 재사용한다. + private func setCreatedCommentList(comments newComments: [Comment], list: inout [Comment]) { + switch type { + case .parent: + list.insert(contentsOf: newComments, at: 0) + case let .child(parent, _): + setAddCommnetCommentList(comments: newComments, list: &list) + // 답글이 추가되면 메인 댓글 페이지의 부모 댓글 '답글 N개'를 +1 한다. + if let parentId = parent.id { + coordinator?.updateReplyCount(parentId: parentId, increment: true) + } + } + } + private func setFirstCommentList(comments: [Comment], list: inout [Comment]) { switch type { case .parent: list = comments - case .child: - list.removeAll { $0.type == .child } - list.append(contentsOf: comments) + case let .child(parent, _): + // 답글 페이지는 부모(메인) 댓글을 상단에 고정하고 그 아래로 답글을 표시한다. + // 서버 응답에는 답글만 내려오므로, 부모 댓글을 직접 맨 앞에 시드한다. + // (이미 갱신된 부모가 리스트에 있으면 그 상태를 유지) + let pinnedParent = list.first { $0.type == .parent } ?? parent + list = [pinnedParent] + comments } } @@ -539,12 +594,27 @@ extension CommentListViewReactor { guard let editIndex = comments.firstIndex(where: { $0.id == comment.id }) else { return } comments[editIndex] = comment } + + // MARK: - 부모 댓글 답글 수 갱신 + /// 답글 페이지에서 전달받은 답글 수 변동을 메인 댓글 리스트의 해당 부모 댓글에 반영한다. + private func updateParentReplyCount(parentId: Int, increment: Bool) -> Mutation { + var comments = currentState.comments + if let index = comments.firstIndex(where: { $0.id == parentId }) { + let newCount = comments[index].replyCount + (increment ? 1 : -1) + comments[index].replyCount = max(newCount, 0) + } + return .updateComment(comments) + } private func handleDeleteComment(comments: inout [Comment], id: Int) { if case .child(let parent, _) = type, parent.id == id { coordinator?.deleteParentComment(id: id) } else { comments.removeAll { $0.id == id } + // 답글 페이지에서 답글이 삭제되면 메인 댓글 페이지의 부모 댓글 '답글 N개'를 -1 한다. + if case let .child(parent, _) = type, let parentId = parent.id { + coordinator?.updateReplyCount(parentId: parentId, increment: false) + } } } } diff --git a/Mople/Presentation/MainScene/Sub/PostDetail/View/PostDetailViewController.swift b/Mople/Presentation/MainScene/Sub/PostDetail/View/PostDetailViewController.swift index f37f6ea6..0405f420 100644 --- a/Mople/Presentation/MainScene/Sub/PostDetail/View/PostDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/PostDetail/View/PostDetailViewController.swift @@ -332,7 +332,12 @@ extension PostDetailViewController { guard postSummary.isCreator, let isReviewd = (postSummary as? ReviewPostSummary)?.isReviewd, !isReviewd else { return } - + + // 게시글별 최초 1회만 노출 — 같은 후기 게시글에 다시 들어와도 반복 노출되지 않도록 이력 체크 + guard let postId = postSummary.postId, + !UserDefaults.hasSuggestedReview(postId: postId) else { return } + UserDefaults.markSuggestedReview(postId: postId) + let writeReview = writeReview() let cancleAction = cancleWriteReview() diff --git "a/Mople/Resources/Assets.xcassets/AppIcon.appiconset/\354\225\261 \354\225\204\354\235\264\354\275\230.png" b/Mople/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png similarity index 100% rename from "Mople/Resources/Assets.xcassets/AppIcon.appiconset/\354\225\261 \354\225\204\354\235\264\354\275\230.png" rename to Mople/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png diff --git a/Mople/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Mople/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 3d650970..cefcc878 100644 --- a/Mople/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Mople/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "앱 아이콘.png", + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/Contents.json index a1480391..0bd80136 100644 --- a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/Contents.json +++ b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/Contents.json @@ -5,15 +5,81 @@ "idiom" : "universal", "scale" : "1x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png", + "idiom" : "universal", + "scale" : "1x" + }, { "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x.png", "idiom" : "universal", "scale" : "2x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x.png", "idiom" : "universal", "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png new file mode 100644 index 00000000..67f1ce8c Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png new file mode 100644 index 00000000..e0b17d74 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png new file mode 100644 index 00000000..ba42c8a2 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png new file mode 100644 index 00000000..843d5ff0 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png new file mode 100644 index 00000000..b5dda5c9 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png new file mode 100644 index 00000000..dcaecc6c Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/Contents.json index a1480391..8f4b31c4 100644 --- a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/Contents.json +++ b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/Contents.json @@ -5,15 +5,81 @@ "idiom" : "universal", "scale" : "1x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png", + "idiom" : "universal", + "scale" : "1x" + }, { "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x.png", "idiom" : "universal", "scale" : "2x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x.png", "idiom" : "universal", "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png new file mode 100644 index 00000000..67f1ce8c Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png new file mode 100644 index 00000000..e0b17d74 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png new file mode 100644 index 00000000..ba42c8a2 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png new file mode 100644 index 00000000..843d5ff0 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png new file mode 100644 index 00000000..b5dda5c9 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png new file mode 100644 index 00000000..dcaecc6c Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/Contents.json index 65ef4ff8..94a5a7b7 100644 --- a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/Contents.json +++ b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/Contents.json @@ -5,15 +5,81 @@ "idiom" : "universal", "scale" : "1x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png", + "idiom" : "universal", + "scale" : "1x" + }, { "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x.png", "idiom" : "universal", "scale" : "2x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x.png", "idiom" : "universal", "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png new file mode 100644 index 00000000..24368e34 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png new file mode 100644 index 00000000..196369bb Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png new file mode 100644 index 00000000..79afff84 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png new file mode 100644 index 00000000..650dcf28 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png new file mode 100644 index 00000000..40db2c8a Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png new file mode 100644 index 00000000..c5268b67 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/Contents.json index 65ef4ff8..94a5a7b7 100644 --- a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/Contents.json +++ b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/Contents.json @@ -5,15 +5,81 @@ "idiom" : "universal", "scale" : "1x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png", + "idiom" : "universal", + "scale" : "1x" + }, { "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x.png", "idiom" : "universal", "scale" : "2x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x.png", "idiom" : "universal", "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png new file mode 100644 index 00000000..196369bb Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png new file mode 100644 index 00000000..24368e34 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png new file mode 100644 index 00000000..650dcf28 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png new file mode 100644 index 00000000..79afff84 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png new file mode 100644 index 00000000..c5268b67 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png differ diff --git a/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png new file mode 100644 index 00000000..40db2c8a Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/Contents.json b/Mople/Resources/Assets.xcassets/Notice/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/Contents.json new file mode 100644 index 00000000..79184338 --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "anchor.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "anchor@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "anchor@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor.png b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor.png new file mode 100644 index 00000000..bdd16a5c Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@2x.png b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@2x.png new file mode 100644 index 00000000..cb82204f Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@2x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@3x.png b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@3x.png new file mode 100644 index 00000000..cd4615d4 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@3x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/Contents.json new file mode 100644 index 00000000..82f8d31f --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "do_anchor.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "do_anchor@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "do_anchor@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor.png b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor.png new file mode 100644 index 00000000..3c414007 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@2x.png b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@2x.png new file mode 100644 index 00000000..fc7cc466 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@2x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@3x.png b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@3x.png new file mode 100644 index 00000000..c489b79e Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@3x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/Contents.json new file mode 100644 index 00000000..bcf501f9 --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "meet_megaphone.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "meet_megaphone@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "meet_megaphone@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone.png b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone.png new file mode 100644 index 00000000..f9119a0c Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@2x.png b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@2x.png new file mode 100644 index 00000000..9b46974b Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@2x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@3x.png b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@3x.png new file mode 100644 index 00000000..be02f6ef Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@3x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/Contents.json new file mode 100644 index 00000000..9ade8e3f --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "megaphone.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "megaphone@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "megaphone@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone.png b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone.png new file mode 100644 index 00000000..513910b2 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@2x.png b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@2x.png new file mode 100644 index 00000000..8fa5fd31 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@2x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@3x.png b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@3x.png new file mode 100644 index 00000000..0432e82e Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@3x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/Contents.json new file mode 100644 index 00000000..b834e7cf --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "pencil.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "pencil@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "pencil@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil.png b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil.png new file mode 100644 index 00000000..529f0e6a Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@2x.png b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@2x.png new file mode 100644 index 00000000..e4fdf153 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@2x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@3x.png b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@3x.png new file mode 100644 index 00000000..c4610298 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@3x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/system.imageset/Contents.json b/Mople/Resources/Assets.xcassets/Notice/system.imageset/Contents.json new file mode 100644 index 00000000..b5b1dfbe --- /dev/null +++ b/Mople/Resources/Assets.xcassets/Notice/system.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "system.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "system@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "system@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Mople/Resources/Assets.xcassets/Notice/system.imageset/system.png b/Mople/Resources/Assets.xcassets/Notice/system.imageset/system.png new file mode 100644 index 00000000..a2e61294 Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/system.imageset/system.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/system.imageset/system@2x.png b/Mople/Resources/Assets.xcassets/Notice/system.imageset/system@2x.png new file mode 100644 index 00000000..d51e447d Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/system.imageset/system@2x.png differ diff --git a/Mople/Resources/Assets.xcassets/Notice/system.imageset/system@3x.png b/Mople/Resources/Assets.xcassets/Notice/system.imageset/system@3x.png new file mode 100644 index 00000000..518cbb2b Binary files /dev/null and b/Mople/Resources/Assets.xcassets/Notice/system.imageset/system@3x.png differ diff --git a/Mople/Resources/Localization/ko.lproj/Localizable.strings b/Mople/Resources/Localization/ko.lproj/Localizable.strings index 927ed955..bd88a3d6 100644 --- a/Mople/Resources/Localization/ko.lproj/Localizable.strings +++ b/Mople/Resources/Localization/ko.lproj/Localizable.strings @@ -175,6 +175,11 @@ "report.comment" = "댓글 신고"; "report.completed" = "신고 접수가 완료되었습니다."; +// MARK: - Notice +"notice.edit" = "공지 수정"; +"notice.delete" = "공지 삭제"; +"notice.report" = "신고하기"; + // MARK: - Common "create" = "생성하기"; "search" = "검색"; diff --git a/Project.swift b/Project.swift index dca98f81..1c569c6e 100644 --- a/Project.swift +++ b/Project.swift @@ -5,10 +5,10 @@ import ProjectDescription // MARK: - 공통 설정 -let marketingVersion = "1.3.0" +let marketingVersion = "1.4.1" let currentProjectVersion = "8" let developmentTeam = "LNXWGGBBH6" -let deploymentTarget: DeploymentTargets = .iOS("17.6") +let deploymentTarget: DeploymentTargets = .iOS("18.0") // MARK: - Info.plist @@ -269,7 +269,7 @@ let mopleTarget: Target = .target( "PRODUCT_BUNDLE_IDENTIFIER": "com.moim.moimtable.dev", "CODE_SIGN_STYLE": "Automatic", "OTHER_SWIFT_FLAGS": "-DDEV", - "API_BASE_URL": "https://dev.zerod.store", + "API_BASE_URL": "https://dev.2erod.com", "MAIN_SCHEME": "mopledev", "KAKAO_NATIVE_APP_KEY": "0fcc3c29ae8669444767451bb1e89e7e", "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon-Dev", @@ -279,7 +279,10 @@ let mopleTarget: Target = .target( .release(name: "Release", settings: [ "PRODUCT_BUNDLE_IDENTIFIER": "com.moim.moimtable", "CODE_SIGN_STYLE": "Manual", - "API_BASE_URL": "https://prod.zerod.store", + // Tuist 전환 시 누락된 Manual 서명 지정 (App Store 배포 archive용) + "CODE_SIGN_IDENTITY": "Apple Distribution", + "PROVISIONING_PROFILE_SPECIFIER": "Mople Distribution", + "API_BASE_URL": "https://prod.2erod.com", "MAIN_SCHEME": "mople", "KAKAO_NATIVE_APP_KEY": "72b95832d0237fce2c5c7eb82d4a6a7a", "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon", @@ -295,7 +298,7 @@ let project = Project( name: "Mople", settings: .settings( base: [ - "IPHONEOS_DEPLOYMENT_TARGET": "17.6", + "IPHONEOS_DEPLOYMENT_TARGET": "18.0", ], configurations: [ .debug(name: "Debug"), diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e54898f5..35cc870d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -21,9 +21,9 @@ default_platform(:ios) # → 2FA 없이 자동화 가능, CI/CD 필수 # ────────────────────────────────────────────── API_KEY = app_store_connect_api_key( - key_id: "9GV3U3P562", + key_id: "4AD2MCFFH2", issuer_id: "077a4d64-9ada-4316-9cf8-45e9b944ba64", - key_filepath: File.join(Dir.home, "Desktop/Task/Develop/Swift/CICD/Release/AuthKey_9GV3U3P562.p8"), + key_filepath: File.join(Dir.home, "Desktop/Task/Develop/Swift/CICD/Release/AuthKey_4AD2MCFFH2.p8"), in_house: false ) @@ -43,12 +43,12 @@ platform :ios do ) + 1 ) - # 2) Mople Dev 스킴으로 빌드 + # 2) Mople Dev 빌드 (Tuist 전환 후: 단일 "Mople" 스킴 + Debug = Dev 서버) notification(title: "📦 TestFlight", message: "[2/3] 빌드 시작... (약 2~3분)") build_app( - project: "Mople.xcodeproj", - scheme: "Mople Dev", - configuration: "Release", + workspace: "Mople.xcworkspace", + scheme: "Mople", + configuration: "Debug", clean: true, export_method: "app-store", cloned_source_packages_path: "SourcePackages", @@ -94,7 +94,7 @@ platform :ios do # 2) Mople 프로덕션 스킴으로 빌드 (Release 설정) build_app( - project: "Mople.xcodeproj", + workspace: "Mople.xcworkspace", scheme: "Mople", configuration: "Release", clean: true, @@ -112,6 +112,7 @@ platform :ios do # 3) App Store 업로드 (심사 제출은 수동) upload_to_app_store( api_key: API_KEY, + app_identifier: "com.moim.moimtable", # 프로덕션 앱 명시 (Appfile 기본값=dev 방지) skip_metadata: true, skip_screenshots: true, precheck_include_in_app_purchases: false @@ -177,7 +178,7 @@ platform :ios do # 6) Mople 프로덕션 빌드 build_app( - project: "Mople.xcodeproj", + workspace: "Mople.xcworkspace", scheme: "Mople", configuration: "Release", clean: true, @@ -198,6 +199,8 @@ platform :ios do upload_to_app_store( api_key: API_KEY, + app_identifier: "com.moim.moimtable", # 프로덕션 앱 명시 (Appfile 기본값=dev 방지) + force: true, # 비대화형 실행: 메타데이터 확인 프롬프트 건너뜀 skip_metadata: !submit, # 심사 제출 시에만 메타데이터 포함 skip_screenshots: true, submit_for_review: submit, # submit:true 시 심사 자동 제출 @@ -224,9 +227,9 @@ platform :ios do desc "빌드만 수행 (업로드 없음, PR 검증용)" lane :build_only do build_app( - project: "Mople.xcodeproj", - scheme: "Mople Dev", - configuration: "Release", + workspace: "Mople.xcworkspace", + scheme: "Mople", + configuration: "Debug", clean: true, export_method: "app-store", cloned_source_packages_path: "SourcePackages",