Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cdea084
feat: Notice 도메인 + Meet.pinnedNotice 모델 추가
Dongju3079 May 26, 2026
f22ca77
feat: NoticeFlowCoordinator + 공지 화면 3개 placeholder
Dongju3079 May 26, 2026
8fb6f3f
feat: MeetDetail UI 리디자인 + 공지 진입 라우팅 연결
Dongju3079 May 26, 2026
bdd027b
refactor: 공지 화면 3개를 SwiftUI + UIHostingController로 교체
Dongju3079 May 26, 2026
f20d867
feat(experimental): MeetDetail 스크롤 시 공지+pill 헤더 sticky/hide
Dongju3079 May 26, 2026
db352df
fix: sticky 헤더를 overlay 방식으로 재구성
Dongju3079 May 26, 2026
d55babf
fix: 헤더 height 변동 시 자식 contentOffset 동기 보정
Dongju3079 May 26, 2026
542facd
fix: 헤더 height 측정 시 layoutIfNeeded 강제 호출
Dongju3079 May 26, 2026
0840bef
feat: pill 세그먼트를 sticky overlay로 전환 (트위터 스타일)
Dongju3079 May 26, 2026
e6c8a6a
fix: 셀이 약속 탭 아래에서 시작하도록 inset 분리
Dongju3079 May 26, 2026
c4c69ff
refactor: 공지 카드 가변 처리를 명시적 height constraint로 전환
Dongju3079 May 26, 2026
98de8d9
fix: 페이지 전환 시 헤더 transform 동기화 (자식 viewWillAppear)
Dongju3079 May 26, 2026
d5ebf23
feat: 작성 유도 말풍선 + sticky 헤더/공지 가변 처리 마무리
Dongju3079 May 26, 2026
3454983
feat(experimental): PageController swipe와 pill 인터랙티브 트래킹
Dongju3079 May 26, 2026
b7f5ff4
refactor: PinnedNotice → Notice rename + NoticeRepo 프로토콜 신설
Dongju3079 May 26, 2026
bf250d9
fix: PageController paging gesture와 edge gesture 충돌 해결
Dongju3079 May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,28 @@ import Domain

struct MeetResponse: Decodable {
let meetId: Int?
let version: Int?
let meetName: String?
let meetImage: String?
let sinceDays: Int?
let hostId: Int?
let memberCount: Int?
let lastPlanDay: String?
let pinnedNotice: NoticeResponse?
}

extension MeetResponse {
func toDomain() -> Meet {
let date = DateManager.parseServerFullDate(string: lastPlanDay)

return .init(meetSummary: .init(id: meetId,
name: meetName,
imagePath: meetImage),
sinceDays: sinceDays,
creatorId: hostId,
memberCount: memberCount,
firstPlanDate: date)
firstPlanDate: date,
version: version,
pinnedNotice: pinnedNotice?.toDomain())
}
}
36 changes: 36 additions & 0 deletions Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// NoticeResponse.swift
// Data
//
// Created by CatSlave on 5/26/26.
//
// 서버 NoticeClientResponse — 공지 객체 단일 표현.
// MeetDetail 응답의 pinnedNotice 중첩 + 공지 리스트/상세 응답 모두 같은 구조.
//

import Foundation
import Domain

struct NoticeResponse: Decodable {
let noticeId: Int?
let version: Int?
let meetId: Int?
let type: String?
let content: String?
let pinned: Bool?
let createdAt: String?
}

