From da1c6ff432233761052e1b45677569c2173319d0 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 21:52:56 +0900 Subject: [PATCH 01/26] =?UTF-8?q?feat:=20NoticeRepo=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=B2=B4=20+=20=EA=B3=B5=EC=A7=80/=EA=B3=B5=EC=A7=80=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20UseCase=20=EA=B3=A8=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DefaultNoticeRepo: /notice/list, /notice/create, /notice/update, /notice/{id} DELETE, /notice/pin/{id} PATCH·DELETE, /comment/notice/{id} GET·POST 매핑 - NoticeCommentResponse DTO 추가 (Post 댓글과 달리 likes/replies/mentions 필드가 응답에 없어 Comment 도메인 일부만 채움) - Notice UseCase 7종: FetchNoticeList / CreateNotice / UpdateNotice / DeleteNotice / TogglePinNotice / FetchNoticeCommentList / CreateNoticeComment (각각 #if DEV Mock 포함) - APIEndpoints에 위 엔드포인트 함수 추가 --- .../Network/Endpoint/APIEndpoints.swift | 94 ++++++++++++++++++- .../Notice/NoticeCommentResponse.swift | 40 ++++++++ .../Notice/DefaultNoticeRepo.swift | 92 ++++++++++++++++++ .../UseCases/Notice/CreateNotice.swift | 45 +++++++++ .../UseCases/Notice/CreateNoticeComment.swift | 57 +++++++++++ .../UseCases/Notice/DeleteNotice.swift | 36 +++++++ .../Notice/FetchNoticeCommentList.swift | 70 ++++++++++++++ .../UseCases/Notice/FetchNoticeList.swift | 75 +++++++++++++++ .../UseCases/Notice/TogglePinNotice.swift | 50 ++++++++++ .../UseCases/Notice/UpdateNotice.swift | 47 ++++++++++ 10 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 Modules/Data/Sources/Network/Response/Notice/NoticeCommentResponse.swift create mode 100644 Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/CreateNotice.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/CreateNoticeComment.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/DeleteNotice.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/FetchNoticeCommentList.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/TogglePinNotice.swift create mode 100644 Modules/Domain/Sources/UseCases/Notice/UpdateNotice.swift diff --git a/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift b/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift index f9239674..74901bc3 100644 --- a/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift +++ b/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift @@ -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,95 @@ 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) + } + + // 공지 생성 — 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/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/Repositories/Notice/DefaultNoticeRepo.swift b/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift new file mode 100644 index 00000000..8c21af97 --- /dev/null +++ b/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift @@ -0,0 +1,92 @@ +// +// 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 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/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/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..096dbd33 --- /dev/null +++ b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift @@ -0,0 +1,75 @@ +// +// 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))", + 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 From 48fba06f2badea861488057fc1331062f4702dc2 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 21:53:20 +0900 Subject: [PATCH 02/26] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8/=EC=83=81=EC=84=B8/=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B8=20=EA=B5=AC=ED=98=84=20+=20PostDetail=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리스트 - 피그마 4154-3802 기준 밑줄 세그먼트(전체/모임공지/시스템) + matchedGeometryEffect로 슬라이드 애니메이션 - 모임장 전용 leading swipe action 핀(토글 API 연동) - 우상단 연필(모임장만) → 작성 화면 push - 셀 디자인: 흰 row + hairline, 본문 1줄 + 메타(절대/상대시간) 상세 - 본문 카드(작성자/날짜/본문) + 댓글 섹션 + 하단 입력바 - 시스템 공지(.system) 분기: 댓글/입력바 숨김, 작성자 = Mople 앱 로고 - 우상단 점 세개 메뉴(DefaultSheetViewController): · 모임장: 공지 수정 → Compose .edit / 공지 삭제 → DeleteNotice · 모임원: 신고하기(공지 신고 API 미구현이라 placeholder Toast) - 댓글 셀 메뉴: 본인은 수정/삭제, 타인은 신고(ReportPost .comment) - 댓글 수정 모드: 입력바 상단 "댓글 수정중" 라벨 + 취소 버튼 - 프로필 이미지 탭 → PhotoBookViewController modal present 작성 - 등록/수정 공용 화면 (NoticeComposeViewModel.Mode .create/.edit) - 키보드 처리: 빈 영역 탭 dismiss, 버튼이 키보드 따라 올라옴 - TextEditor 가변 + 스크롤, 500자 제한(prefix) - 등록 버튼 색: PlanCreate 패턴 (.appPrimary↔.disablePrimary, .primaryText↔.disableText) 인프라 - CustomNavigationBar trailing 슬롯 추가 (제네릭 ViewBuilder) - InteractivePopHostingController: SwiftUI 자식 화면에서 좌 edge swipe 표준 pop 동작하도록 viewDidAppear에서 popGesture 활성화 - Notification.noticeUpdated 채널: 공지 변경 시 리스트 자동 reload - MeetDetail → NoticeDetail 진입 시 isCreator 전달 Closes #37 --- .../Sub/NoticeSceneDIContainer.swift | 174 ++++++++++- .../SwiftUI/CustomNavigationBar.swift | 80 +++-- .../InteractivePopHostingController.swift | 28 ++ .../MeetDetailFlowCoordinator.swift | 6 +- .../View/MeetDetailViewReactor.swift | 7 +- .../Sub/Notice/NoticeFlowCoordinator.swift | 55 +++- .../Sub/Notice/View/NoticeComposeView.swift | 98 +++++- .../Notice/View/NoticeComposeViewModel.swift | 112 +++++++ .../Sub/Notice/View/NoticeDetailView.swift | 274 ++++++++++++++++- .../Notice/View/NoticeDetailViewModel.swift | 280 ++++++++++++++++++ .../Sub/Notice/View/NoticeListView.swift | 222 ++++++++++++-- .../Sub/Notice/View/NoticeListViewModel.swift | 156 ++++++++++ 12 files changed, 1396 insertions(+), 96 deletions(-) create mode 100644 Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift create mode 100644 Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift index c7a36f2b..23211437 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,117 @@ 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 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 +163,56 @@ 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: NoticeListView(viewModel: viewModel)) + } + + @MainActor + func makeNoticeDetailViewController(notice: Notice, + isCreator: Bool, + coordinator: NoticeFlowCoordination) -> UIViewController { + let noticeRepo = makeNoticeRepo() + let commentRepo = makeCommentRepo() + let viewModel = NoticeDetailViewModel( + notice: notice, + isCreator: isCreator, + 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 UIHostingController(rootView: view) + return InteractivePopHostingController(rootView: NoticeDetailView(viewModel: viewModel)) } @MainActor - func makeNoticeDetailViewController(noticeId: Int) -> UIViewController { - let view = NoticeDetailView(noticeId: noticeId) - return UIHostingController(rootView: view) + 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/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..7e32b9ba --- /dev/null +++ b/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift @@ -0,0 +1,28 @@ +// +// InteractivePopHostingController.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SwiftUI + +// SwiftUI 자식 화면을 AppNaviViewController에 push할 때 사용하는 베이스. +// AppNaviViewController는 navigationBar.isHidden = true 이므로 기본 동작상 +// interactivePopGestureRecognizer가 비활성/동작 불가 상태가 된다. +// 이 클래스는 viewDidAppear 시점에 명시적으로 활성화하여 +// 좌측 edge swipe로 표준 pop이 동작하도록 보장한다. +// +// 사용 패턴은 TitleNaviViewController.setNavigation() 과 동일. +final class InteractivePopHostingController: UIHostingController { + + 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/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/View/MeetDetailViewReactor.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift index fc11d9f3..5dfd13f7 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift @@ -198,8 +198,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() 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..94e7ab0d 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift @@ -6,23 +6,101 @@ // 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)) + VStack(spacing: 0) { + editor + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 16) + submitButton + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // VStack 빈 영역 탭으로 키보드 내림. background에 contentShape를 줘야 빈 영역 hit testing 됨. + .background( + Color(uiColor: .bgPrimary) + .contentShape(Rectangle()) + .onTapGesture { editorFocused = false } + ) + .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)) - Text("meetId: \(meetId)") - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) - .foregroundColor(Color(uiColor: .text03)) + .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) - .background(Color(uiColor: .bgPrimary)) - .customNavigationBar(title: "공지 작성") + } + + 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..aa579c5b --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift @@ -0,0 +1,112 @@ +// +// 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 { + 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 } + _ = try await self.updateUseCase.execute(noticeId: noticeId, + meetId: meetId, + content: text) + } + self.didSubmit = true + // 리스트/상세 갱신 트리거 + NotificationCenter.default.post(name: .noticeUpdated, object: nil) + // 작성/수정 완료 후엔 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..0cb6e5c3 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -6,23 +6,279 @@ // import SwiftUI +import Domain -// 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 + + 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)) + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + noticeBody + if !isSystem { + Color(uiColor: .bgSecondary) + .frame(height: 8) + commentSection + } + } + } + if !isSystem { + inputBar + } + } + .background( + // 화면 빈 영역 탭으로 키보드 dismiss + Color(uiColor: .bgPrimary) + .contentShape(Rectangle()) + .onTapGesture { inputFocused = false } + ) + .customNavigationBar(title: navigationTitle, + isLoading: viewModel.isLoading, + trailing: { trailingMenuButton }) + .alert("오류", isPresented: $viewModel.showError) { + Button("확인", role: .cancel) {} + } message: { + Text(viewModel.errorMessage) + } + } + + // 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 앱 로고. 일반 공지는 도메인에 작성자 정보가 없어 + // 일단 기본 프로필(.defaultUser)을 사용 (백엔드에서 writer 응답 추가되면 교체). + @ViewBuilder + private var authorAvatar: some View { + if isSystem { + Image(.logo) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Image(.defaultUser) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .clipShape(Circle()) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var authorName: String { + isSystem ? "Mople" : "모임공지" + } + + // 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.comments.count)개") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text03)) + } + .padding(.horizontal, 20) + .padding(.top, 28) + .padding(.bottom, 8) + + ForEach(viewModel.comments, id: \.id) { comment in + NoticeCommentRow( + comment: comment, + onProfileTap: { + viewModel.tapProfile(name: comment.writerName, + imagePath: comment.writerThumbnailPath) + }, + onMenuTap: { + viewModel.showCommentMenu(for: comment) + } + ) + } + } + .background(Color(uiColor: .bgPrimary)) + } + + // MARK: - 입력바 (ChatingTextFieldView SwiftUI 포팅, 멘션 제외) + // edit 모드면 상단에 "댓글 수정중" 라벨 + 취소 버튼 표시 + private var inputBar: some View { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isEditingComment { + HStack(spacing: 8) { + Text("댓글 수정중") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text03)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(uiColor: .bgSecondary)) + .cornerRadius(4) + Button("취소") { + viewModel.cancelCommentEdit() + inputFocused = false + } + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text02)) + Spacer(minLength: 0) + } + } + + HStack(alignment: .bottom, spacing: 0) { + TextField("댓글을 입력해주세요", text: $viewModel.inputText, axis: .vertical) + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text01)) + .focused($inputFocused) + .lineLimit(1...5) + .padding(.vertical, 8) + + Button(action: { + inputFocused = false + viewModel.submitComment() + }) { + Image(viewModel.canSubmit ? .sendArrowCircle : .sendArrowCircleDisable) + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + } + .disabled(!viewModel.canSubmit) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 8) .background(Color(uiColor: .bgPrimary)) - .customNavigationBar(title: "공지 상세") + } +} + +// 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 ?? "익명") + .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) { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + Image(.defaultUser).resizable() + } + } else { + Image(.defaultUser).resizable() + } + } + .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..bb68fc52 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift @@ -0,0 +1,280 @@ +// +// NoticeDetailViewModel.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain + +// 공지 상세 화면의 상태 머신. +// 진입 시점에 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] = [] + @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 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? + + init(notice: Notice, + isCreator: Bool, + fetchCommentsUseCase: FetchNoticeCommentList, + createCommentUseCase: CreateNoticeComment, + editCommentUseCase: EditComment, + deleteCommentUseCase: DeleteComment, + reportUseCase: ReportPost, + deleteNoticeUseCase: DeleteNotice, + coordinator: NoticeFlowCoordination?) { + self.notice = notice + self.isCreator = isCreator + self.fetchCommentsUseCase = fetchCommentsUseCase + self.createCommentUseCase = createCommentUseCase + self.editCommentUseCase = editCommentUseCase + self.deleteCommentUseCase = deleteCommentUseCase + self.reportUseCase = reportUseCase + self.deleteNoticeUseCase = deleteNoticeUseCase + self.coordinator = coordinator + + // 시스템 공지는 댓글이 없으니 fetch 스킵 + if notice.type != .system { + Task { await self.loadComments() } + } + + // Compose 화면에서 공지 수정 완료 시 본문 갱신을 받기 위해 notification 구독 + observeNoticeUpdate() + } + + // MARK: - Notification (공지 수정 완료 시 본문 갱신) + private func observeNoticeUpdate() { + NotificationCenter.default.addObserver(forName: .noticeUpdated, + object: nil, + queue: .main) { [weak self] _ in + // 현재 본문을 그대로 두면 stale. 일단 dismiss하지 않고 placeholder 유지. + // 보다 정확한 갱신은 list로 돌아간 후 fetch에 맡김. + _ = self + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Comments + func loadComments() async { + guard let noticeId = notice.noticeId else { return } + isLoading = true + defer { isLoading = false } + + do { + let page = try await fetchCommentsUseCase.execute(noticeId: noticeId, + size: nil, + cursor: nil) + self.comments = page.content + self.pageInfo = page.info + } 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.append(created) + 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 } + // 편집 중이던 댓글을 지운 경우 입력바 초기화 + 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: "신고하기", 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: "공지 수정", image: .editPlan) { [weak self] in + guard let self else { return } + self.coordinator?.pushEditView(notice: self.notice) + } + let deleteAction = DefaultSheetAction(text: "공지 삭제", image: .delete) { [weak self] in + self?.deleteNotice() + } + SheetManager.shared.showSheet(actions: [editAction, deleteAction]) + } else { + let reportAction = DefaultSheetAction(text: "신고하기", 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..c43d6398 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -6,43 +6,207 @@ // 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(systemName: "square.and.pencil") + .font(.system(size: 20, weight: .regular)) + .foregroundColor(Color(uiColor: .text01)) } - .padding(.horizontal, 20) - .padding(.top, 24) + .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) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 8) .background(Color(uiColor: .bgPrimary)) - .customNavigationBar(title: "공지사항") + } + + // 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: { + Label(notice.isPinned ? "고정해제" : "고정", + systemImage: "pin.fill") + } + .tint(Color(uiColor: .appPrimary)) + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color(uiColor: .bgPrimary)) + .refreshable { + await viewModel.loadInitial() + } + } + } +} + +// MARK: - Row (피그마 4154-3802 기준) +// 흰 배경 + 하단 hairline. 모임장은 leading swipe로 파란 핀 버튼이 드러난다. +private struct NoticeListRow: View { + let notice: Notice + + var body: some View { + textColumn + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .bgPrimary)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color(uiColor: .appStroke)) + .frame(height: 1) + } + } + + 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..08a69ebd --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift @@ -0,0 +1,156 @@ +// +// NoticeListViewModel.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import Foundation +import Domain + +// 공지 리스트 화면의 상태 머신. +// 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 + func loadInitial() async { + guard !isFetching else { return } + isFetching = true + isLoading = true + defer { isFetching = false; isLoading = false } + + do { + let page = try await fetchListUseCase.execute(meetId: meetId, + size: nil, + cursor: nil) + self.notices = page.content + self.pageInfo = page.info + } catch { + 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으로 호출. 응답 받은 Notice로 로컬 상태 교체. + 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 }) { + self.notices[idx] = updated + } + } catch { + self.errorMessage = error.localizedDescription + self.showError = true + } + } + } +} From 559b68eebaa7b3aa9019a9dd2e46f3cff5beec3f Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 23:03:36 +0900 Subject: [PATCH 03/26] =?UTF-8?q?fix:=20=ED=82=A4=EB=B3=B4=EB=93=9C=20dism?= =?UTF-8?q?iss=EB=A5=BC=20UIKit=20tap=20recognizer=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI .background+.onTapGesture는 자식(TextEditor 등)이 차지한 영역에서 hit testing이 부모로 전달되지 않아 간헐적으로 안 잡히는 문제가 있었음. InteractivePopHostingController의 root view에 UITapGestureRecognizer 를 달아 더 신뢰성 있게 처리. - cancelsTouchesInView = false: SwiftUI Button 등 컨트롤은 함께 동작 - shouldReceive에서 UITextView/UITextField 위 탭은 제외 → 입력 컴포넌트의 포커싱/커서 동작 정상 유지 NoticeDetail, NoticeCompose 둘 다 이 controller로 감싸져 있어 자동 적용. 양쪽 View의 SwiftUI background tap 코드는 중복 동작이라 제거. --- .../InteractivePopHostingController.swift | 47 ++++++++++++++++--- .../Sub/Notice/View/NoticeComposeView.swift | 9 ++-- .../Sub/Notice/View/NoticeDetailView.swift | 8 +--- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift b/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift index 7e32b9ba..7fd742e3 100644 --- a/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift +++ b/Mople/CommonUI/SwiftUI/InteractivePopHostingController.swift @@ -9,13 +9,47 @@ import UIKit import SwiftUI // SwiftUI 자식 화면을 AppNaviViewController에 push할 때 사용하는 베이스. -// AppNaviViewController는 navigationBar.isHidden = true 이므로 기본 동작상 -// interactivePopGestureRecognizer가 비활성/동작 불가 상태가 된다. -// 이 클래스는 viewDidAppear 시점에 명시적으로 활성화하여 -// 좌측 edge swipe로 표준 pop이 동작하도록 보장한다. // -// 사용 패턴은 TitleNaviViewController.setNavigation() 과 동일. -final class InteractivePopHostingController: UIHostingController { +// 두 가지 책임: +// +// 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) @@ -26,3 +60,4 @@ final class InteractivePopHostingController: UIHostingController< navi.interactivePopGestureRecognizer?.delegate = nil } } + diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift index 94e7ab0d..dece6ff2 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift @@ -32,12 +32,9 @@ struct NoticeComposeView: View { submitButton } .frame(maxWidth: .infinity, maxHeight: .infinity) - // VStack 빈 영역 탭으로 키보드 내림. background에 contentShape를 줘야 빈 영역 hit testing 됨. - .background( - Color(uiColor: .bgPrimary) - .contentShape(Rectangle()) - .onTapGesture { editorFocused = false } - ) + .background(Color(uiColor: .bgPrimary)) + // 키보드 dismiss는 InteractivePopHostingController의 UIKit tap recognizer가 담당. + // SwiftUI .background+.onTapGesture는 자식 영역에서 간헐적으로 안 잡히는 문제가 있어 사용 안 함. .customNavigationBar(title: viewModel.navigationTitle, isLoading: viewModel.isSubmitting, onBack: { viewModel.dismissFlow() }) diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift index 0cb6e5c3..8972afac 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -41,12 +41,8 @@ struct NoticeDetailView: View { inputBar } } - .background( - // 화면 빈 영역 탭으로 키보드 dismiss - Color(uiColor: .bgPrimary) - .contentShape(Rectangle()) - .onTapGesture { inputFocused = false } - ) + .background(Color(uiColor: .bgPrimary)) + // 키보드 dismiss는 InteractivePopHostingController의 UIKit tap recognizer가 담당 .customNavigationBar(title: navigationTitle, isLoading: viewModel.isLoading, trailing: { trailingMenuButton }) From 7e838e140ffdb5d451a4a714491ee4dc8a6d39b6 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 23:06:31 +0900 Subject: [PATCH 04/26] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B0=94=EB=A5=BC=20ChatingTextFieldView=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=EA=B3=BC=20=EC=9D=BC=EC=B9=98?= =?UTF-8?q?=EC=8B=9C=ED=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 SwiftUI 구현이 단순 TextField + 아이콘이라 일정 상세의 ChatingTextFieldView와 시각적으로 달랐음. UIKit 원본과 동일하게: - 박스형 입력 (.bgInput + cornerRadius 8), 내부 padding 좌우 8/상하 18 - placeholder는 .text04, ZStack(.topLeading)로 동일 위치 겹침 - send 영역 폭 52pt, 아이콘은 bottom-trailing에 leading 12 / bottom 6 (height = width 1:1) - isEditMode 동작: 키보드 떠있을 때(inputFocused)만 send 노출 - editLabel: 25x70, cornerRadius 4, bgSecondary, Body2.medium, text03 - 전체 패딩 top 16 / horizontal 20 / bottom safeArea - maxTextLine 4 (lineLimit 1...4) - tint = .text02 멘션 기능은 그대로 제외. --- .../Sub/Notice/View/NoticeDetailView.swift | 107 ++++++++++++------ 1 file changed, 73 insertions(+), 34 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift index 8972afac..9d905089 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -160,53 +160,92 @@ struct NoticeDetailView: View { .background(Color(uiColor: .bgPrimary)) } - // MARK: - 입력바 (ChatingTextFieldView SwiftUI 포팅, 멘션 제외) - // edit 모드면 상단에 "댓글 수정중" 라벨 + 취소 버튼 표시 + // 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 { - HStack(spacing: 8) { - Text("댓글 수정중") - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) - .foregroundColor(Color(uiColor: .text03)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(uiColor: .bgSecondary)) - .cornerRadius(4) - Button("취소") { - viewModel.cancelCommentEdit() - inputFocused = false - } - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) - .foregroundColor(Color(uiColor: .text02)) - Spacer(minLength: 0) - } + editLabelRow } HStack(alignment: .bottom, spacing: 0) { - TextField("댓글을 입력해주세요", text: $viewModel.inputText, axis: .vertical) + inputBox + if inputFocused { + sendButton + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + // 하단은 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) + Button("취소") { + viewModel.cancelCommentEdit() + inputFocused = false + } + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) + .foregroundColor(Color(uiColor: .text02)) + Spacer(minLength: 0) + } + } + + // DefaultTextView 박스: bgInput + cornerRadius 8 + 내부 padding (좌우 8, 상하 18) + // placeholder는 같은 padding으로 ZStack(topLeading) 겹쳐서 위치 일치 + private var inputBox: some View { + ZStack(alignment: .topLeading) { + if viewModel.inputText.isEmpty { + Text("댓글을 입력해주세요") .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) - .foregroundColor(Color(uiColor: .text01)) - .focused($inputFocused) - .lineLimit(1...5) - .padding(.vertical, 8) + .foregroundColor(Color(uiColor: .text04)) + .padding(.horizontal, 8) + .padding(.vertical, 18) + .allowsHitTesting(false) + } + TextField("", text: $viewModel.inputText, axis: .vertical) + .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text01)) + .tint(Color(uiColor: .text02)) + .focused($inputFocused) + // ChatingTextFieldView의 maxTextLine = 4와 동일 + .lineLimit(1...4) + .padding(.horizontal, 8) + .padding(.vertical, 18) + } + .background(Color(uiColor: .bgInput)) + .cornerRadius(8) + } - Button(action: { - inputFocused = false - viewModel.submitComment() - }) { + // 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() - .frame(width: 40, height: 40) + .padding(.leading, 12) + .padding(.bottom, 6) } - .disabled(!viewModel.canSubmit) - } } - .padding(.horizontal, 20) - .padding(.top, 16) - .padding(.bottom, 8) - .background(Color(uiColor: .bgPrimary)) + .frame(width: 52) + .disabled(!viewModel.canSubmit) } } From b280eda4e14d69bc221689e89e6f839d9bd2f71a Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 26 May 2026 23:08:21 +0900 Subject: [PATCH 05/26] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=B0=94=EB=A5=BC=20TextEditor=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=ED=95=B4=20=EB=A9=80=ED=8B=B0=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EB=A7=A4=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextField(axis: .vertical)은 UITextView 기반인 ChatingTextFieldView의 줄바꿈/자동 높이/스크롤 동작과 미묘하게 달랐음. - TextEditor(UITextView 기반)로 변경 → 줄바꿈 자유, 자동 높이 확장, 초과 시 자체 스크롤 모두 매칭 - minHeight ≈ 1줄(20pt), maxHeight ≈ 4줄(80pt). maxTextLine 4와 매핑 - TextEditor 내부 inset(~5pt) 보정해 ChatingTextFieldView 외관과 일치 - placeholder는 ZStack(.topLeading) overlay 유지 --- .../Sub/Notice/View/NoticeDetailView.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift index 9d905089..28bff9b8 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -202,9 +202,17 @@ struct NoticeDetailView: View { } // DefaultTextView 박스: bgInput + cornerRadius 8 + 내부 padding (좌우 8, 상하 18) - // placeholder는 같은 padding으로 ZStack(topLeading) 겹쳐서 위치 일치 + // TextEditor는 UIKit UITextView 기반이라 ChatingTextFieldView와 동일하게 + // 줄바꿈/자동 높이 확장/초과 시 자체 스크롤 동작이 매칭됨. + // 높이는 minHeight(1줄) ~ maxHeight(4줄) 범위에서 콘텐츠에 맞춰 자동. private var inputBox: some View { - ZStack(alignment: .topLeading) { + // 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)) @@ -213,15 +221,17 @@ struct NoticeDetailView: View { .padding(.vertical, 18) .allowsHitTesting(false) } - TextField("", text: $viewModel.inputText, axis: .vertical) + TextEditor(text: $viewModel.inputText) .font(.custom(FontFamily.Pretendard.regular, size: FontStyle.Size.body1)) .foregroundColor(Color(uiColor: .text01)) .tint(Color(uiColor: .text02)) .focused($inputFocused) - // ChatingTextFieldView의 maxTextLine = 4와 동일 - .lineLimit(1...4) - .padding(.horizontal, 8) - .padding(.vertical, 18) + .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) From 3c1c036f15ac6135f0ec0b6daaadd6c72e04d5e9 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 10:24:07 +0900 Subject: [PATCH 06/26] =?UTF-8?q?chore:=20=EC=84=9C=EB=B2=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B3=80=EA=B2=BD=20(zerod.store=20?= =?UTF-8?q?=E2=86=92=202erod.com)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.swift b/Project.swift index dca98f81..c259516b 100644 --- a/Project.swift +++ b/Project.swift @@ -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,7 @@ 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", + "API_BASE_URL": "https://prod.2erod.com", "MAIN_SCHEME": "mople", "KAKAO_NATIVE_APP_KEY": "72b95832d0237fce2c5c7eb82d4a6a7a", "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon", From b08de0909273d6e01e3d0cbe1811429eab328970 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 10:36:48 +0900 Subject: [PATCH 07/26] =?UTF-8?q?fix:=20Tuist=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=ED=9B=84=20Fastlane=20=EB=B9=8C=EB=93=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=ED=95=A9=ED=99=94=20(=EC=8A=A4=ED=82=B4/=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlane/Fastfile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e54898f5..a2964313 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -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, @@ -177,7 +177,7 @@ platform :ios do # 6) Mople 프로덕션 빌드 build_app( - project: "Mople.xcodeproj", + workspace: "Mople.xcworkspace", scheme: "Mople", configuration: "Release", clean: true, @@ -224,9 +224,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", From af7c92ba4357b69ec8b6750d31b5915408b1ed32 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 10:41:02 +0900 Subject: [PATCH 08/26] =?UTF-8?q?chore:=20=EB=A7=88=EC=BC=80=ED=8C=85=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=201.4.0=EC=9C=BC=EB=A1=9C=20=EC=83=81?= =?UTF-8?q?=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.swift b/Project.swift index c259516b..f76b004e 100644 --- a/Project.swift +++ b/Project.swift @@ -5,7 +5,7 @@ import ProjectDescription // MARK: - 공통 설정 -let marketingVersion = "1.3.0" +let marketingVersion = "1.4.0" let currentProjectVersion = "8" let developmentTeam = "LNXWGGBBH6" let deploymentTarget: DeploymentTargets = .iOS("17.6") From a5e02f420c24492b3e7d46efc7c23c70e8d7ddc7 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 10:51:57 +0900 Subject: [PATCH 09/26] =?UTF-8?q?fix:=20Release=20config=EC=97=90=20Manual?= =?UTF-8?q?=20=EC=84=9C=EB=AA=85=20=EC=A7=80=EC=A0=95=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(Tuist=20=EC=A0=84=ED=99=98=20=EB=88=84=EB=9D=BD=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Project.swift b/Project.swift index f76b004e..e35ab216 100644 --- a/Project.swift +++ b/Project.swift @@ -279,6 +279,9 @@ let mopleTarget: Target = .target( .release(name: "Release", settings: [ "PRODUCT_BUNDLE_IDENTIFIER": "com.moim.moimtable", "CODE_SIGN_STYLE": "Manual", + // 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", From 73c798df79c1b9fe848f986d90989d15e533bf81 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 10:59:51 +0900 Subject: [PATCH 10/26] =?UTF-8?q?fix:=20App=20Store=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=20prod=20=EB=B2=88=EB=93=A4ID=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C=20(Appfile=20dev=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlane/Fastfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a2964313..77b96c87 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -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 @@ -198,6 +199,7 @@ platform :ios do upload_to_app_store( api_key: API_KEY, + app_identifier: "com.moim.moimtable", # 프로덕션 앱 명시 (Appfile 기본값=dev 방지) skip_metadata: !submit, # 심사 제출 시에만 메타데이터 포함 skip_screenshots: true, submit_for_review: submit, # submit:true 시 심사 자동 제출 From 4841befec8977e4cedb92db39fa1da06703057e5 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 11:13:13 +0900 Subject: [PATCH 11/26] =?UTF-8?q?chore:=20App=20Store=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=9A=A9=20API=20=ED=82=A4=EB=A5=BC=20App=20Manager=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=ED=82=A4=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlane/Fastfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 77b96c87..67b109d4 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 ) From 7566eaed34e6433fe6eac1e8329af8432e854651 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 11:16:34 +0900 Subject: [PATCH 12/26] =?UTF-8?q?chore:=20App=20Store=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=A0=9C=EC=B6=9C=20=EC=8B=9C=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=99=95=EC=9D=B8=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EC=83=9D=EB=9E=B5(force)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlane/Fastfile | 1 + 1 file changed, 1 insertion(+) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 67b109d4..35cc870d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -200,6 +200,7 @@ 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 시 심사 자동 제출 From 856e094f4461a6eb407102ec06d98804a03cd0e4 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Tue, 9 Jun 2026 11:27:59 +0900 Subject: [PATCH 13/26] =?UTF-8?q?fix:=20=EC=95=B1=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20ASCII=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(=ED=95=9C=EA=B8=80=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EC=9C=A0=EB=8B=88=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EB=B6=88=EC=9D=BC=EC=B9=98=EB=A1=9C=20act?= =?UTF-8?q?ool=EC=9D=B4=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Assets.xcassets/AppIcon.appiconset/AppIcon.png | Bin .../AppIcon.appiconset/Contents.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename "Mople/Resources/Assets.xcassets/AppIcon.appiconset/\354\225\261 \354\225\204\354\235\264\354\275\230.png" => Mople/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png (100%) 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" From bb1bedea91b6d34324c8b11b8c84a2ff61a77544 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Thu, 11 Jun 2026 17:07:14 +0900 Subject: [PATCH 14/26] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=95=84=EC=9D=B4=EC=BD=98=206=EC=A2=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(=ED=94=BC=EA=B7=B8=EB=A7=88=204206-3611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notice/{anchor,do_anchor,meet_megaphone,megaphone,pencil,system}.imageset 생성. iOS 17+ ImageResource로 자동 노출(.anchor, .pencil, .doAnchor 등) - MeetDetailViewController 확성기 버튼: SF Symbol → .meetMegaphone 에셋 - NoticeListView 우상단 작성 버튼: square.and.pencil → .pencil 에셋 (24pt) - NoticeListRow 좌측 타입 아이콘 추가 · 고정(pinned) 공지: .anchor (24x24) · 모임공지(.custom) / 기본: .megaphone (24 컨테이너에 20) · 시스템 공지(.system): .system (24 컨테이너에 20) - 셀 leading swipe: pin.fill → .doAnchor · 고정 요청 시 .appPrimary, 해제 시 .appRed 배경 분기 --- .../View/MeetDetailViewController.swift | 6 +- .../Sub/Notice/View/NoticeListView.swift | 70 ++++++++++++++---- .../Assets.xcassets/Notice/Contents.json | 6 ++ .../Notice/anchor.imageset/Contents.json | 8 ++ .../Notice/anchor.imageset/anchor.png | Bin 0 -> 286 bytes .../Notice/anchor.imageset/anchor@2x.png | Bin 0 -> 361 bytes .../Notice/anchor.imageset/anchor@3x.png | Bin 0 -> 489 bytes .../Notice/do_anchor.imageset/Contents.json | 8 ++ .../Notice/do_anchor.imageset/do_anchor.png | Bin 0 -> 229 bytes .../do_anchor.imageset/do_anchor@2x.png | Bin 0 -> 282 bytes .../do_anchor.imageset/do_anchor@3x.png | Bin 0 -> 375 bytes .../meet_megaphone.imageset/Contents.json | 8 ++ .../meet_megaphone.png | Bin 0 -> 632 bytes .../meet_megaphone@2x.png | Bin 0 -> 1026 bytes .../meet_megaphone@3x.png | Bin 0 -> 1492 bytes .../Notice/megaphone.imageset/Contents.json | 8 ++ .../Notice/megaphone.imageset/megaphone.png | Bin 0 -> 459 bytes .../megaphone.imageset/megaphone@2x.png | Bin 0 -> 778 bytes .../megaphone.imageset/megaphone@3x.png | Bin 0 -> 1046 bytes .../Notice/pencil.imageset/Contents.json | 8 ++ .../Notice/pencil.imageset/pencil.png | Bin 0 -> 421 bytes .../Notice/pencil.imageset/pencil@2x.png | Bin 0 -> 666 bytes .../Notice/pencil.imageset/pencil@3x.png | Bin 0 -> 903 bytes .../Notice/system.imageset/Contents.json | 8 ++ .../Notice/system.imageset/system.png | Bin 0 -> 427 bytes .../Notice/system.imageset/system@2x.png | Bin 0 -> 696 bytes .../Notice/system.imageset/system@3x.png | Bin 0 -> 972 bytes 27 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 Mople/Resources/Assets.xcassets/Notice/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/anchor.imageset/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@2x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/anchor.imageset/anchor@3x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@2x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/do_anchor.imageset/do_anchor@3x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@2x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/meet_megaphone.imageset/meet_megaphone@3x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@2x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/megaphone.imageset/megaphone@3x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/pencil.imageset/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@2x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/pencil.imageset/pencil@3x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/system.imageset/Contents.json create mode 100644 Mople/Resources/Assets.xcassets/Notice/system.imageset/system.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/system.imageset/system@2x.png create mode 100644 Mople/Resources/Assets.xcassets/Notice/system.imageset/system@3x.png diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 65e9f6f8..97f700eb 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 }() diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift index c43d6398..c7511a79 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -35,9 +35,10 @@ struct NoticeListView: View { trailing: { if viewModel.isCreator { Button(action: { viewModel.tapCompose() }) { - Image(systemName: "square.and.pencil") - .font(.system(size: 20, weight: .regular)) - .foregroundColor(Color(uiColor: .text01)) + Image(.pencil) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) } .frame(width: 40, height: 40) } @@ -107,10 +108,15 @@ struct NoticeListView: View { Button { viewModel.togglePin(notice) } label: { + // 라벨에 직접 .doAnchor를 못 받아서 systemImage placeholder를 두고 + // tint로 배경색만 분기. (실제 이미지는 placeholder가 보임) + // 정확한 .doAnchor 이미지 + 배경색 적용을 위해 Label 대신 VStack 사용 가능하지만, + // SwiftUI .swipeActions는 systemImage만 정식 지원이라 placeholder 유지 + tint 분기. Label(notice.isPinned ? "고정해제" : "고정", - systemImage: "pin.fill") + image: ImageResource.doAnchor) } - .tint(Color(uiColor: .appPrimary)) + // 고정 요청 시 .appPrimary, 해제 시 .appRed + .tint(notice.isPinned ? Color(uiColor: .appRed) : Color(uiColor: .appPrimary)) } } } @@ -125,21 +131,53 @@ struct NoticeListView: View { } } -// MARK: - Row (피그마 4154-3802 기준) -// 흰 배경 + 하단 hairline. 모임장은 leading swipe로 파란 핀 버튼이 드러난다. +// 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 { - textColumn - .padding(20) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(uiColor: .bgPrimary)) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color(uiColor: .appStroke)) - .frame(height: 1) - } + HStack(alignment: .center, spacing: 0) { + leadingIcons + textColumn + .padding(.leading, 8) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .bgPrimary)) + .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 { 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 0000000000000000000000000000000000000000..bdd16a5c4aeac57f2f59ad138b95dd4ac9140fad GIT binary patch literal 286 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$WoCN1 zIEGX(zMXuQw?ToYRsPuSzDF!>>V>A}f#wA)ZUsymeN7%ZtQ5@qB>VdX{{?mtu9XsR z4>-)>>s}Jh_Smb#szk?kqmqs6@m!tAT!+Jv=^I*2`Zo7XVO#lUxnpzxitJn854$W+ zbF_PBdUt8fU$&ycWxbUxD;HihpT{l}#a<%vxRZa6L#@*M<5y=@*LLx5GQaWOf4jz$ aM@(DNwJ&|w|9cDQNCr<=KbLh*2~7ZtG-luc literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cb82204f01581f6c4e2e54c39e9818fc658a2735 GIT binary patch literal 361 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=QW0t1p9eji3a%9CH(HW>ivbW zWU{}Gk?ueJEsbG1oa*wdGLN`+rki^hWCTWBY)X5xHZ1JgzTk=pSC27lHFU`3`S9?m z-PXh2yO)^mJ5gPK|J|C-_K0fXoedo?p4%|6sfP~h{Nl!ObzF*>|?Cn`O`}5qUGcA)jQUp%y2vJQ9 z+cQmai|^cN$!8WB*%VLTa@Tt;Q>gSM_U*o#!T~OV-;LrJ9tsMcXwR6@5?=Rec!D8L>qj}f&X$DMnU@YVl zyEyR>GwU%+j$RSF=BCGgY@DN}7H{umI>OG?SNvtpRHhFz%|XU;*jvm|<=q#t;r*9I zi)V)NN!y=3cz5x+6~7KX)U3~MaJhd+qAC0gR@Wp)HBQ=RVmO)W%KAH(-*3IQ?N8l> wnZ8-3_qAvL`Mhb-{V#<@=hgxPr9tx-+cL)hr5_ykKrzAK>FVdQ&MBb@06zS@6aWAK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3c414007d2d205b80f2e1d3d0a4fba6c9c994c9f GIT binary patch literal 229 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$WfDAH z978G?-%hdQV^HL9wV%9m`^P6;tz|i^ot=B8J?++UNOYK0Xz)QLQRQ}f%88;4t1iUt z_gKGxB~JVSXRg7AA03T{`!$@p8eaR%aXEBo>34-A|N1?A-X3ITvQH|xbBtwzgjedk S4Rs(7F?hQAxvXtL-I$bfzJ8m8GqdFT1-8$^Ze9Hm)m7fbn|aJ^Vi~1Ri3T>!^3 zN~V3AUm9P!2w;wy-!k*N@8%mUU%`~4^b9^8y{M&)>k~v*n@iqm=kn5oj^1wC6I9i z*Nsu_C;K6!rz_+de+pFAd4*M+^cY;{iE>?9Am)1zA0#dLA9Ayp7Q)A-N{r~Beu&oX zNeOZV!UDdO06*15(s)TLvYqrsv)K&t-ciRkuti^R?_9x={x7}zoBJ|}ZQEnD9Ug#3hgjtOE?V|dKP5D7ekn3Uwx7soS z7Wp|tNt9||K-Xez;S=K;S{pSiI%Q^39K`q3Mm@yPb8=pl8+EB9fe2okd{k+v@Y*~c zb?Thj4YU?S^d~bLWBa>I{1Mh zu3ui!8;zC)@$z>(Civh;XB{nywan&Fbrp7X?N|QocFUVjSXB}k3HWcq> z1%wJ96_5_1Q~+<>9;^;RUSWWopOK zj(iokq?L9OrhB}$w$}9>Ku9G8q-Y2NCosp~eBIvO9^|>M(P%_7r|ux${`U6vpxtiY zSpX_0AfqQl8{xtGlXEb=A^cl!(Y23{k6-Z?^E1W7bzO+}%>q)HAc9%e!vh~dxK7$Q z{96xi(Y(Krm}uKxk3`KAjkh1{~bO!M{MXrN*<^U6BmSx`ax}rFla3UU)*FxfXVmD7@4vmw5D9)Lm zwX7tP`G5#2|WHNGIYU2JV_{V)fXz8eD{{2^#UKUrf4GKaSK`eSpT4jt(zYktE;Q~Rsp<4;UtMk^-=TPL>cixa>l}TEUbrE_Szm=-ASpEu zqzNW2pinRhu{`-wN>8LI24-a+LUJ)Ewk4x7iS)#mqPQQxSMJ&WX1y?7nc0nX!JP=9 zXKUq3IaUv|X3caZPumINolOO<4tHnq2eE)uvXo0^O&<$K1Vov4YX3e= z3;}}`^@OI+U0B?Wl{Ow?4JYUxX_^eoYL}lW=xZjwNi=7YKHV+i8c5eX@)kCkGctGR wA8F!MO2UN@LI@#*5JCtcgb+dqA;cTuFS2@>k&aUh!2kdN07*qoM6N<$f^$sDmjD0& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..be02f6ef50358cb9bb17772ec646eacc209fcd30 GIT binary patch literal 1492 zcmZ{ki8~Vv0LC}Duc0KBBc)8qRW?4Yuo1bh%sGjzMepvtjpkRLd?fckv7tze0rt{8{#iSvjF zi%ST`g#v6N(3s$|`r;4(K*GfyZsnN>T;lq#Pky%L$R+KmwV-7|{o9e;FB(}_mp{JvOel6hx|Pi3un zWhyEEU2br*>kg+i^B09eAtNs@&jQ8>Z=Ouev6F9skByDlu~;n9RiB_lQH3{6M=9%{G}#4iQ{8NJQMddpEDYzaM{f+y^%yUMWHg(hL$Md@0J` zUN>0!d#Smks5DgRZVCi~GY+_^Vd9}OBc2%6Wt8$VMnD1aWiB*U(r7fBUac#uYin^l z{F66nOLMYnv%K<~?aj@QJ^3jmDtU@-pwz9KJ>tTF)a)kxm+=`Z#evsQXSK&)iEE33 zhx_}T)XKxjPue#utu44*F6mftM=wm-i2ZnJPf;q`}p(eT6d+Y(J7ZVqPc~m$9o{^UFh1-zB5R-tBrdX(N5H`!R&@ z4k?v-JqzS_TyF74%mBo*2;@WTSy+3R+4Grs+bjNHE=nkX+L^NloWV0+NM1V6*Z1b) zI3>4l9y| z8V-dSZ{fjRhZnG)2HRmnera%K60>cUk}$4PvO6nnBrWzV(J=m9WXGVE1KzLt6dD*6 z(Y;-JY;@gLe*nqU>ksIi>Qgy|>Dp&OsQk~rctOtsY%zU8wcFqybtAK1qw~p(m_;Zz z`l?GPyNH!t8#aMrsz=|9=Tv`)ZA=Q7|Isq9MBnV1dn4QY^)fArY`mxw(0iX8WfL^J z3S*_=TiV;-$?vlwRB9JSqPJBg{Z$xL2P>p-(aVT;Fg`xsq|=6Z1JZmLg`SL5$2QrQ z6{NR?+9h~o>yi|bhXBLCt*?orS3IqNtMVSk}t@bm+$uJj4CEYN@YyrUji29$eEgg4JypL8z-B%>hLU42BXQz=Rlz|BXE7@Lq*3UVo?XA zSY(hzY)O7quci-S zgVw^xC9Y5|ha;lBFTKBUR{l~%E54|-=7YAYD5wZ23X!|$m2%Se!t40VLCWznNASAY zU6PojQs+J$CvvwrF#^Ns-ZV&}f0!d)Vj37gMYl$H-e>#b?OK6~m}fGuAIK7o0`m zD$E2Vw1oBq)^!!QI+*Qhdc1raU2n}^(ZDGIU8o~RW+}CFdQsi&3CCLP%Q5J@YtlZ; z9uXI^Ibd0gtlOs%!X<>7)sg8s)fg#326!l3l|I+i;$_qGoxOal^`r=b}$b-8)E zIEGX(z709(d)Pr>PU$C?L#i|QbSAO;2Qyx9{9`DnU)~VJxF?{2U)Dj(%MPS7^>chZU~!uJi(cg^9{0XuVdDQ@%dWJ^ zNf&>a(waHv<0jX}NjXdT*6=A^+$}ca!`Y%bfAP@5rF{2}nRx5W;gZhXX_0Iuv}>a0 zl8L&eTV#v9cNP8o^nXe2E&htcBe`2L*IEZ8cRk}bneeVJjOV_`Kb^-(zn3=|3TCRM zDSrL&SU~gFq?0ehY(@Vweea6hzNlHOxOK=6!2W!U{ z;tCp8Abmr8D6YWn03V&Pt#Jo+2OtU$G`XI`heKi#61Xuu+}{k7aB?p>_nxnN01O6$ z!C){LHiobx9G1VoUrxvrQy;1D3HHIMy1MDo6*Q4(0ohsBt-np0 zi{P@xaRN;wE?_kfHBu>$(SRnBT}Z$3GZf7%+Fq3{T(uc|JqGvtXeDK*Vk)u1ldy>6 zLn?ALI1DIFX89~OQx#H)WoIMA8(F&$3$P3Q2+J&mV}x9Y6Ua~sT+(kPFF^55<#1l* z1^kmnzkVQ>FDJ-Iq6>MYS)gSu?}_l4$A5_bQwLl=fQf0@2re|iyWiU6%oE4jAQsbJ zcj+ltPm&LdKK^}3=!wj;@Hilk!Q-aownIH=L`xPDOYuS)%3>d87l|D=_j9di%|eUa z?H!&fhjaM(%A;Y_csY9in#{+Q2^VX7h6r-01MG6n(yc6ZGmnB*cZqal+{oMV;R9j+ z=uP(1YHoGFo$qEN!<4Fjk8jV|dbeF_rJa=B0xFPJhu8w?$oLXSshlMoX^fDP%oYf@ zf!KmYNA@BjW>T{=e{9vb_t;Z7=Xm(eG1P{^U@#aA21EY*0YmoVPq+%+$p8QV07*qo IM6N<$g3zT={{R30 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0432e82e260b7f1f56acdf18b5c4e69b1d8ba8bd GIT binary patch literal 1046 zcmV+x1nK*UP)=|Y9h*q$z#+ur4S)%1>37Zxa8 zAdWt_Kk=0u>egT%J!ETrM>i&2OD6PY;Aa$xDlXN%~Fo=JVT^e&l<~u>(wi zM$j<~sGJW{8eY;d_AP7M`bH(?X%0FUC#nMjOaUcWJ?P?%XRO0^ z#wgSVC8zp1I{uoDyO;)^p;*KoN?__J7WsnCV0(RAwwqqdA*5L0L##3mBWRT(*Z8`C$_Qh1`s4@;qV%U?jfOoyM9(h^1Qo#KRVg^I@} zP1)>@n;lo)lYZkdzUCOG^iqSf-*K~}O!y#6TY_7^55DFIaLt0lA~R8o4b5%|&v+Se z?5V`GewRtyLkbv&HM1$~L}sd3&%v@k66(5-%>Sfe5B1lZv9U3Uwib)1j|e^1wjx5K@bE%5ClOG1Yz#@3z^PS)Jj~i QRsaA107*qoM6N<$f(6IWk^lez literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..529f0e6a7cc61c430ea7e11d98946309f2f4d72f GIT binary patch literal 421 zcmV;W0b2fvP)TH1Pf|G^?5@};E_07;T0Nm`Nz7R&Q|69mDvZQC7wy*EvB zilV4e_z8PJ!uh`cz{AbxbcM*h3epPj2X06S>4V{Ba8$SKz|1I+f$Qp@Nmj;yTwGrU z%!~jPar6u(z-;j*(ZIRe#kO&jHqZr*(gM1|QOZDa^}_L4*ERdCM3N*)lK#^N=9Iw&Dd7%y P00000NkvXXu0mjf->t0j literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4fdf1533c82e3f1fb8712c38d775bc58b6961f1 GIT binary patch literal 666 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)oCO|{#S9E$svykh8Km+7D9BhG zve_amrP_oKC0V&m#=bXmWkA5ozrr^ z{{EZ9R3KYmH{U+bY@P?EVnhOUnt>qXg@=)7u}_3>NO5}jKOY#&=* zee`#cyr3cXF@4{JO)r?Fj#WSU{n$K6>}qOH;T}WD4z&fxE0nq#;vRRNVy@~_*W!I) zJ8y+fg6yMCFQ#j%^El)-_UZw~7LaXnrAT-G@yGywqLdlrHK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4610298d7555a38903aa1dade90f737a3f0813f GIT binary patch literal 903 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^b&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=AZ3)xr4CTy=x|F^zN=uzRX>}fZzw*c+J1eY#xYM9LQ=}X^ybHVMm`{LJz ziGRAZ(Pr5viBsA4{r&vf4jM9vOZ2+gc2;QnSI@S){IX=<+SvMVqUdCKukB%H6{4!iS3R<(D@fk2}8B8%F)(~CHK?wpO1pRwa;l$7tc9!cA1*^`(uS(Y~vhgtzay22@xUB{*Zp?P8$9-GxAfyNjh~?D+lA$e{51 z@u#A)LgD$}U$9yS9tqWBmY0={m5%F_@Qr!;%{(G}`^@vtx8FQv=G&>YuE@4K&!VWL zaO&^B589O6CBwHq@(4RSadGeJGwPAMyiMf#^SkUWzyH3xy}do$)MwA4zSU=#PwQQD zEmN<#JaKV9kmr5p^qc(26DN19w&{8B-`w3?#B%12Q-?YBReY+SJ8^Q?Y9D6r6E&6h z+_Spo#KfdZML2!qx_3MMiOOTG*9WGr-?P8}#K~i!%Nnn%N6daBVfk{$sjnPyzwek$ zoD4LbDP4HYrByD|mhbQQ-G6gqMyKh74D-`}E$I$9%Q6@}73~w>;;X%})~@6gM9bdeMDED@x~gq)*z> zt25&+DysjsKb`-Pz4F%Sq<`0Du(Pw58wHz1-xtJ8m<$XH_Womb}$b*Xu} zIEGX(z74s^+hibM%f)Qbr0mg9KgZ#&gLwgSA@f}ZvkRPW%qKkH=y|}@@LtHoWu-`u zq1wKdFJ9~4Pu=a_`R|7r&)g*{o;8X`cpl31>zp?FWaIzpO(@rqLK{Qh;;_)GQkO4Y zb~}8+P)1?T_FU`tsuPyI|&89DmST6RCk=M#AnbFAYR#}-oo6=~MWJ|nMJ$_+&^CAl0q@v)*_ z;tMzK))R~?mr<{odbx6T(c7Iixlgw4exb|Awwt||PvFzk+I1&Y|9%wS!K8J$^SYd8 S&Szj)GI+ZBxvXxz%$m*Sm=tj`FrUvm7~PvVj(h0V ziu?yAHfYR#zn`?*Z9|TT4e)5=<~-6p2(Oa^YzEgFRjbvhZHN^%fYBZ#aowSEFu49i z8e)YFEEbEHEI1rw`0d%+}KfcODP zgapADi$I@*i1q=98WlLFE){b|>l>!wYq=D?tm{IU&@c2|$Z}zXjY~XVPH`%1!NeHNJ=rly(rPE1ve87qk1JCK zD&iUEbecmzFef6M`=}+JQL9e#4vYftkn%&y@Cv3~r7okqelUWy{hIq2@fki-jY6)+ ero;>5XYNG0A2=M25j;83=!x>AQyoeftrD2 zfEh>x?q?o_mjM~v-gh4NZ*Hmb5ca`z&#qSgsw~jm1@`AmPfyQ4WEh5F7=~dOhGF=o zLIpFOPJ7jA^%767@Dpw}o99la6H-YiM9^e1Y1V4B%k6f1i(fAek5?*{B_3WNZWsi? zk_yC|pv7Wwv0AMLxOr|Zy&RW@xaJwM!Xjj;3*s(>8@$KUCLL)ldBE$8(CWtRcKd}q zp(1D>S8y`}a+Gq6xC@5s^?KOtcHc-5F+tpg5MXa0rAPU?kQ1@2N3AaCBq)}01u56$ zQAz^4c-3mPMkIujpxJEpn-sc`01@KUHxi&qZj@0DBlfvm1$}u;Pzoj}1rwBl2};2P zrSNG4#Tm1AK7}C2`H87SfTybSu49y4P=skrpQjF(XGN$X^~tlOG7E|@&+EUhE*2rF ztb%rOv`ZOT@F^(Ddfq8HTs=Sy=#~Bz-@M>O5RX9U8UY8}lv7J3hfx>Aaxen*xtPVl zHbGL7oCc09$g>>ARZwELc`s-Gw-b`m4CkDb#9}nM6|Vb3Lb$e|ta-{wNrwo@Gzd^A}E3)%lcawx~>D7q|N zCr5BoJY-+zyrvOL;)y3o<~h85h6E8=dOmjnabEBhG7_nVHk!{JbwX_^ww!+r2@YI0000 Date: Thu, 11 Jun 2026 17:11:30 +0900 Subject: [PATCH 15/26] =?UTF-8?q?fix:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EA=B3=A0=EC=A0=95=20=EC=8B=9C=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=B4=20.custom=EC=9C=BC=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인 - MockTogglePinNoticeUseCase가 응답으로 type: .custom을 항상 반환 - ViewModel에서 응답을 통째로 로컬 배열에 덮어써서 시스템 공지가 고정되면 type이 .custom으로 바뀌고 megaphone 아이콘이 표시됨 수정 - togglePin은 의미상 isPinned/version 외 필드를 바꾸지 않으므로 응답에서 isPinned/version만 머지하고 type/content/createdAt 등은 기존 Notice 값 보존 - Mock의 잘못된 응답 + 실제 서버 응답이 일부 필드를 누락해도 안전 --- .../Sub/Notice/View/NoticeListViewModel.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift index 08a69ebd..83d7d081 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift @@ -134,7 +134,13 @@ final class NoticeListViewModel: ObservableObject { } // MARK: - Pin Toggle - // 모임장이 swipe action으로 호출. 응답 받은 Notice로 로컬 상태 교체. + // 모임장이 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 @@ -145,7 +151,16 @@ final class NoticeListViewModel: ObservableObject { isCurrentlyPinned: notice.isPinned ) if let idx = self.notices.firstIndex(where: { $0.noticeId == updated.noticeId }) { - self.notices[idx] = updated + let existing = self.notices[idx] + self.notices[idx] = Notice( + noticeId: existing.noticeId, + version: updated.version ?? existing.version, + meetId: existing.meetId, + type: existing.type, + content: existing.content, + isPinned: updated.isPinned, + createdAt: existing.createdAt + ) } } catch { self.errorMessage = error.localizedDescription From d4ced784b6dbf1549a9d0e2e540605744363bcfe Mon Sep 17 00:00:00 2001 From: CatSlave Date: Thu, 11 Jun 2026 17:27:47 +0900 Subject: [PATCH 16/26] =?UTF-8?q?fix:=20FetchMeetDetail=EC=97=90=EC=84=9C?= =?UTF-8?q?=20verifyCreator=20=EB=88=84=EB=9D=BD=20=E2=80=94=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20isC?= =?UTF-8?q?reator=3Dfalse=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상 (라이브 모드) - 모임장으로 모임 상세 진입해도 "공지를 작성해보세요" 툴팁 미노출 - 공지 리스트 우상단 연필(작성) 버튼 미노출 원인 - 서버 MeetResponse에 isCreator 필드가 없어 MeetResponse.toDomain()이 isCreator=false(default)로 시작 - FetchMeetPageUseCase는 verifyCreator(currentUserId == creatorId) 호출로 isCreator를 채우는데, FetchMeetDetailUseCase는 그 호출이 누락 - Mock은 isCreator: true 하드코딩이라 dev 모드에선 정상 동작했음 - 결과: 라이브 모드 모임장이라도 isCreator=false 고정 → 모임장 전용 UI 미노출 수정 - FetchMeetDetailUseCase에 UserSessionProvider 주입 - execute 시 verifyCreator(with:) 호출하여 currentUserId와 creatorId 비교로 isCreator 채움 (FetchMeetPage와 동일 패턴) - DI 컨테이너(MeetDetailSceneDIContainer)에서 userSession 전달 --- .../UseCases/Meet/Read/FetchMeetDetail.swift | 17 +++++++++++++++-- .../Sub/MeetDetailSceneDIContainer.swift | 4 ++-- .../Sub/Notice/View/NoticeListView.swift | 1 - 3 files changed, 17 insertions(+), 5 deletions(-) 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/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/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift index c7511a79..a28cf30e 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -38,7 +38,6 @@ struct NoticeListView: View { Image(.pencil) .resizable() .scaledToFit() - .frame(width: 24, height: 24) } .frame(width: 40, height: 40) } From b6efc6960f85599dfccc0928b18b2e7b5deac641 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Thu, 11 Jun 2026 17:40:31 +0900 Subject: [PATCH 17/26] =?UTF-8?q?refactor:=20Meet=20isCreator=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=20=ED=8C=A8=ED=84=B4=EC=9D=84=20UseCase=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 상태 - FetchMeetPageUseCase만 verifyCreator로 isCreator 채움 - FetchMeetDetail / CreateMeet / EditMeet UseCase는 verifyCreator 누락 - MeetSetupViewReactor는 자체적으로 UserInfoStorage 비교 - 두 패턴(UseCase / Reactor)이 혼재 — 일관성 없음 통일 방향 - 단일 진실 소스: Meet.isCreator - UseCase 레이어가 채우는 책임 (FetchMeetPage와 동일 패턴) - Reactor/ViewModel은 meet.isCreator를 그대로 신뢰 변경 - CreateMeetUseCase: UserSessionProvider 주입 + verifyCreator 추가 - EditMeetUseCase: UserSessionProvider 주입 + verifyCreator 추가 - ViewDIContainer: userSession 전달받도록 생성자 확장 - AppDIContainer: ViewDIContainer 생성 시 userSession 전달 - MeetSetupViewReactor.setMeetInfo: UserInfoStorage 직접 비교 제거, meet.isCreator 그대로 사용 영향 - 라이브 모드에서 새 모임 생성/수정 후 모임장 인식 정상화 (이전에 잠재 버그 — Mock이 isCreator: true 박혀있어 dev에선 안 드러났음) - TransferMeetViewModel은 양도 후 isCreator=false 명시 세팅 그대로 유지 (양도 직후 본인은 모임장 아님 — 의미적으로 옳음) --- .../UseCases/Meet/Write/CreateMeet.swift | 17 +++++++++++++++-- .../Sources/UseCases/Meet/Write/EditMeet.swift | 15 +++++++++++++-- .../DIContainer/AppDIContainer.swift | 3 ++- .../ViewControllerDI/ViewDependencies.swift | 17 ++++++++++------- .../MeetSetup/MeetSetupViewReactor.swift | 8 ++++---- 5 files changed, 44 insertions(+), 16 deletions(-) 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/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/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/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]) } } From 318e23bbcab29836b105f201d42b8fc6c24447f6 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Thu, 11 Jun 2026 18:01:45 +0900 Subject: [PATCH 18/26] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=ED=88=B4=ED=8C=81/=ED=95=80=20=ED=86=A0=EA=B8=80/?= =?UTF-8?q?=EB=A9=94=EA=B0=80=ED=8F=B0=20=EB=B0=B0=EC=A7=80=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 작성 유도 툴팁 — 모임별 첫 진입 1회만 표시 - NoticeTooltipMemory(UserDefaults)로 meetId별 seen 플래그 저장 - applyMeet에서 hasSeen 체크 후 첫 노출 시 즉시 markSeen 2. 확성기 파란 점 배지 — 향후 개발 예정으로 일단 비활성화 - megaphoneBadge.isHidden = true 고정 - 서버에 unread 신호가 추가되면 hasNotice 분기 복원 예정 3-4. 핀 토글 → 모임상세 pinnedNotice 자동 반영 - NotificationManager에 NoticePayload(.created/.updated/.deleted) 추가 - NoticeListViewModel.togglePin 후 NoticePayload.updated 발행 - MeetDetailViewReactor가 addNoticeObservable 구독 · 핀(isPinned=true): 기존 pinnedNotice 유무 무관하게 새 공지로 교체 · 해제(isPinned=false): - 메인의 pinnedNotice가 같은 noticeId면 nil 처리 - 다른 공지면 변경 없음 - Meet 엔티티에 with(pinnedNotice:) copy 헬퍼 추가 (pinnedNotice가 let이라 부분 갱신 불가 → 새 인스턴스로) --- .../Domain/Sources/Entities/Meet/Meet.swift | 17 +++++++++++++ .../Notification/NotificationManager.swift | 9 +++++++ .../View/MeetDetailViewController.swift | 18 ++++++++++--- .../View/MeetDetailViewReactor.swift | 23 ++++++++++++++++- .../MeetDetail/View/NoticeTooltipMemory.swift | 25 +++++++++++++++++++ .../Sub/Notice/View/NoticeListViewModel.swift | 5 +++- 6 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 Mople/Presentation/MainScene/Sub/MeetDetail/View/NoticeTooltipMemory.swift 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/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/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 97f700eb..6148e661 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -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 5dfd13f7..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): @@ -216,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/View/NoticeListViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift index 83d7d081..ed0b51d9 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift @@ -152,7 +152,7 @@ final class NoticeListViewModel: ObservableObject { ) if let idx = self.notices.firstIndex(where: { $0.noticeId == updated.noticeId }) { let existing = self.notices[idx] - self.notices[idx] = Notice( + let merged = Notice( noticeId: existing.noticeId, version: updated.version ?? existing.version, meetId: existing.meetId, @@ -161,6 +161,9 @@ final class NoticeListViewModel: ObservableObject { isPinned: updated.isPinned, createdAt: existing.createdAt ) + self.notices[idx] = merged + // 모임상세 화면이 구독해서 pinnedNotice를 갱신할 수 있도록 알림 발행 + NotificationManager.shared.postItem(NoticePayload.updated(merged), from: self) } } catch { self.errorMessage = error.localizedDescription From c39ee6dafcc6aad63c0c8c4c072d0285f0d0da6d Mon Sep 17 00:00:00 2001 From: CatSlave Date: Fri, 12 Jun 2026 14:16:07 +0900 Subject: [PATCH 19/26] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80/=ED=9B=84?= =?UTF-8?q?=EA=B8=B0=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 후기 추천 알럿 게시글별 최초 1회만 노출 (UserDefaults) - 댓글 최신순 정렬에서 새 댓글 상단 추가 - 대댓글 페이지 부모 댓글 상단 고정 표시 - 대댓글 페이지 당겨서 새로고침 정상 종료 - 대댓글 생성/삭제 시 부모 댓글 답글 수 반영 - 메인 댓글 수에서 대댓글 제외 (부모 댓글 수만 표시) - 대댓글 페이지 부모 댓글 좋아요 메인 반영 - 댓글 전송 후 키보드 내림 --- .../ReviewSuggestionStorage.swift | 31 +++++++ .../PostDetailFlowCoordinator.swift | 12 +++ .../CommentListViewController.swift | 55 +++++++++++- .../CommentListViewReactor.swift | 84 +++++++++++++++++-- .../View/PostDetailViewController.swift | 7 +- 5 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 Modules/Data/Sources/PersistentStorages/UserDefaults/ReviewSuggestionStorage.swift 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/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() From c829a2b7e7b0c00d0e2b07672ef190dfe89e2ae3 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Fri, 12 Jun 2026 14:18:01 +0900 Subject: [PATCH 20/26] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=201.4.1?= =?UTF-8?q?=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.swift b/Project.swift index e35ab216..0df1c6da 100644 --- a/Project.swift +++ b/Project.swift @@ -5,7 +5,7 @@ import ProjectDescription // MARK: - 공통 설정 -let marketingVersion = "1.4.0" +let marketingVersion = "1.4.1" let currentProjectVersion = "8" let developmentTeam = "LNXWGGBBH6" let deploymentTarget: DeploymentTargets = .iOS("17.6") From 81c6725995c802fc7572c576892263428b635608 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Sun, 28 Jun 2026 16:12:41 +0900 Subject: [PATCH 21/26] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=ED=83=80?= =?UTF-8?q?=EA=B2=9F=20iOS=2018.0=EB=A1=9C=20=EC=83=81=ED=96=A5=20(SwiftUI?= =?UTF-8?q?=20ScrollPosition/onScrollGeometryChange=20=EC=82=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.swift b/Project.swift index 0df1c6da..1c569c6e 100644 --- a/Project.swift +++ b/Project.swift @@ -8,7 +8,7 @@ import ProjectDescription 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 @@ -298,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"), From a0587714e3b287eebc5eda5e3f29cfeac369fdaa Mon Sep 17 00:00:00 2001 From: CatSlave Date: Sun, 28 Jun 2026 16:12:42 +0900 Subject: [PATCH 22/26] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=9E=90(writer)=20=EC=9D=91=EB=8B=B5=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(Notice.writer=20+=20NoticeResponse=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Network/Response/Notice/NoticeResponse.swift | 2 ++ Modules/Domain/Sources/Entities/Notice/Notice.swift | 3 +++ Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift | 1 + 3 files changed, 6 insertions(+) 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/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/UseCases/Notice/FetchNoticeList.swift b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift index 096dbd33..c8951905 100644 --- a/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift +++ b/Modules/Domain/Sources/UseCases/Notice/FetchNoticeList.swift @@ -50,6 +50,7 @@ public final class MockFetchNoticeListUseCase: FetchNoticeList { 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) )) From c7c2489822191219ec35da3eb8d78d35746f8ac0 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Sun, 28 Jun 2026 16:12:42 +0900 Subject: [PATCH 23/26] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=EC=A1=B0=ED=9A=8C(GET=20/notice/detail/{noticeId})?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Endpoint/APIEndpoints.swift | 18 +++++-- .../Notice/DefaultNoticeRepo.swift | 7 +++ .../Repositories/Notice/NoticeRepo.swift | 3 ++ .../Sources/UseCases/Notice/FetchNotice.swift | 47 +++++++++++++++++++ .../Sub/NoticeSceneDIContainer.swift | 10 ++++ 5 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 Modules/Domain/Sources/UseCases/Notice/FetchNotice.swift diff --git a/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift b/Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift index 74901bc3..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()) @@ -627,6 +627,14 @@ extension APIEndpoints { 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 { diff --git a/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift b/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift index 8c21af97..83ba415a 100644 --- a/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift +++ b/Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift @@ -24,6 +24,13 @@ public final class DefaultNoticeRepo: BaseRepositories, NoticeRepo { 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 { 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/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/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift index 23211437..02b72872 100644 --- a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift @@ -71,6 +71,15 @@ private extension NoticeSceneDIContainer { #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, @@ -183,6 +192,7 @@ extension NoticeSceneDIContainer { let viewModel = NoticeDetailViewModel( notice: notice, isCreator: isCreator, + fetchNoticeUseCase: makeFetchNoticeUseCase(repo: noticeRepo), fetchCommentsUseCase: makeFetchNoticeCommentListUseCase(repo: noticeRepo), createCommentUseCase: makeCreateNoticeCommentUseCase(repo: noticeRepo), editCommentUseCase: makeEditCommentUseCase(repo: commentRepo), From 8dd1e1389f89155a58d14deec32cddcf72a0ff17 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Sun, 28 Jun 2026 16:13:01 +0900 Subject: [PATCH 24/26] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9D=BC=EC=A0=95/=EB=A6=AC=EB=B7=B0=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20+=20=ED=82=A4=EB=B3=B4=EB=93=9C/=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 정렬(상단 삽입)·페이지네이션·서버 totalCount·수정 포커스·본문 갱신·Kingfisher·pull-to-refresh·로딩셀·L10n·수정모드 취소버튼 제거 등 일정/리뷰와 정합화 - 키보드: iOS18 ScrollPosition/onScrollGeometryChange로 규칙1/2 구현, 입력바 fixedSize, 스크롤 보정 타이밍 수정 - 새로고침: isLoading(블로킹 로더)이 refresh 제스처를 취소시키는 문제 → showLoadingIndicator 분리 + .handled 에러 필터 (상세/목록 둘 다) - 단건 조회 연결: 진입/새로고침 시 공지 본문도 fresh fetch (loadNotice) - 공지 작성자(writer) 표시 + 본문 수정 즉시 반영 --- .../Notice/View/NoticeComposeViewModel.swift | 16 +- .../Sub/Notice/View/NoticeDetailView.swift | 159 +++++++++++++++--- .../Notice/View/NoticeDetailViewModel.swift | 105 ++++++++++-- .../Sub/Notice/View/NoticeListView.swift | 3 +- .../Sub/Notice/View/NoticeListViewModel.swift | 16 +- .../Localization/ko.lproj/Localizable.strings | 5 + 6 files changed, 257 insertions(+), 47 deletions(-) diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift index aa579c5b..972da848 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeViewModel.swift @@ -78,18 +78,24 @@ final class NoticeComposeViewModel: ObservableObject { 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 } - _ = try await self.updateUseCase.execute(noticeId: noticeId, - meetId: meetId, - content: text) + updatedNotice = try await self.updateUseCase.execute(noticeId: noticeId, + meetId: meetId, + content: text) } self.didSubmit = true - // 리스트/상세 갱신 트리거 - NotificationCenter.default.post(name: .noticeUpdated, object: nil) + // 리스트 reload 트리거 + (수정 시) 상세 본문 즉시 갱신용 payload 전달 + NotificationCenter.default.post( + name: .noticeUpdated, + object: nil, + userInfo: updatedNotice.map { ["notice": $0] } + ) // 작성/수정 완료 후엔 modal 전체 종료가 아니라 한 단계 pop만 (list/detail 복귀) self.coordinator?.popCurrentView() } catch { diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift index 28bff9b8..d4384516 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -7,6 +7,7 @@ import SwiftUI import Domain +import Kingfisher // 공지 상세. type에 따라 두 모드: // - .custom (모임공지): 본문 카드 + 댓글 섹션 + 하단 입력바 @@ -17,6 +18,22 @@ struct NoticeDetailView: View { @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) @@ -26,6 +43,8 @@ struct NoticeDetailView: View { private var navigationTitle: String { isSystem ? "시스템" : "상세보기" } var body: some View { + // 입력바를 스크롤뷰 '바깥' 형제로 두는 게 핵심 — + // safeAreaInset/스크롤 내부에 두면 포커스 시 SwiftUI가 자동 스크롤해 위 뷰가 사라진다. VStack(spacing: 0) { ScrollView { VStack(spacing: 0) { @@ -37,11 +56,36 @@ struct NoticeDetailView: View { } } } + .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, @@ -51,6 +95,43 @@ struct NoticeDetailView: View { } 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) @@ -105,8 +186,8 @@ struct NoticeDetailView: View { } } - // 시스템 공지는 Mople 앱 로고. 일반 공지는 도메인에 작성자 정보가 없어 - // 일단 기본 프로필(.defaultUser)을 사용 (백엔드에서 writer 응답 추가되면 교체). + // 시스템 공지는 Mople 앱 로고. 일반 공지는 백엔드 writer의 프로필 이미지를 Kingfisher로 로딩. + // writer 이미지가 없으면 기본 프로필(.defaultUser)로 폴백. @ViewBuilder private var authorAvatar: some View { if isSystem { @@ -116,16 +197,25 @@ struct NoticeDetailView: View { .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { - Image(.defaultUser) - .resizable() - .scaledToFit() - .frame(width: 32, height: 32) - .clipShape(Circle()) + 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 { - isSystem ? "Mople" : "모임공지" + if isSystem { return "Mople" } + return viewModel.notice.writer?.name ?? "모임공지" } // MARK: - 댓글 섹션 (custom 전용) @@ -136,7 +226,7 @@ struct NoticeDetailView: View { .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) .foregroundColor(Color(uiColor: .text01)) Spacer() - Text("\(viewModel.comments.count)개") + Text("\(viewModel.totalCount)개") .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) .foregroundColor(Color(uiColor: .text03)) } @@ -144,6 +234,11 @@ struct NoticeDetailView: View { .padding(.top, 28) .padding(.bottom, 8) + // 새 댓글 작성 중 로딩 표시 (일정/리뷰의 로딩 셀과 동일하게 상단에 노출, 수정 중에는 표시 안 함) + if viewModel.isSubmitting && !viewModel.isEditingComment { + submittingRow + } + ForEach(viewModel.comments, id: \.id) { comment in NoticeCommentRow( comment: comment, @@ -155,11 +250,23 @@ struct NoticeDetailView: View { viewModel.showCommentMenu(for: comment) } ) + // 마지막(가장 오래된) 댓글이 보이면 다음 페이지 로드 + .onAppear { viewModel.loadMoreIfNeeded(currentItem: comment) } } } .background(Color(uiColor: .bgPrimary)) } + // 댓글 작성 중 로딩 셀 + 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 버튼 노출. @@ -178,11 +285,15 @@ struct NoticeDetailView: View { } .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) + // "댓글 수정중" 뱃지 (UIKit editLabel: 25x70, cornerRadius 4, bgSecondary, Body2.medium, text03) + // 일정/리뷰 댓글과 동일하게 라벨만 노출 — 취소는 입력창 바깥 탭으로 처리(디자이너 스펙에 취소 버튼 없음) private var editLabelRow: some View { HStack(spacing: 8) { Text("댓글 수정중") @@ -191,12 +302,6 @@ struct NoticeDetailView: View { .frame(width: 70, height: 25) .background(Color(uiColor: .bgSecondary)) .cornerRadius(4) - Button("취소") { - viewModel.cancelCommentEdit() - inputFocused = false - } - .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body2)) - .foregroundColor(Color(uiColor: .text02)) Spacer(minLength: 0) } } @@ -259,6 +364,13 @@ struct NoticeDetailView: View { } } +// 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 탭 → 메뉴 시트. @@ -273,7 +385,7 @@ private struct NoticeCommentRow: View { .onTapGesture { onProfileTap() } VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - Text(comment.writerName ?? "익명") + Text(comment.writerName ?? L10n.nonName) .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.body1)) .foregroundColor(Color(uiColor: .text01)) Text(relativeTime) @@ -309,13 +421,13 @@ private struct NoticeCommentRow: View { private var avatar: some View { Group { if let path = comment.writerThumbnailPath, let url = URL(string: path) { - AsyncImage(url: url) { image in - image.resizable().scaledToFill() - } placeholder: { - Image(.defaultUser).resizable() - } + // 일정/리뷰와 동일하게 Kingfisher로 캐싱 로딩 + KFImage(url) + .placeholder { Image(.defaultUser).resizable().scaledToFill() } + .resizable() + .scaledToFill() } else { - Image(.defaultUser).resizable() + Image(.defaultUser).resizable().scaledToFill() } } .frame(width: 32, height: 32) @@ -327,3 +439,4 @@ private struct NoticeCommentRow: View { 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 index bb68fc52..86c0c80d 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Domain +import Data // DataRequestError.isHandledError 사용 (취소/이미 처리된 에러 필터) // 공지 상세 화면의 상태 머신. // 진입 시점에 Notice 객체를 받아 본문을 즉시 그리고, 댓글만 별도 API로 로드한다. @@ -24,6 +25,8 @@ final class NoticeDetailViewModel: ObservableObject { // 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 @@ -42,6 +45,7 @@ final class NoticeDetailViewModel: ObservableObject { } // MARK: - Dependencies + private let fetchNoticeUseCase: FetchNotice private let fetchCommentsUseCase: FetchNoticeCommentList private let createCommentUseCase: CreateNoticeComment private let editCommentUseCase: EditComment @@ -50,9 +54,13 @@ final class NoticeDetailViewModel: ObservableObject { 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, @@ -62,6 +70,7 @@ final class NoticeDetailViewModel: ObservableObject { coordinator: NoticeFlowCoordination?) { self.notice = notice self.isCreator = isCreator + self.fetchNoticeUseCase = fetchNoticeUseCase self.fetchCommentsUseCase = fetchCommentsUseCase self.createCommentUseCase = createCommentUseCase self.editCommentUseCase = editCommentUseCase @@ -70,6 +79,9 @@ final class NoticeDetailViewModel: ObservableObject { self.deleteNoticeUseCase = deleteNoticeUseCase self.coordinator = coordinator + // 진입 시 공지 본문을 fresh하게 다시 받는다 (목록에서 건네받은 값은 stale일 수 있음) + Task { await self.loadNotice() } + // 시스템 공지는 댓글이 없으니 fetch 스킵 if notice.type != .system { Task { await self.loadComments() } @@ -80,25 +92,52 @@ final class NoticeDetailViewModel: ObservableObject { } // MARK: - Notification (공지 수정 완료 시 본문 갱신) + // Compose에서 수정 완료 시 userInfo["notice"]에 갱신된 Notice를 실어 발행한다. + // 같은 공지면 본문을 즉시 교체해 stale 표시를 막는다 (일정/리뷰의 수정 즉시 반영과 동일). private func observeNoticeUpdate() { - NotificationCenter.default.addObserver(forName: .noticeUpdated, - object: nil, - queue: .main) { [weak self] _ in - // 현재 본문을 그대로 두면 stale. 일단 dismiss하지 않고 placeholder 유지. - // 보다 정확한 갱신은 list로 돌아간 후 fetch에 맡김. - _ = self + 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 { - NotificationCenter.default.removeObserver(self) + 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 - func loadComments() async { + // showLoadingIndicator: 전체 화면 로딩 오버레이(customNavigationBar isLoading) 표시 여부. + // 초기 로드는 true, 당겨서 새로고침은 false(.refreshable 자체 스피너가 있어 오버레이가 겹치면 화면이 깨져 보임). + func loadComments(showLoadingIndicator: Bool = true) async { guard let noticeId = notice.noticeId else { return } - isLoading = true - defer { isLoading = false } + if showLoadingIndicator { isLoading = true } + defer { + if showLoadingIndicator { isLoading = false } + } do { let page = try await fetchCommentsUseCase.execute(noticeId: noticeId, @@ -106,6 +145,39 @@ final class NoticeDetailViewModel: ObservableObject { 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 @@ -135,7 +207,9 @@ final class NoticeDetailViewModel: ObservableObject { let created = try await self.createCommentUseCase.execute(noticeId: noticeId, content: text, mentions: []) - self.comments.append(created) + // 댓글은 최신순 — 새 댓글을 상단에 삽입 (일정/리뷰와 동일) + self.comments.insert(created, at: 0) + self.totalCount += 1 self.inputText = "" } catch { self.errorMessage = error.localizedDescription @@ -183,6 +257,7 @@ final class NoticeDetailViewModel: ObservableObject { 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() @@ -220,7 +295,7 @@ final class NoticeDetailViewModel: ObservableObject { } SheetManager.shared.showSheet(actions: [editAction, deleteAction]) } else { - let reportAction = DefaultSheetAction(text: "신고하기", image: .report) { [weak self] in + let reportAction = DefaultSheetAction(text: L10n.Report.comment, image: .report) { [weak self] in self?.reportComment(comment) } SheetManager.shared.showSheet(actions: [reportAction]) @@ -232,16 +307,16 @@ final class NoticeDetailViewModel: ObservableObject { // 모임원: "신고하기" (공지 신고 API 미구현 — placeholder Toast) func showPageMenu() { if isCreator { - let editAction = DefaultSheetAction(text: "공지 수정", image: .editPlan) { [weak self] in + 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: "공지 삭제", image: .delete) { [weak self] in + 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: "신고하기", image: .report) { [weak self] in + let reportAction = DefaultSheetAction(text: L10n.Notice.report, image: .report) { [weak self] in self?.reportNoticePlaceholder() } SheetManager.shared.showSheet(actions: [reportAction]) diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift index a28cf30e..f4cd7d24 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -123,8 +123,9 @@ struct NoticeListView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .background(Color(uiColor: .bgPrimary)) + // 당겨서 새로고침은 블로킹 로더(isLoading)를 끈다 — 켜면 터치 차단이 refresh 제스처를 취소시킴. .refreshable { - await viewModel.loadInitial() + await viewModel.loadInitial(showLoadingIndicator: false) } } } diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift index ed0b51d9..177c729b 100644 --- a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Domain +import Data // DataRequestError.isHandledError 사용 (취소/이미 처리된 에러 필터) // 공지 리스트 화면의 상태 머신. // API 1회 호출로 전체 공지를 모두 받아두고, 탭 전환은 로컬 필터링으로만 처리한다 (명세 요구사항). @@ -101,11 +102,17 @@ final class NoticeListViewModel: ObservableObject { private var updateToken: NSObjectProtocol? // MARK: - Loading - func loadInitial() async { + // showLoadingIndicator: 전체화면 블로킹 로더(customNavigationBar isLoading) 표시 여부. + // isFetching(중복 요청 guard)은 항상 동작. 당겨서 새로고침은 false — 블로킹 로더가 터치를 막아 + // 진행 중인 refresh 제스처를 취소시키는 것을 방지(상세 화면과 동일 패턴). + func loadInitial(showLoadingIndicator: Bool = true) async { guard !isFetching else { return } isFetching = true - isLoading = true - defer { isFetching = false; isLoading = false } + if showLoadingIndicator { isLoading = true } + defer { + isFetching = false + if showLoadingIndicator { isLoading = false } + } do { let page = try await fetchListUseCase.execute(meetId: meetId, @@ -114,6 +121,8 @@ final class NoticeListViewModel: ObservableObject { 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 } @@ -158,6 +167,7 @@ final class NoticeListViewModel: ObservableObject { meetId: existing.meetId, type: existing.type, content: existing.content, + writer: existing.writer, isPinned: updated.isPinned, createdAt: existing.createdAt ) 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" = "검색"; From d47299c0c8fbc6599aebaa9577387b4168f2940e Mon Sep 17 00:00:00 2001 From: CatSlave Date: Sun, 28 Jun 2026 16:13:01 +0900 Subject: [PATCH 25/26] =?UTF-8?q?fix:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=9D=91=EB=8B=B5=20=EC=83=81=ED=83=9C=EC=BD=94?= =?UTF-8?q?=EB=93=9C/=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20(Rx=E2=86=92async=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=8B=9C=20=EB=88=84=EB=9D=BD=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Network/Protocols/NetworkService.swift | 1 + Mople/Infrastructure/Network/Service/NetworkService.swift | 8 ++++++++ 2 files changed, 9 insertions(+) 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/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] { From 6696dff7d7a5df677b10b8b32bdb3761ceeeb016 Mon Sep 17 00:00:00 2001 From: CatSlave Date: Sun, 28 Jun 2026 16:13:01 +0900 Subject: [PATCH 26/26] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94/?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=95=84=EC=9D=B4=EC=BD=98=20+=20?= =?UTF-8?q?=ED=94=BC=EC=BB=A4=20=EB=9D=BC=EB=B2=A8=20=EB=8B=A4=ED=81=AC?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Uikit/Modal/DefaultPickerView.swift | 2 +- .../reply_comment.imageset/Contents.json | 66 ++++++++++++++++++ ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png | Bin 0 -> 649 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png | Bin 0 -> 665 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png | Bin 0 -> 819 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png | Bin 0 -> 853 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png | Bin 0 -> 396 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png | Bin 0 -> 412 bytes .../reply_comment_on.imageset/Contents.json | 66 ++++++++++++++++++ ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png | Bin 0 -> 649 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png | Bin 0 -> 665 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png | Bin 0 -> 819 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png | Bin 0 -> 853 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png | Bin 0 -> 396 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png | Bin 0 -> 412 bytes .../Like/like_off.imageset/Contents.json | 66 ++++++++++++++++++ ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png | Bin 0 -> 503 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png | Bin 0 -> 480 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png | Bin 0 -> 695 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png | Bin 0 -> 675 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png | Bin 0 -> 311 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png | Bin 0 -> 322 bytes .../Like/like_on.imageset/Contents.json | 66 ++++++++++++++++++ ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png | Bin 0 -> 480 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png | Bin 0 -> 503 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png | Bin 0 -> 675 bytes ...EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png | Bin 0 -> 695 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png | Bin 0 -> 322 bytes ...dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png | Bin 0 -> 311 bytes 29 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png create mode 100644 Mople/Resources/Assets.xcassets/Comment/reply_comment_on.imageset/chat_add_on_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_off.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@2x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 1.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 1@3x 2.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 2.png create mode 100644 Mople/Resources/Assets.xcassets/Like/like_on.imageset/thumb_up_24dp_E8EAED_FILL1_wght400_GRAD0_opsz24 3.png 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/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 0000000000000000000000000000000000000000..67f1ce8c0d541cb435a9cda5787491108c7d2edb GIT binary patch literal 649 zcmV;40(Sk0P){0zw5)0#twq2aZ4maXN4VSbQZmlMD{n&f3Yq zz8S{y$Y%7T-M=dXilQirq9}@@D9V4tpa$03p7*%+972u+9)sb~T|tlyRD|N9)Z>N2 zJ;<`fpWE*2MrItx4nlNLkrX)LrHiKUIEEmV1-3v0LDo^AD1TnyCgg}a2B+a#;@K{9 z`AjWcTH3D(E?&S$f6&L|ES-$Ny+CUw18v| z2D-jRjWlMGx8X0;*#`(BEwBm+^Y(^0a?jL#LqmuP^akhb{k|H@E zL}4)+M$$zeo}C*kn05Pod&p}q6L&yybGe!sRIiE?mqF4+w-?QY#hYnv#@Wt&Ga?$) zE1UR7n$#l%k#teZT^gB!LeGWw5DbWoKv7s`CzL4vKM+LPWTGGJKV}Hup=H|ZMUAZe zm!-8b!C%tl;}DX5##K3A|^>xjEi^7>qd@ zpav^BE^*D{im%k#y@13HhGWuiABvLM`Zjd#l3^Mef=Ot8@X9iCozG{-uGx3*QIpDF z)%LBuoast}xPxUolB`UO^84YFtM#?7h-E@lL|oHU7TB2<@+QUA&bPIOp+8_JYh jD2k#eilQirvT{BFh|{wU+&K&D00000NkvXXu0mjfJWwHY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e0b17d74629a1042ea6e8862bf9163b19fdfe925 GIT binary patch literal 665 zcmV;K0%rY*P)DN z!axv(2MAo_4q^`c-RK~sf+!Uv=^&{fPywWZPy$o{9Y9X}9c()A6@(yfB8(hl6Kuu? zCG$yWZS5lM^Ul1TT_QzM6h%=KMNt&xKa!#dOsCT-n&xJ+sgWE`I-PD63WWv<5?bJB zG%_)f0UiyCheoMXs*@0D3JJtLQGDWThy?mi0idyL^CeVEJT?(+Hkk6dMgTCG-96x)3gM9f98Lf1ik zJb8%Y+~mFTVP6~rgBqZ(hY!&sK_pkO1_!lVv+#n+^U4`q2>691pHOI9g9;zP#7)M; z1kHdx8{*oOFp1TooTGFG65A#ax_$SVLlWD=&Q-VhxdVCl;dynk*=)lvZ;v4{fylqz z1X&zJJq3;3?^`Sub2LE?eUP&pmmpvsa(txKVK!~y_ zUU8B0?V=fv$1Q~a2H{#KMNt$*Q4~c{l(X{%RD<&R)p-7J00000NkvXXu0mjfqof$N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba42c8a2597ff26601ff95b42e0f08260b82fe3a GIT binary patch literal 819 zcmV-31I+x1P)qxCE5|1;mI7LOOs7fC>^SU{XQSL7WQEq=6(Aa3!dL=(20s8Ar%)NcSgr z-wc1o82qf$?cNE1h=_=Yh=_=Yh=_=Yh=_lY!s1-MTq3SUp3u4|a8uW{ zK>}U_)iABaodrsy)hr5}N~pgO*#aJA5~f^;?X#Bi>F-4so=#MaPFhH0wt;GpGSllqa*d3n2(+=X zqAtVn;87&2M9YQ6<$>iuT|B;!9M{NDio*Wbr52Ua=<7i7Zag#wL1kOmZ_a}SzVH96 zOPE#X42h&D?49dJDX&g;pSy&)T2;4N=j!&y>ZH1#^%jUL-FMRMlE?0&osfp}?;Pr| zi@E8*3S8~oRLZ$H`jHy_9I7p&g!)sCpo903BW!;^*8HgJPne193wx~yFeBLqnxfm8 zBI`i4c+a^+8j^LVq%Gyp?OGPKz2&=hVD(9wgZHc6a-rS!_@Q2?+ygcJ+qR|AmE+H! z$-Dn%dY$XqZq@%dM9-i5IrQNZF5JR=LWw{ZLCkUaF4;Cs^1(K3XOy_zL xgw-~*z~?zF`w_~V&*Z|sN=5fKp)5fKp)5fKp)5fM>QDq`7SFod8E7k27iyZQ8!HDb8QZk++Qx?Q3S-o7z4~ z`3BR&J&xWnzxP~4FG`!|G_j&uzZ%l3Gy>jvjn2Wi5@T_w$RY3Tp;w=Z3iMNksjLN> z*n$7FVJd5ZngFkTg{g~;q)IAO39&e4{l7qb!oqZeg=q5-1S*T)W!m;5A|fIpA|fIp fA|fIpq7%IW+BHx|GcOsZ00000NkvXXu0mjfHWY}4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b5dda5c94cb6c701696d248017bf05e0eeb747b0 GIT binary patch literal 396 zcmV;70dxL|P)T)tWU;C$6U6oOXN7ffvbj=Qg-3-@SP4q z4>o@ZVafTEr$Y^QN-40J(vCtG>j|IF`FpO zPAf058ZK1ZoQHM2;Pxk{lMY4jW`}Jo**L-+$=BZ$Yx%Bg#J-rrx$3g>sM4mWMN@(Zm6Y=s;W}BH&{UH(0$+ECP|{#0-dD|rPPCvj1eL}l>|C;B7jhI zxeH0qFbq#|9KTH0QT7iwk+P!*ar^7Kt_|&9N?mnbXP`PCmqHj{0zw5)0#twq2aZ4maXN4VSbQZmlMD{n&f3Yq zz8S{y$Y%7T-M=dXilQirq9}@@D9V4tpa$03p7*%+972u+9)sb~T|tlyRD|N9)Z>N2 zJ;<`fpWE*2MrItx4nlNLkrX)LrHiKUIEEmV1-3v0LDo^AD1TnyCgg}a2B+a#;@K{9 z`AjWcTH3D(E?&S$f6&L|ES-$Ny+CUw18v| z2D-jRjWlMGx8X0;*#`(BEwBm+^Y(^0a?jL#LqmuP^akhb{k|H@E zL}4)+M$$zeo}C*kn05Pod&p}q6L&yybGe!sRIiE?mqF4+w-?QY#hYnv#@Wt&Ga?$) zE1UR7n$#l%k#teZT^gB!LeGWw5DbWoKv7s`CzL4vKM+LPWTGGJKV}Hup=H|ZMUAZe zm!-8b!C%tl;}DX5##K3A|^>xjEi^7>qd@ zpav^BE^*D{im%k#y@13HhGWuiABvLM`Zjd#l3^Mef=Ot8@X9iCozG{-uGx3*QIpDF z)%LBuoast}xPxUolB`UO^84YFtM#?7h-E@lL|oHU7TB2<@+QUA&bPIOp+8_JYh jD2k#eilQirvT{BFh|{wU+&K&D00000NkvXXu0mjfJWwHY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e0b17d74629a1042ea6e8862bf9163b19fdfe925 GIT binary patch literal 665 zcmV;K0%rY*P)DN z!axv(2MAo_4q^`c-RK~sf+!Uv=^&{fPywWZPy$o{9Y9X}9c()A6@(yfB8(hl6Kuu? zCG$yWZS5lM^Ul1TT_QzM6h%=KMNt&xKa!#dOsCT-n&xJ+sgWE`I-PD63WWv<5?bJB zG%_)f0UiyCheoMXs*@0D3JJtLQGDWThy?mi0idyL^CeVEJT?(+Hkk6dMgTCG-96x)3gM9f98Lf1ik zJb8%Y+~mFTVP6~rgBqZ(hY!&sK_pkO1_!lVv+#n+^U4`q2>691pHOI9g9;zP#7)M; z1kHdx8{*oOFp1TooTGFG65A#ax_$SVLlWD=&Q-VhxdVCl;dynk*=)lvZ;v4{fylqz z1X&zJJq3;3?^`Sub2LE?eUP&pmmpvsa(txKVK!~y_ zUU8B0?V=fv$1Q~a2H{#KMNt$*Q4~c{l(X{%RD<&R)p-7J00000NkvXXu0mjfqof$N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba42c8a2597ff26601ff95b42e0f08260b82fe3a GIT binary patch literal 819 zcmV-31I+x1P)qxCE5|1;mI7LOOs7fC>^SU{XQSL7WQEq=6(Aa3!dL=(20s8Ar%)NcSgr z-wc1o82qf$?cNE1h=_=Yh=_=Yh=_=Yh=_lY!s1-MTq3SUp3u4|a8uW{ zK>}U_)iABaodrsy)hr5}N~pgO*#aJA5~f^;?X#Bi>F-4so=#MaPFhH0wt;GpGSllqa*d3n2(+=X zqAtVn;87&2M9YQ6<$>iuT|B;!9M{NDio*Wbr52Ua=<7i7Zag#wL1kOmZ_a}SzVH96 zOPE#X42h&D?49dJDX&g;pSy&)T2;4N=j!&y>ZH1#^%jUL-FMRMlE?0&osfp}?;Pr| zi@E8*3S8~oRLZ$H`jHy_9I7p&g!)sCpo903BW!;^*8HgJPne193wx~yFeBLqnxfm8 zBI`i4c+a^+8j^LVq%Gyp?OGPKz2&=hVD(9wgZHc6a-rS!_@Q2?+ygcJ+qR|AmE+H! z$-Dn%dY$XqZq@%dM9-i5IrQNZF5JR=LWw{ZLCkUaF4;Cs^1(K3XOy_zL xgw-~*z~?zF`w_~V&*Z|sN=5fKp)5fKp)5fKp)5fM>QDq`7SFod8E7k27iyZQ8!HDb8QZk++Qx?Q3S-o7z4~ z`3BR&J&xWnzxP~4FG`!|G_j&uzZ%l3Gy>jvjn2Wi5@T_w$RY3Tp;w=Z3iMNksjLN> z*n$7FVJd5ZngFkTg{g~;q)IAO39&e4{l7qb!oqZeg=q5-1S*T)W!m;5A|fIpA|fIp fA|fIpq7%IW+BHx|GcOsZ00000NkvXXu0mjfHWY}4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b5dda5c94cb6c701696d248017bf05e0eeb747b0 GIT binary patch literal 396 zcmV;70dxL|P)T)tWU;C$6U6oOXN7ffvbj=Qg-3-@SP4q z4>o@ZVafTEr$Y^QN-40J(vCtG>j|IF`FpO zPAf058ZK1ZoQHM2;Pxk{lMY4jW`}Jo**L-+$=BZ$Yx%Bg#J-rrx$3g>sM4mWMN@(Zm6Y=s;W}BH&{UH(0$+ECP|{#0-dD|rPPCvj1eL}l>|C;B7jhI zxeH0qFbq#|9KTH0QT7iwk+P!*ar^7Kt_|&9N?mnbXP`PCmqHjihKiVH*v|`fFy}Z5e zg6OVBzXi%yIM+yD;k?3`!g7z}14FR`!~g#*LMqoD9gOY&^4-&?Yr5Irw%4)_EI_k? zVEc)M+8&on0!5Bi$6mKSUifE|j=b4yUpJ{8DKm~dt^B82Saiq5ZlanS^I?N8Dt+;J zCns1Nxjc%wKGkBqz1rm|CWb|9|GR`kLs^$}emme@sB>ZK0gkne%#Tv4vaG9}q907K zIJo+SdZtO%M9)bn$Kx+N-aJ8fy7zwTV}<%9it9G^KCURtoABME{)|Z2y%#^dZHw+~ zw0*XK+1+mY@j#KQCUbv(t@`W6HD$TR@xM3bRI1t(Y+3jBtw-0fKWuyd*;iQUdhkCz zZ(6r9O^WGxo>=1^-nkwZW6nL$*4$(l@XjP`_0&&K=X`D4^Hn4FgYIo+<9$BX7dgV% zKV6;lDSv%x^#k_al-vb5yuo)kqNOd$3Ntm?|9x7Zk^S#n@Ben2V;?e^PBnhouuWL- r!|U2|udm10Cx8F2_WC?X(5-tVE$uaB?xvRv4?qH*u6{1-oD!MmmwDAh0MqQ9X#GVAyHLTat+4G&3r)NT>^t<50FSBd^vB@QV#4ySROS_tYvU?2P+;JooonZj0#+)-QjH3NBi{ z!e{ONqdN};xgA=zuhc)q|03gepZ$h8F5hSW4bnAdliB6SGG%F9tH)uEh=;5byZAL8 zZn@wde@;|BU|q=~u0L)s8E5Oi5BCTtIS_cLKL0lRsc(HQN7=4iD(LGi*vYth%h6Qj zI`fbJ-2yM$ecSr|7wsxE U{^}tV2aFH~Pgg&ebxsLQ0Kb~Y8vpoo1K-6l5$8 za(7}_cTVOdki(Mh=3#gt_tdMxZD!CHq182p;mgwZ!F(|b7gz0lx75aV!n}E1tfxe;KcA5H=X1fWtq-!Q z`l60R=uMZtw`HY-U3`0_&qt32j|DOpg!Ao``B-BDoBft=-eK|IgY|I$yU;?ftqV8L z_`9|xxpmdmtYo1C+kiY{-;B3A9xqgTuFK3~xqhr#rXgI#ngJUZ2F);T{PgypD{FyT zz`nb#){BkRnWoBVwMO!r9Od$Rm@g@QQF3l1|CKclW<2z~^Kr#CotHJ-VnO{qi#_gs zS}zjYX%~LlwG9k+|$+1Wt~$(6990vF023m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..650dcf28ace90e06c7e5a13dacc44289d7c1899e GIT binary patch literal 675 zcmeAS@N?(olHy`uVBq!ia0vp^At21b1|(&&1r7o!&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=BZ>IQxaKvu;IhE0t<7g(3<6F*?G%*5gMwCy{ee|NpKyX8~C|853BRFGNt zV&Uo2Z~WfJ! zdE(R$$r;kl{4>s;?U~=WQmpF4C66-CVu>WR3(nVPdOzT8TT%W~=QP_V_4T*Kyyjlq zbby;L;DlXWzoE(Xd-GPYYAL3=Hmc8TJbyjkWcs_x;L;95nY8;s`3-qGlddLqwKGlC z(~pev?ykPyBH41i=&kpubN{cuH#n0Z@J_epVZZ{RjEtGlU6*EU@;h1Evv9#zho{~C zucKmiUX=^pC^a=`i_hZLWxS_%U+H#Oe9R#lh{UQFtkwUtm^Z{sGA{YLNbZ6nwhvsa z8`*aK_)^Ej8px)_uGJqPl)7M+`k#xeTFc{v&(42)M0~|zV-Z);7gZ~l{5qks%3GMWymtYjC&jQlp5_`?e8YNmR-uPAm*%} z%c*6e*SB5E*Rt7nttutPb?IsE?j7!>-Z4A6YD;|oF8lN$nxVu)^}>b``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{uxgf$B+ufx05gOHW>)C@fR|Q>m1g*%=Agyn6b7s=v8OM zqc3YS*y5he+5NDr*}I_X<{<~SYqzFdJ}=Z}#Q(|KP(zJ{oh7ly@>=QLv-!fxAy4u(kUlXcI;uScZ-x2<1Tyv^cRDttDnm{r-UW| DjYf3Q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c5268b678e6c1976f7a6fca365b85e412d39853b GIT binary patch literal 322 zcmeAS@N?(olHy`uVBq!ia0vp^G9b*s1|*Ak?@s|zoCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{&i0m$B+ufx0BBbHW>)G?O(uJ@kM2d1M7sa zH3y{LI9MOxd-CGs+>MO+a;UN=BR5LGCxbzl$Mxny>@7w&vD*>TUVkve;e_>KPf535o~n&i5JI` znzfxPP8C;%I$X+jxVuB}#@vpsh4X@E*rzZ4vUzXf&FQwmpO(68Eo@|*)AmgIpW>Pa z!4jM~y_J9F+17AYCUM)I{h;b) P(2ES7u6{1-oD!MmmwDAh0MqQ9X#GVAyHLTat+4G&3r)NT>^t<50FSBd^vB@QV#4ySROS_tYvU?2P+;JooonZj0#+)-QjH3NBi{ z!e{ONqdN};xgA=zuhc)q|03gepZ$h8F5hSW4bnAdliB6SGG%F9tH)uEh=;5byZAL8 zZn@wde@;|BU|q=~u0L)s8E5Oi5BCTtIS_cLKL0lRsc(HQN7=4iD(LGi*vYth%h6Qj zI`fbJ-2yM$ecSr|7wsxE U{^}tV2aFH~Pgg&ebxsLQ0Kb~Y8vpihKiVH*v|`fFy}Z5e zg6OVBzXi%yIM+yD;k?3`!g7z}14FR`!~g#*LMqoD9gOY&^4-&?Yr5Irw%4)_EI_k? zVEc)M+8&on0!5Bi$6mKSUifE|j=b4yUpJ{8DKm~dt^B82Saiq5ZlanS^I?N8Dt+;J zCns1Nxjc%wKGkBqz1rm|CWb|9|GR`kLs^$}emme@sB>ZK0gkne%#Tv4vaG9}q907K zIJo+SdZtO%M9)bn$Kx+N-aJ8fy7zwTV}<%9it9G^KCURtoABME{)|Z2y%#^dZHw+~ zw0*XK+1+mY@j#KQCUbv(t@`W6HD$TR@xM3bRI1t(Y+3jBtw-0fKWuyd*;iQUdhkCz zZ(6r9O^WGxo>=1^-nkwZW6nL$*4$(l@XjP`_0&&K=X`D4^Hn4FgYIo+<9$BX7dgV% zKV6;lDSv%x^#k_al-vb5yuo)kqNOd$3Ntm?|9x7Zk^S#n@Ben2V;?e^PBnhouuWL- r!|U2|udm10Cx8F2_WC?X(5-tVE$uaB?xvRv4?qH*u6{1-oD!Moo1K-6l5$8 za(7}_cTVOdki(Mh=BZ>IQxaKvu;IhE0t<7g(3<6F*?G%*5gMwCy{ee|NpKyX8~C|853BRFGNt zV&Uo2Z~WfJ! zdE(R$$r;kl{4>s;?U~=WQmpF4C66-CVu>WR3(nVPdOzT8TT%W~=QP_V_4T*Kyyjlq zbby;L;DlXWzoE(Xd-GPYYAL3=Hmc8TJbyjkWcs_x;L;95nY8;s`3-qGlddLqwKGlC z(~pev?ykPyBH41i=&kpubN{cuH#n0Z@J_epVZZ{RjEtGlU6*EU@;h1Evv9#zho{~C zucKmiUX=^pC^a=`i_hZLWxS_%U+H#Oe9R#lh{UQFtkwUtm^Z{sGA{YLNbZ6nwhvsa z8`*aK_)^Ej8px)_uGJqPl)7M+`k#xeTFc{v&(42)M0~|zV-Z);7gZ~l{5qks%3GMWymtYjC&jQlp5_`?e8YNmR-uPAm*%} z%c*6e*SB5E*Rt7nttutPb?IsE?j7!>-Z4A6YD;|oF8lN$nxVu)^}>boo1K-6l5$8 za(7}_cTVOdki(Mh=3#gt_tdMxZD!CHq182p;mgwZ!F(|b7gz0lx75aV!n}E1tfxe;KcA5H=X1fWtq-!Q z`l60R=uMZtw`HY-U3`0_&qt32j|DOpg!Ao``B-BDoBft=-eK|IgY|I$yU;?ftqV8L z_`9|xxpmdmtYo1C+kiY{-;B3A9xqgTuFK3~xqhr#rXgI#ngJUZ2F);T{PgypD{FyT zz`nb#){BkRnWoBVwMO!r9Od$Rm@g@QQF3l1|CKclW<2z~^Kr#CotHJ-VnO{qi#_gs zS}zjYX%~LlwG9k+|$+1Wt~$(6990vF023m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c5268b678e6c1976f7a6fca365b85e412d39853b GIT binary patch literal 322 zcmeAS@N?(olHy`uVBq!ia0vp^G9b*s1|*Ak?@s|zoCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{&i0m$B+ufx0BBbHW>)G?O(uJ@kM2d1M7sa zH3y{LI9MOxd-CGs+>MO+a;UN=BR5LGCxbzl$Mxny>@7w&vD*>TUVkve;e_>KPf535o~n&i5JI` znzfxPP8C;%I$X+jxVuB}#@vpsh4X@E*rzZ4vUzXf&FQwmpO(68Eo@|*)AmgIpW>Pa z!4jM~y_J9F+17AYCUM)I{h;b) P(2ES7u6{1-oD!M``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{uxgf$B+ufx05gOHW>)C@fR|Q>m1g*%=Agyn6b7s=v8OM zqc3YS*y5he+5NDr*}I_X<{<~SYqzFdJ}=Z}#Q(|KP(zJ{oh7ly@>=QLv-!fxAy4u(kUlXcI;uScZ-x2<1Tyv^cRDttDnm{r-UW| DjYf3Q literal 0 HcmV?d00001