extension NoticeResponse {
func toDomain() -> Notice {
return .init(
noticeId: noticeId,
version: version,
meetId: meetId,
type: NoticeType(rawValue: type ?? ""),
content: content,
isPinned: pinned ?? false,
createdAt: DateManager.parseServerFullDate(string: createdAt)
)
}
}
13 changes: 12 additions & 1 deletion Modules/Domain/Sources/Entities/Meet/Meet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@ public struct Meet {
public var creatorId: Int?
public let memberCount: Int?
public let firstPlanDate: Date?
public let version: Int?
public let pinnedNotice: Notice?

public init(isCreator: Bool = false, meetSummary: MeetSummary? = nil, sinceDays: Int? = nil, creatorId: Int? = nil, memberCount: Int? = nil, firstPlanDate: Date? = nil) {
public init(isCreator: Bool = false,
meetSummary: MeetSummary? = nil,
sinceDays: Int? = nil,
creatorId: Int? = nil,
memberCount: Int? = nil,
firstPlanDate: Date? = nil,
version: Int? = nil,
pinnedNotice: Notice? = nil) {
self.isCreator = isCreator
self.meetSummary = meetSummary
self.sinceDays = sinceDays
self.creatorId = creatorId
self.memberCount = memberCount
self.firstPlanDate = firstPlanDate
self.version = version
self.pinnedNotice = pinnedNotice
}
}
36 changes: 36 additions & 0 deletions Modules/Domain/Sources/Entities/Notice/Notice.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// Notice.swift
// Domain
//
// Created by CatSlave on 5/26/26.
//

import Foundation

// 모임 공지. MeetDetail 응답의 pinnedNotice, Notice 리스트 응답의 셀, 공지 상세 모두 같은 구조.
// isPinned 플래그로 고정 여부 표현.
public struct Notice: Hashable {
public let noticeId: Int?
public let version: Int?
public let meetId: Int?
public let type: NoticeType?
public let content: String?
public let isPinned: Bool
public let createdAt: Date?

public init(noticeId: Int? = nil,
version: Int? = nil,
meetId: Int? = nil,
type: NoticeType? = nil,
content: String? = nil,
isPinned: Bool = false,
createdAt: Date? = nil) {
self.noticeId = noticeId
self.version = version
self.meetId = meetId
self.type = type
self.content = content
self.isPinned = isPinned
self.createdAt = createdAt
}
}
14 changes: 14 additions & 0 deletions Modules/Domain/Sources/Entities/Notice/NoticeType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// NoticeType.swift
// Domain
//
// Created by CatSlave on 5/26/26.
//

import Foundation

// 모임 공지 분류 — 모임장이 작성한 사용자 공지(custom)와 시스템이 자동 발행한 공지(system) 두 가지
public enum NoticeType: String, Sendable {
case custom = "CUSTOM"
case system = "SYSTEM"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// NoticeRepo.swift
// Domain
//
// Created by CatSlave on 5/26/26.
//
// 모임 공지(Notice) + 공지 댓글 API 추상화.
// - 공지 CRUD/고정 토글: /notice/*
// - 공지 댓글 조회/생성: /comment/notice/{noticeId}
// - 공지 댓글 수정/삭제: 일반 CommentRepo.editComment / deleteComment 재사용 (URL 공통)
//

import Foundation

public protocol NoticeRepo {

// MARK: - 공지 CRUD
func fetchNoticeList(meetId: Int,
size: Int?,
cursor: String?) async throws -> Page<Notice>

func createNotice(meetId: Int, content: String) async throws -> Notice

func updateNotice(noticeId: Int,
meetId: Int,
content: String) async throws -> Notice

func deleteNotice(noticeId: Int) async throws

// MARK: - 고정 토글
func pinNotice(noticeId: Int) async throws -> Notice
func unpinNotice(noticeId: Int) async throws -> Notice

// MARK: - 공지 댓글
// 게시글 댓글과 별도 endpoint이지만 응답 구조는 동일(Comment)이라 같은 entity로 매핑.
// 댓글 수정/삭제는 CommentRepo의 editComment / deleteComment 재사용.
func fetchNoticeCommentList(noticeId: Int,
size: Int?,
cursor: String?) async throws -> Page<Comment>

func createNoticeComment(noticeId: Int,
content: String,
mentions: [Int]) async throws -> Comment
}
16 changes: 15 additions & 1 deletion Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,27 @@ public final class MockFetchMeetDetailUseCase: FetchMeetDetail {
public func execute(meetId: Int) async throws -> Meet {
print("✅ [Mock] 모임 상세 조회 - meetId: \(meetId)")

// meetId 짝수: 공지 있음 / 홀수: 공지 없음 → 모임장 작성 유도 툴팁 노출 케이스 둘 다 확인 가능
let hasNotice = meetId % 2 == 0
let mockPinnedNotice: Notice? = hasNotice ? Notice(
noticeId: 1,
version: 1,
meetId: meetId,
type: .custom,
content: "11/28일 모임 18:00 → 20:00 변경 되었습니다. 날씨이슈로 인해서",
isPinned: true,
createdAt: Date()
) : nil

let mockMeet = Meet(
isCreator: true,
meetSummary: MeetSummary(id: meetId, name: "테니스 동호회"),
sinceDays: 120,
creatorId: 1,
memberCount: 8,
firstPlanDate: Calendar.current.date(byAdding: .day, value: 3, to: Date())
firstPlanDate: Calendar.current.date(byAdding: .day, value: 3, to: Date()),
version: 1,
pinnedNotice: mockPinnedNotice
)

try await Task.sleep(nanoseconds: 1_000_000_000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ protocol MeetDetailSceneDependencies {
// MARK: - Flow
func makePlanCreateFlowCoordinator(meet: MeetSummary,
completion: ((Plan) -> Void)?) -> BaseCoordinator

func makePostDetailFlowCoordinator(postId: Int,
type: PostType) -> BaseCoordinator

func makeNoticeFlowCoordinator(entry: NoticeFlowEntry) -> BaseCoordinator
}

final class MeetDetailSceneDIContainer: BaseContainer, MeetDetailSceneDependencies {
Expand Down Expand Up @@ -261,4 +263,13 @@ extension MeetDetailSceneDIContainer {
postId: postId)
return planDetailDI.makePostDetailCoordinator()
}

// MARK: - 공지
func makeNoticeFlowCoordinator(entry: NoticeFlowEntry) -> BaseCoordinator {
let noticeDI = NoticeSceneDIContainer(appNetworkService: appNetworkService,
commonFactory: commonViewFactory,
userSession: userSession,
entry: entry)
return noticeDI.makeNoticeFlowCoordinator()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// NoticeSceneDIContainer.swift
// Mople
//
// Created by CatSlave on 5/26/26.
//

import UIKit
import SwiftUI
import Domain
import Data

protocol NoticeSceneDependencies {
func makeNoticeListViewController(meetId: Int,
isCreator: Bool,
coordinator: NoticeFlowCoordination) -> UIViewController
func makeNoticeDetailViewController(noticeId: Int) -> UIViewController
func makeNoticeComposeViewController(meetId: Int) -> UIViewController
}

final class NoticeSceneDIContainer: BaseContainer, NoticeSceneDependencies {

private let entry: NoticeFlowEntry

init(appNetworkService: AppNetworkService,
commonFactory: ViewDependencies,
userSession: UserSessionProvider,
entry: NoticeFlowEntry) {
self.entry = entry
super.init(appNetworkService: appNetworkService,
commonFactory: commonFactory,
userSession: userSession)
}

func makeNoticeFlowCoordinator() -> NoticeFlowCoordinator {
return .init(dependencies: self,
entry: entry,
navigationController: AppNaviViewController())
}
}

// MARK: - View Factories (SwiftUI + UIHostingController)
extension NoticeSceneDIContainer {

@MainActor
func makeNoticeListViewController(meetId: Int,
isCreator: Bool,
coordinator: NoticeFlowCoordination) -> UIViewController {
let view = NoticeListView(
meetId: meetId,
isCreator: isCreator,
onComposeTap: { [weak coordinator] in
coordinator?.pushComposeView()
}
)
return UIHostingController(rootView: view)
}

@MainActor
func makeNoticeDetailViewController(noticeId: Int) -> UIViewController {
let view = NoticeDetailView(noticeId: noticeId)
return UIHostingController(rootView: view)
}

@MainActor
func makeNoticeComposeViewController(meetId: Int) -> UIViewController {
let view = NoticeComposeView(meetId: meetId)
return UIHostingController(rootView: view)
}
}
5 changes: 5 additions & 0 deletions Mople/Infrastructure/ScreenTrack/ScreenName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ enum ScreenName: String {
case notification
case notification_setting

// MARK: - Notice (모임 공지)
case notice_list
case notice_detail
case notice_compose

// MARK: - Member
case participant_list

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ protocol MeetDetailCoordination: AnyObject {
func presentPlanDetailView(postId: Int,
type: PostType)
func pushMemberListView()
func presentNoticeListView(meetId: Int, isCreator: Bool)
func presentNoticeDetailView(noticeId: Int)
func endFlow()
}

Expand Down Expand Up @@ -51,6 +53,11 @@ final class MeetDetailSceneCoordinator: BaseCoordinator, MeetDetailCoordination
reviewListVC = dependencies.makeMeetReviewListViewController()
detailMeetVC?.pageController.setViewControllers([planListVC!], direction: .forward, animated: false)
detailMeetVC?.configureEdgeGesture()

// sticky 헤더 — 자식 스크롤을 부모로 전달
if let plan = planListVC, let review = reviewListVC {
detailMeetVC?.attachChildScrollObservers(plan, review)
}
}
}

Expand Down Expand Up @@ -144,6 +151,28 @@ extension MeetDetailSceneCoordinator {
}
}

// MARK: - Notice Flow
extension MeetDetailSceneCoordinator {

// 확성기 버튼 → 공지 리스트 (modal present)
func presentNoticeListView(meetId: Int, isCreator: Bool) {
let coordinator = dependencies.makeNoticeFlowCoordinator(
entry: .list(meetId: meetId, isCreator: isCreator)
)
start(coordinator: coordinator)
self.present(coordinator.navigationController)
}

// 공지 미리보기 카드 → 공지 상세 (modal present)
func presentNoticeDetailView(noticeId: Int) {
let coordinator = dependencies.makeNoticeFlowCoordinator(
entry: .detail(noticeId: noticeId)
)
start(coordinator: coordinator)
self.present(coordinator.navigationController)
}
}

// MARK: - End Flow
extension MeetDetailSceneCoordinator {
func endFlow() {
Expand Down
Loading