diff --git a/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift b/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift index 6c15d708..d6901ec9 100644 --- a/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift +++ b/Modules/Data/Sources/Network/Response/Meet/MeetResponse.swift @@ -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()) } } diff --git a/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift b/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift new file mode 100644 index 00000000..95d92b8f --- /dev/null +++ b/Modules/Data/Sources/Network/Response/Notice/NoticeResponse.swift @@ -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) + ) + } +} diff --git a/Modules/Domain/Sources/Entities/Meet/Meet.swift b/Modules/Domain/Sources/Entities/Meet/Meet.swift index 08164c9c..67487163 100644 --- a/Modules/Domain/Sources/Entities/Meet/Meet.swift +++ b/Modules/Domain/Sources/Entities/Meet/Meet.swift @@ -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 } } diff --git a/Modules/Domain/Sources/Entities/Notice/Notice.swift b/Modules/Domain/Sources/Entities/Notice/Notice.swift new file mode 100644 index 00000000..bbe5228c --- /dev/null +++ b/Modules/Domain/Sources/Entities/Notice/Notice.swift @@ -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 + } +} diff --git a/Modules/Domain/Sources/Entities/Notice/NoticeType.swift b/Modules/Domain/Sources/Entities/Notice/NoticeType.swift new file mode 100644 index 00000000..edc0c5e6 --- /dev/null +++ b/Modules/Domain/Sources/Entities/Notice/NoticeType.swift @@ -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" +} diff --git a/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift b/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift new file mode 100644 index 00000000..5ab9b6e4 --- /dev/null +++ b/Modules/Domain/Sources/Interfaces/Repositories/Notice/NoticeRepo.swift @@ -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 + + 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 + + func createNoticeComment(noticeId: Int, + content: String, + mentions: [Int]) async throws -> Comment +} diff --git a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift index 5efb8ed2..3d856876 100644 --- a/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift +++ b/Modules/Domain/Sources/UseCases/Meet/Read/FetchMeetDetail.swift @@ -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) diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift index 196df42c..3b9d0b05 100644 --- a/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/MeetDetailSceneDIContainer.swift @@ -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 { @@ -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() + } } diff --git a/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift new file mode 100644 index 00000000..c7a36f2b --- /dev/null +++ b/Mople/Application/DIContainer/MainSceneDI/Sub/NoticeSceneDIContainer.swift @@ -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) + } +} diff --git a/Mople/Infrastructure/ScreenTrack/ScreenName.swift b/Mople/Infrastructure/ScreenTrack/ScreenName.swift index 34a6cb93..a360e267 100644 --- a/Mople/Infrastructure/ScreenTrack/ScreenName.swift +++ b/Mople/Infrastructure/ScreenTrack/ScreenName.swift @@ -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 diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift index 170fcc77..7bf89c71 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/MeetDetailFlowCoordinator.swift @@ -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() } @@ -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) + } } } @@ -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() { diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift index e19afae7..e48caff8 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetPlanListViewController/MeetPlanListViewController.swift @@ -28,6 +28,28 @@ final class MeetPlanListViewController: BaseViewController, View { private var hasAppeared: Bool = false private var isVisibleView: Bool = false private var isSetEdgeGesture: Bool = false + + // 부모(MeetDetail)가 자식 스크롤을 추적해 헤더 sticky/hide를 처리할 수 있게 노출 + var onScrollChange: ((CGFloat) -> Void)? + + // 헤더 overlay 높이만큼 tableView 상단을 비워두는 inset. 부모가 layout 후 호출. + // 헤더 height가 변동(공지 hidden ↔ visible)되면, 자식 contentOffset도 같은 delta만큼 따라 이동시켜 + // swipe 거리와 hide 거리가 항상 1:1로 매핑되게 한다. + private var topInsetApplied: Bool = false + func setTopContentInset(_ inset: CGFloat) { + let oldInset = tableView.contentInset.top + tableView.contentInset.top = inset + tableView.verticalScrollIndicatorInsets.top = inset + if !topInsetApplied { + topInsetApplied = true + tableView.setContentOffset(CGPoint(x: 0, y: -inset), animated: false) + return + } + let delta = inset - oldInset + guard abs(delta) > 0.5 else { return } + let oldOffset = tableView.contentOffset.y + tableView.setContentOffset(CGPoint(x: 0, y: oldOffset - delta), animated: false) + } // MARK: - UI Components private let countView: CountView = { @@ -74,6 +96,9 @@ final class MeetPlanListViewController: BaseViewController, View { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) isVisibleView = true + // 페이지 전환으로 다시 표시될 때 부모(MeetDetail)의 헤더 transform이 이전 자식의 상태로 + // stale일 수 있다. 자기 contentOffset.y를 즉시 알려서 헤더 transform을 재계산하게 한다. + onScrollChange?(tableView.contentOffset.y) } override func viewWillDisappear(_ animated: Bool) { @@ -283,6 +308,9 @@ extension MeetPlanListViewController: UIScrollViewDelegate { } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 부모 헤더 sticky/hide 처리용 offset 전달 + onScrollChange?(scrollView.contentOffset.y) + guard scrollView.isBottom(threshold: 50), reactor?.page?.hasNext == true else { return } nextPage.onNext(()) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift index 3b5eee4e..f34ce5c5 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/ChildView/MeetReviewListViewController/MeetReviewListViewController.swift @@ -25,6 +25,28 @@ final class MeetReviewListViewController: BaseViewController, View { // MARK: - Variables private var hasAppeared: Bool = false private var isSetEdgeGesture: Bool = false + + // 부모(MeetDetail)가 자식 스크롤을 추적해 헤더 sticky/hide를 처리할 수 있게 노출 + var onScrollChange: ((CGFloat) -> Void)? + + // 헤더 overlay 높이만큼 tableView 상단을 비워두는 inset. 부모가 layout 후 호출. + // 헤더 height가 변동(공지 hidden ↔ visible)되면, 자식 contentOffset도 같은 delta만큼 따라 이동시켜 + // swipe 거리와 hide 거리가 항상 1:1로 매핑되게 한다. + private var topInsetApplied: Bool = false + func setTopContentInset(_ inset: CGFloat) { + let oldInset = tableView.contentInset.top + tableView.contentInset.top = inset + tableView.verticalScrollIndicatorInsets.top = inset + if !topInsetApplied { + topInsetApplied = true + tableView.setContentOffset(CGPoint(x: 0, y: -inset), animated: false) + return + } + let delta = inset - oldInset + guard abs(delta) > 0.5 else { return } + let oldOffset = tableView.contentOffset.y + tableView.setContentOffset(CGPoint(x: 0, y: oldOffset - delta), animated: false) + } // MARK: - UI Components private lazy var countView: CountView = { @@ -67,7 +89,14 @@ final class MeetReviewListViewController: BaseViewController, View { super.viewDidLoad() setupUI() } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // 페이지 전환으로 다시 표시될 때 부모(MeetDetail)의 헤더 transform이 이전 자식의 상태로 + // stale일 수 있다. 자기 contentOffset.y를 즉시 알려서 헤더 transform을 재계산하게 한다. + onScrollChange?(tableView.contentOffset.y) + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() setHeaderView() @@ -192,6 +221,9 @@ extension MeetReviewListViewController: UIScrollViewDelegate { } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 부모 헤더 sticky/hide 처리용 offset 전달 + onScrollChange?(scrollView.contentOffset.y) + guard scrollView.isBottom(threshold: 50), reactor?.page?.hasNext == true else { return } nextPage.onNext(()) diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift index 8559e5ac..65e9f6f8 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewController.swift @@ -13,63 +13,128 @@ import RxCocoa import ReactorKit final class MeetDetailViewController: TitleNaviViewController, View { - + // MARK: - Reactor typealias Reactor = MeetDetailViewReactor var disposeBag: DisposeBag = DisposeBag() // MARK: - Observables private let endFlow: PublishSubject = .init() - + // MARK: - UI Components + + // 본문 컨테이너 (네비바 아래 전체) private let contentView: UIView = { let view = UIView() view.backgroundColor = .bgSecondary return view }() - - private let borderView: UIView = { - let view = UIView() - view.backgroundColor = .appStroke - view.layer.makeCornes(radius: 16, corners: [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]) - return view + + // 네비 중앙: 모임 썸네일 + 이름 + private let naviTitleView = MeetDetailNaviTitleView() + + // 네비 우측 확성기 버튼 (rightButton(햄버거) 왼쪽에 배치) + 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 + return btn }() - - private let thumbnailView: MeetDetailThumbnail = { - let view = MeetDetailThumbnail(thumbnailSize: 56, - thumbnailRadius: 12) - view.setTitleLabel(font: FontStyle.Title2.semiBold, - color: .text01) - view.setSpacing(12) - return view + + // 확성기 배지 점 (공지 존재 시 노출) + private let megaphoneBadge: UIView = { + let dot = UIView() + dot.backgroundColor = .appPrimary + dot.layer.cornerRadius = 4 + dot.isHidden = true + return dot }() - - private let segment: DefaultSegmentedControl = { - let buttonTitles = [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] - let segControl = DefaultSegmentedControl(buttonTitles: buttonTitles) - return segControl + + // 작성 유도 툴팁 (모임장 + 공지 없음 조건). 위쪽에 삼각형 꼬리가 달린 말풍선. + private let composeTooltipView: TooltipBalloonView = { + let v = TooltipBalloonView(text: "공지를 작성해보세요.") + v.isHidden = true + return v }() - - private lazy var headerStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [thumbnailView, segment]) - stackView.axis = .vertical - stackView.distribution = .fill - stackView.alignment = .fill - stackView.spacing = 24 - stackView.backgroundColor = .bgPrimary - stackView.layer.makeShadow(opactity: 0.02, radius: 12, offset: .init(width: 0, height: 0)) - stackView.layer.makeCornes(radius: 16, corners: [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]) - stackView.isLayoutMarginsRelativeArrangement = true - stackView.layoutMargins = .init(top: 20, left: 20, bottom: 20, right: 20) - return stackView + + // 공지 미리보기 카드 + private let noticePreviewView = MeetDetailNoticePreviewView() + + // pill 세그먼트 + private let pillSegment = MeetDetailPillSegment( + titles: [L10n.Meetdetail.planlist, L10n.Meetdetail.reviwelist] + ) + + // 공지 카드 wrapper. height constraint와 isHidden을 함께 토글해서 가변 처리. + // 기본은 isHidden = true / height = 0 → mock 도착 전에도 약속 탭이 sticky 위치(navi 아래 16)에 있음. + // pinnedNotice 도착 시 applyMeet에서 isHidden = false / height = 80으로 동시 전환. + private let noticePreviewContainer: UIView = { + let v = UIView() + v.clipsToBounds = true + v.isHidden = true + return v }() - + private var noticeHeightConstraint: Constraint? + + // pill을 감싸는 wrapper. frame.maxY/minY를 측정해서 inset과 hideMax를 계산. + private let pillWrap = UIView() + + // 공지 + pill을 하나의 헤더 단위로 묶음. 자식 스크롤 시 transform으로 위로 슬라이드. + // 공지 height가 0(기본)이면 wrapper도 0, 80이면 80 — 그에 따라 pillWrap.minY/maxY가 자동 변동. + // PassThroughStackView로 만들어서 자체 영역의 hit는 통과시키고, 자식 view만 터치를 받게 한다. + private lazy var headerContainer: PassThroughStackView = { + pillWrap.addSubview(pillSegment) + pillSegment.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.bottom.equalToSuperview() + make.width.equalTo(200) + make.height.equalTo(48) + } + + noticePreviewContainer.addSubview(noticePreviewView) + noticePreviewView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview().offset(20) + make.trailing.equalToSuperview().offset(-20) + make.height.equalTo(80) + } + noticePreviewContainer.snp.makeConstraints { make in + self.noticeHeightConstraint = make.height.equalTo(0).constraint + } + + let sv = PassThroughStackView(arrangedSubviews: [noticePreviewContainer, pillWrap]) + sv.axis = .vertical + sv.alignment = .fill + sv.spacing = 16 + sv.isLayoutMarginsRelativeArrangement = true + sv.layoutMargins = UIEdgeInsets(top: 16, left: 0, bottom: 16, right: 0) + return sv + }() + + // PageController 내부 scrollView contentOffset KVO observer. + // 사용자 swipe progress를 selectedPill 보간 이동에 사용. + private var pageScrollObservation: NSKeyValueObservation? + + // 자식 스크롤에 따라 헤더 transform 조정. 약속 탭은 sticky로 navi 아래 16pt에서 멈춘다. + // - inset.top = 약속 탭 maxY + spacing → 첫 셀이 약속 탭 아래에서 시작 (겹치지 않음) + // - hideMax = 약속 탭 sticky 도달까지 필요한 transform 거리 (= 약속 탭 minY - 16) + // inset != hideMax 이므로 1:1 매핑은 아니지만, swipe 시작과 동시에 transform이 진행되고 + // sticky 도달 후 추가 swipe에서는 tableView만 정상 스크롤된다. + private var lastPropagatedInset: CGFloat = -1 + private var lastPropagatedHideMax: CGFloat = -1 + private var currentHideAmount: CGFloat = 0 + private weak var planChild: MeetPlanListViewController? + private weak var reviewChild: MeetReviewListViewController? + + // 일정/리뷰 페이지 영역 private(set) var pageController: UIPageViewController = { let pageVC = UIPageViewController(transitionStyle: .scroll, - navigationOrientation: .horizontal) + navigationOrientation: .horizontal) return pageVC }() - + + // 일정 추가 FAB private let addPlanButton: BaseButton = { let btn = BaseButton() btn.setImage(image: .addButton) @@ -80,85 +145,147 @@ final class MeetDetailViewController: TitleNaviViewController, View { offset: .init(width: 0, height: 0)) return btn }() - + // MARK: - LifeCycle init(screenName: ScreenName, title: String?, reactor: MeetDetailViewReactor?) { super.init(screenName: screenName, - title: title) + title: nil) // 커스텀 중앙 뷰를 쓰므로 기본 title은 비워둔다 self.reactor = reactor } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() setupUI() } - + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + propagateHeaderInsetIfNeeded() + alignTooltipTailToMegaphone() + } + + // tooltip의 trailing이 화면 우측 inset에 고정되어 있으므로, tail의 가로 위치를 + // megaphoneButton의 centerX와 매칭시켜야 확성기를 정확히 가리킨다. + private func alignTooltipTailToMegaphone() { + guard composeTooltipView.bounds.width > 0 else { return } + let megaphoneCenter = megaphoneButton.convert( + CGPoint(x: megaphoneButton.bounds.midX, y: 0), + to: composeTooltipView + ) + composeTooltipView.tailCenterX = megaphoneCenter.x + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // 첫 layout 사이클에서 stale일 수 있는 height를 viewDidAppear 시점에 한 번 더 측정 + propagateHeaderInsetIfNeeded() + } + // MARK: - UI Setup private func setupUI() { - setLayout() setupNavi() + setLayout() + // setupNavi에서 add한 composeTooltipView가 setLayout에서 나중에 add된 contentView/addPlanButton에 + // 가려지지 않도록 가장 앞으로 가져온다. + view.bringSubviewToFront(composeTooltipView) + composeTooltipView.layer.zPosition = 2 } - + private func setLayout() { self.add(child: pageController) - self.view.addSubview(contentView) - self.view.addSubview(addPlanButton) - self.contentView.addSubview(borderView) - self.contentView.addSubview(headerStackView) - self.contentView.addSubview(pageController.view) - + view.addSubview(contentView) + view.addSubview(addPlanButton) + + // pageController.view는 contentView 전체를 채운다 (height 고정). + // 헤더는 그 위에 overlay로 떠서 transform.y로만 슬라이드 → 페이지 컨텐츠 영역은 일정. + contentView.addSubview(pageController.view) + contentView.addSubview(headerContainer) + contentView.snp.makeConstraints { make in make.top.equalTo(self.titleViewBottom) make.horizontalEdges.bottom.equalToSuperview() } - - headerStackView.snp.makeConstraints { make in + + // 헤더 (공지 미리보기 + pill 세그먼트) — 공지 height는 기본 0 (constraint로 처리) + headerContainer.snp.makeConstraints { make in make.top.equalToSuperview() make.horizontalEdges.equalToSuperview() } - - segment.snp.makeConstraints { make in - make.height.equalTo(56) - } - - borderView.snp.makeConstraints { make in - make.top.equalTo(headerStackView) - make.horizontalEdges.equalToSuperview() - make.bottom.equalTo(headerStackView).offset(1) - } - + headerContainer.layer.zPosition = 1 + pageController.view.snp.makeConstraints { make in - make.top.equalTo(borderView.snp.bottom) - make.horizontalEdges.bottom.equalToSuperview() + make.edges.equalToSuperview() } - + addPlanButton.snp.makeConstraints { make in make.size.equalTo(54) make.trailing.equalToSuperview().inset(20) make.bottom.equalToSuperview().inset(24) } } - + private func setupNavi() { self.setBarItem(type: .left) self.setBarItem(type: .right, image: .list) + + // 중앙: 모임 이미지 + 이름 + self.naviBar.addSubview(naviTitleView) + naviTitleView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.centerX.equalToSuperview() + } + + // 확성기 (햄버거 rightButton 좌측에 배치: 20 padding + 40 rightButton + 8 gap = 68) + self.naviBar.addSubview(megaphoneButton) + megaphoneButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(68) + make.size.equalTo(40) + } + + // 배지 점 — 확성기 우상단 + megaphoneButton.addSubview(megaphoneBadge) + megaphoneBadge.snp.makeConstraints { make in + make.size.equalTo(8) + make.top.equalToSuperview().offset(8) + make.trailing.equalToSuperview().inset(8) + } + + // 작성 유도 툴팁 — 확성기 버튼 아래. + // 조건 1: 말풍선의 trailing = 확성기 trailing (오른쪽 끝이 확성기와 정렬) + // 조건 2: tail의 centerX = 확성기의 centerX (꼬리가 확성기 중앙을 가리킴) + // → tail은 viewDidLayoutSubviews의 alignTooltipTailToMegaphone()에서 동적 설정 + view.addSubview(composeTooltipView) + composeTooltipView.snp.makeConstraints { make in + make.top.equalTo(megaphoneButton.snp.bottom).offset(4) + make.trailing.equalTo(megaphoneButton.snp.trailing) + } } - + // MARK: - Gesture public func configureEdgeGesture() { guard let currentNavi = self.findCurrentNavigation(), let appNavi = currentNavi as? AppNaviViewController else { return } - + pageController.viewControllers?.forEach { guard let gestureVC = $0 as? EdgeGestureConfigurable else { return } gestureVC.configureEdgeGesture(appNavi.edgeGesture) } + + // PageController의 paging pan gesture도 edge gesture가 먼저 인식되도록 양보. + // dataSource 활성화 이후 좌측 edge swipe로 modal dismiss가 안 되는 충돌을 해결. + for sv in pageController.view.subviews { + if let scrollView = sv as? UIScrollView { + scrollView.panGestureRecognizer.require(toFail: appNavi.edgeGesture) + break + } + } } } @@ -169,7 +296,7 @@ extension MeetDetailViewController { inputBind(reactor) outputBind(reactor) } - + private func inputBind(_ reactor: Reactor) { setActionBind(reactor) setNotificationBind(reactor) @@ -182,44 +309,46 @@ extension MeetDetailViewController { }) .disposed(by: disposeBag) } - - + + private func setActionBind(_ reactor: Reactor) { self.naviBar.rightItemEvent .map { Reactor.Action.flow(.pushMeetSetupView) } .bind(to: reactor.action) .disposed(by: disposeBag) - - self.segment.rx.tapped + + self.pillSegment.selectedIndexChanged .map { index -> Reactor.Action in - let isFirst = index == 0 - return .flow(.switchPage(isFuture: isFirst)) + let isFuture = index == 0 + return .flow(.switchPage(isFuture: isFuture)) } .bind(to: reactor.action) .disposed(by: disposeBag) - - self.thumbnailView.rx.imageTap - .map { Reactor.Action.flow(.showMeetImage) } - .bind(to: reactor.action) - .disposed(by: disposeBag) - + self.addPlanButton.rx.tap .map { Reactor.Action.flow(.createPlan) } .bind(to: reactor.action) .disposed(by: disposeBag) - + self.naviBar.leftItemEvent .map { Reactor.Action.flow(.endFlow) } .bind(to: reactor.action) .disposed(by: disposeBag) - + self.endFlow .map { Reactor.Action.flow(.endFlow) } .bind(to: reactor.action) .disposed(by: disposeBag) - - self.thumbnailView.inviteButton.rx.tap - .map { Reactor.Action.flow(.memberList) } + + // 확성기 → 공지 리스트 진입 + self.megaphoneButton.rx.tap + .map { Reactor.Action.flow(.openNoticeList) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // 미리보기 카드 → 공지 상세 진입 + self.noticePreviewView.tapEvent + .map { Reactor.Action.flow(.openNoticeDetail) } .bind(to: reactor.action) .disposed(by: disposeBag) } @@ -229,7 +358,7 @@ extension MeetDetailViewController { .map { Reactor.Action.editMeet($0) } .bind(to: reactor.action) .disposed(by: disposeBag) - + NotificationManager.shared.addObservable(name: .midnightUpdate) .map { _ in Reactor.Action.refresh } .bind(to: reactor.action) @@ -241,10 +370,10 @@ extension MeetDetailViewController { .asDriver(onErrorJustReturn: nil) .compactMap({ $0 }) .drive(with: self, onNext: { vc, meet in - vc.thumbnailView.configure(with: .init(meet: meet)) + vc.applyMeet(meet) }) .disposed(by: disposeBag) - + reactor.pulse(\.$inviteUrl) .asDriver(onErrorJustReturn: nil) .compactMap { [weak self] url -> String? in @@ -256,7 +385,7 @@ extension MeetDetailViewController { vc.showActivityViewController(items: [url]) }) .disposed(by: disposeBag) - + Observable.merge(reactor.pulse(\.$meetInfoLoaded), reactor.pulse(\.$futurePlanLoaded), reactor.pulse(\.$pastPlanLoaded)) @@ -268,7 +397,7 @@ extension MeetDetailViewController { .map({ _ in true }) .drive(self.rx.isLoading) .disposed(by: disposeBag) - + Observable.combineLatest(reactor.pulse(\.$meetInfoLoaded), reactor.pulse(\.$futurePlanLoaded), reactor.pulse(\.$pastPlanLoaded)) @@ -282,7 +411,7 @@ extension MeetDetailViewController { .asDriver(onErrorJustReturn: false) .drive(self.rx.isLoading) .disposed(by: disposeBag) - + reactor.pulse(\.$error) .asDriver(onErrorJustReturn: nil) .compactMap { $0 } @@ -291,7 +420,133 @@ extension MeetDetailViewController { }) .disposed(by: disposeBag) } - + + // 모임 데이터 → UI 반영 (네비 중앙, 공지 카드, 확성기 배지, 작성 유도 툴팁) + private func applyMeet(_ meet: Meet) { + naviTitleView.configure(name: meet.meetSummary?.name, + imagePath: meet.meetSummary?.imagePath) + + let pinned = meet.pinnedNotice + let hasNotice = pinned != nil + + if let content = pinned?.content { + noticePreviewView.configure(content: content) + } + + // 공지 카드 토글: + // isHidden: UIStackView가 자동 collapse — 자식 + 인접 spacing이 함께 빠짐 + // → 약속 탭이 navi 바로 아래(layoutMargin.top 16만)에 위치 + // height : 명시적 안전망 (isHidden 처리 외 상황에서도 layout 명확) + let targetHeight: CGFloat = hasNotice ? 80 : 0 + noticePreviewContainer.isHidden = !hasNotice + noticeHeightConstraint?.update(offset: targetHeight) + view.layoutIfNeeded() + propagateHeaderInsetIfNeeded() + + // 헤더 높이가 바뀔 수 있으므로 sticky 상태도 재계산 + applyHide(currentHideAmount) + + // 확성기 파란 점 배지 + megaphoneBadge.isHidden = !hasNotice + + // 모임장 + 공지 없음 → 작성 유도 툴팁 + composeTooltipView.isHidden = !(meet.isCreator && !hasNotice) + } + + // MARK: - Sticky Header + // Coordinator가 page child VC들을 생성한 직후 wire-up. + // 두 자식 모두 같은 콜백으로 묶어 헤더 transform이 한 곳에서만 변하도록 한다. + // 그리고 layout 사이클 후 setTopContentInset(headerHeight)로 자식 tableView의 상단을 비워둔다. + func attachChildScrollObservers(_ children: UIViewController...) { + for child in children { + if let plan = child as? MeetPlanListViewController { + planChild = plan + plan.onScrollChange = { [weak self] offset in + self?.handleChildScroll(offset) + } + } else if let review = child as? MeetReviewListViewController { + reviewChild = review + review.onScrollChange = { [weak self] offset in + self?.handleChildScroll(offset) + } + } + } + // 즉시 layout을 강제해서 attach 직후에도 인셋 전파가 일어나도록 한다. + // setNeedsLayout만으로는 다음 runloop으로 미뤄지고, 그 사이 child가 그려지면 + // contentInset 없이 잘못된 위치에서 첫 표시될 수 있다. + view.layoutIfNeeded() + propagateHeaderInsetIfNeeded() + + // 인터랙티브 swipe ↔ pill 트래킹 setup + setupInteractivePageSwipe() + } + + // PageController 좌우 swipe와 pill 세그먼트 selectedPill을 동기화한다. + // - dataSource로 양방향 swipe 활성화 (plan ↔ review) + // - delegate로 transition 완료 후 selectedIndex 동기화 + // - 내부 scrollView contentOffset KVO로 progress 추출 → pill에 보간 이동 적용 + private func setupInteractivePageSwipe() { + pageController.dataSource = self + pageController.delegate = self + + for sv in pageController.view.subviews { + guard let scrollView = sv as? UIScrollView else { continue } + pageScrollObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] sv, _ in + self?.handlePageInteractiveScroll(sv) + } + break + } + } + + // UIPageViewController scrollView 동작: + // 정상 상태 contentOffset.x = bounds.width (가운데 페이지가 visible) + // 왼→오 swipe: x < bounds.width (이전 페이지 방향) + // 오→왼 swipe: x > bounds.width (다음 페이지 방향) + private func handlePageInteractiveScroll(_ sv: UIScrollView) { + let pageWidth = sv.bounds.width + guard pageWidth > 0 else { return } + let progress = (sv.contentOffset.x - pageWidth) / pageWidth + pillSegment.setInteractiveProgress(progress) + } + + // 헤더 layout 변동 시(공지 hidden ↔ visible, 첫 표시 등) 자식 tableView에 inset 전파. + // 두 값을 계산해 보관: + // inset = 약속 탭 maxY + spacing (= pillWrap.frame.maxY + 16) + // → 자식 tableView contentInset.top. 셀이 약속 탭 아래에서 시작. + // hideMax = 약속 탭이 sticky 위치(navi 아래 16pt)까지 이동해야 하는 transform 거리 + // = pillWrap.frame.minY - 16 + private func propagateHeaderInsetIfNeeded() { + headerContainer.layoutIfNeeded() + let inset = pillWrap.frame.maxY + 16 + let hideMax = max(0, pillWrap.frame.minY - 16) + guard inset > 0, + abs(inset - lastPropagatedInset) > 0.5 else { return } + lastPropagatedInset = inset + lastPropagatedHideMax = hideMax + planChild?.setTopContentInset(inset) + reviewChild?.setTopContentInset(inset) + } + + // 자식 VC가 emit하는 contentOffset.y에 따라 헤더가 슬라이드되며 사라지고 다시 나타난다. + // swipeDistance = offset + inset (사용자가 위로 swipe한 누적 거리, 0 이상) + // hide = min(hideMax, swipeDistance) + // → swipe 0 ~ hideMax: 공지 사라지면서 약속 탭이 sticky 위치 도달 + // → swipe hideMax+: sticky 유지, tableView만 자체 스크롤 (약속 탭 transform 더 안 됨) + private func handleChildScroll(_ offset: CGFloat) { + let inset = lastPropagatedInset + let hideMax = lastPropagatedHideMax + guard inset > 0 else { return } + let swipeDistance = max(0, offset + inset) + let hide = min(hideMax, swipeDistance) + guard abs(hide - currentHideAmount) > 0.5 else { return } + currentHideAmount = hide + applyHide(hide) + } + + private func applyHide(_ amount: CGFloat) { + headerContainer.transform = CGAffineTransform(translationX: 0, y: -amount) + } + // MARK: - 에러 핸들링 private func handleError(_ err: MeetDetailError) { switch err { @@ -316,9 +571,56 @@ extension MeetDetailViewController { let inviteComment = L10n.Meetdetail.inviteMessage return inviteComment + "\n" + url } - + private func showActivityViewController(items: [Any]) { let ac = UIActivityViewController(activityItems: items, applicationActivities: nil) self.present(ac, animated: true) } } + +// MARK: - PassThroughStackView +// 자기 영역의 hit는 통과시키고 자식 view들의 hit만 잡는 UIStackView. +// 헤더 overlay 영역에서도 그 아래 tableView의 swipe가 동작하도록 한다. +final class PassThroughStackView: UIStackView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + return hit === self ? nil : hit + } +} + +// MARK: - UIPageViewController DataSource / Delegate (인터랙티브 swipe) +extension MeetDetailViewController: UIPageViewControllerDataSource { + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController) -> UIViewController? { + // review의 이전 = plan + if viewController === reviewChild { return planChild } + return nil + } + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController) -> UIViewController? { + // plan의 다음 = review + if viewController === planChild { return reviewChild } + return nil + } +} + +extension MeetDetailViewController: UIPageViewControllerDelegate { + func pageViewController(_ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + // 전환 실패(복귀): transform reset만 + guard completed else { + pillSegment.commitInteractiveTransition(to: pillSegment.selectedIndex) + return + } + // 전환 성공: 현재 visible VC로 selectedIndex 확정 + guard let currentVC = pageViewController.viewControllers?.first else { return } + let newIndex: Int + if currentVC === planChild { newIndex = 0 } + else if currentVC === reviewChild { newIndex = 1 } + else { return } + pillSegment.commitInteractiveTransition(to: newIndex) + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift index 0e93d710..fc11d9f3 100644 --- a/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/MeetDetailViewReactor.swift @@ -31,6 +31,8 @@ final class MeetDetailViewReactor: Reactor, LifeCycleLoggable { case endFlow case showMeetImage case memberList + case openNoticeList + case openNoticeDetail } enum Loading { @@ -188,8 +190,18 @@ extension MeetDetailViewReactor { imagePath: imagePath) case .memberList: coordinator?.pushMemberListView() + case .openNoticeList: + // 확성기 버튼 → 공지 리스트 진입 + guard let meet = currentState.meet, + let meetId = meet.meetSummary?.id else { return .empty() } + coordinator?.presentNoticeListView(meetId: meetId, + isCreator: meet.isCreator) + case .openNoticeDetail: + // 미리보기 카드 → 공지 상세 진입 (pinnedNotice가 있을 때만) + guard let noticeId = currentState.meet?.pinnedNotice?.noticeId else { return .empty() } + coordinator?.presentNoticeDetailView(noticeId: noticeId) } - + return .empty() } } diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift new file mode 100644 index 00000000..a26a2bf3 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNaviTitleView.swift @@ -0,0 +1,69 @@ +// +// MeetDetailNaviTitleView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit +import Kingfisher + +// 모임 상세 네비바 중앙 — 모임 썸네일(28×28) + 이름(Medium 14) 가로 스택. +// 기존 TitleNaviBar의 titleLabel을 가리는 형태로 addSubview한다. +final class MeetDetailNaviTitleView: UIView { + + private let thumbnailImageView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.layer.cornerRadius = 6 + iv.layer.borderWidth = 1 + iv.layer.borderColor = UIColor.appStroke.cgColor + return iv + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = FontStyle.Body1.medium + label.textColor = .text01 + label.textAlignment = .left + return label + }() + + private lazy var stackView: UIStackView = { + let sv = UIStackView(arrangedSubviews: [thumbnailImageView, nameLabel]) + sv.axis = .horizontal + sv.alignment = .center + sv.spacing = 8 + return sv + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(stackView) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + thumbnailImageView.snp.makeConstraints { make in + make.size.equalTo(28) + } + } + + func configure(name: String?, imagePath: String?) { + nameLabel.text = name + if let path = imagePath, let url = URL(string: path) { + thumbnailImageView.kf.setImage(with: url, placeholder: UIImage(named: "defaultMeet")) + } else { + thumbnailImageView.image = UIImage(named: "defaultMeet") + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift new file mode 100644 index 00000000..2a5a1a49 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailNoticePreviewView.swift @@ -0,0 +1,112 @@ +// +// MeetDetailNoticePreviewView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +// MeetDetail 본문 상단의 공지 미리보기 카드 (80px / r12 / bg white / shadow). +// pinnedNotice가 nil일 때는 숨김 처리(외부에서 isHidden 토글). +final class MeetDetailNoticePreviewView: UIView { + + // MARK: - Public + var tapEvent: ControlEvent { tapControl.rx.controlEvent(.touchUpInside) } + + // MARK: - UI Components + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .bgPrimary + view.layer.cornerRadius = 12 + view.layer.makeShadow(opactity: 0.02, radius: 12, offset: .init(width: 0, height: 0)) + return view + }() + + // 헤더(아이콘 + 라벨) + private let iconImageView: UIImageView = { + let iv = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) + iv.image = UIImage(systemName: "megaphone.fill", withConfiguration: config) + iv.tintColor = .defaultRed1 + iv.contentMode = .scaleAspectFit + return iv + }() + + private let headerLabel: UILabel = { + let label = UILabel() + label.font = FontStyle.Body2.semiBold + label.textColor = .text03 + label.text = "공지알림" + return label + }() + + private lazy var headerStack: UIStackView = { + let sv = UIStackView(arrangedSubviews: [iconImageView, headerLabel]) + sv.axis = .horizontal + sv.alignment = .center + sv.spacing = 4 + return sv + }() + + private let contentLabel: UILabel = { + let label = UILabel() + label.font = FontStyle.Body1.medium + label.textColor = .text02 + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var verticalStack: UIStackView = { + let sv = UIStackView(arrangedSubviews: [headerStack, contentLabel]) + sv.axis = .vertical + sv.alignment = .leading + sv.spacing = 4 + return sv + }() + + // 카드 전체를 덮는 투명 버튼 — 탭 이벤트 단순화 + private let tapControl: UIControl = { + let c = UIControl() + c.backgroundColor = .clear + return c + }() + + // MARK: - LifeCycle + override init(frame: CGRect) { + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(containerView) + containerView.addSubview(verticalStack) + containerView.addSubview(tapControl) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + verticalStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + iconImageView.snp.makeConstraints { make in + make.size.equalTo(20) + } + tapControl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + // MARK: - Configure + func configure(content: String?) { + contentLabel.text = content + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift new file mode 100644 index 00000000..4764069a --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/MeetDetailPillSegment.swift @@ -0,0 +1,170 @@ +// +// MeetDetailPillSegment.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +// 예정된 약속 / 지난 약속 알약(pill) 세그먼트. +// 전체 폭 200, 높이 48, 반지름 22의 둥근 알약 + 좌우 6 padding. +// 선택 시 appPrimary 배경 + 흰 글자 + 미세 그림자. +final class MeetDetailPillSegment: UIView { + + // MARK: - Public + private(set) var selectedIndex: Int = 0 + var selectedIndexChanged: Observable { selectedSubject.asObservable() } + + // MARK: - Private + private let selectedSubject = PublishSubject() + private let disposeBag = DisposeBag() + private let titles: [String] + private var buttons: [UIButton] = [] + + // 선택된 알약 — 버튼 frame에 맞춰 슬라이드. + // button height = container(48) - padding(6+6) = 36 → cornerRadius 18 (capsule) + private let selectedPill: UIView = { + let v = UIView() + v.backgroundColor = .appPrimary + v.layer.cornerRadius = 18 + v.layer.makeShadow(opactity: 0.12, radius: 8, offset: .init(width: 0, height: 0)) + return v + }() + + private lazy var stackView: UIStackView = { + let sv = UIStackView(arrangedSubviews: buttons) + sv.axis = .horizontal + sv.distribution = .fillEqually + sv.alignment = .fill + sv.spacing = 8 + return sv + }() + + private let containerView: UIView = { + let v = UIView() + v.backgroundColor = UIColor.bgPrimary.withAlphaComponent(0.6) + v.layer.cornerRadius = 24 + return v + }() + + init(titles: [String], defaultIndex: Int = 0) { + self.titles = titles + self.selectedIndex = defaultIndex + super.init(frame: .zero) + makeButtons() + setupUI() + bind() + DispatchQueue.main.async { [weak self] in + self?.updatePillPosition(animated: false) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func makeButtons() { + buttons = titles.enumerated().map { index, title in + let btn = UIButton() + btn.setTitle(title, for: .normal) + btn.titleLabel?.font = FontStyle.Body1.semiBold + btn.setTitleColor(.text03, for: .normal) + btn.setTitleColor(.primaryText, for: .selected) + btn.tag = index + btn.isSelected = (index == selectedIndex) + return btn + } + } + + private func setupUI() { + addSubview(containerView) + containerView.addSubview(selectedPill) + containerView.addSubview(stackView) + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(6) + } + } + + private func bind() { + for button in buttons { + button.rx.controlEvent(.touchUpInside) + .map { button.tag } + .subscribe(with: self, onNext: { owner, idx in + owner.select(index: idx) + }) + .disposed(by: disposeBag) + } + } + + private func select(index: Int) { + guard index != selectedIndex else { return } + selectedIndex = index + buttons.enumerated().forEach { $0.element.isSelected = ($0.offset == index) } + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) { [weak self] in + self?.updatePillPosition(animated: true) + self?.layoutIfNeeded() + } + selectedSubject.onNext(index) + } + + private func updatePillPosition(animated: Bool) { + guard let target = buttons[safe: selectedIndex] else { return } + selectedPill.snp.remakeConstraints { make in + make.edges.equalTo(target) + } + } +} + +// MARK: - Interactive Transition (PageController swipe와 연동) +extension MeetDetailPillSegment { + + // PageController가 emit하는 -1.0 ~ +1.0 progress를 받아 selectedPill x 위치를 보간. + // progress > 0 → 다음 인덱스 방향, progress < 0 → 이전 인덱스 방향. + // transform.x로만 이동시켜서 constraint 변경 없이 가볍게 처리한다. + func setInteractiveProgress(_ progress: CGFloat) { + let clamped = max(-1, min(1, progress)) + let baseIndex = selectedIndex + + let targetIndex: Int + if clamped > 0 && baseIndex < buttons.count - 1 { + targetIndex = baseIndex + 1 + } else if clamped < 0 && baseIndex > 0 { + targetIndex = baseIndex - 1 + } else { + // 끝 페이지에서 그 이상 방향 swipe — bounce. transform 0. + selectedPill.transform = .identity + return + } + + guard let baseBtn = buttons[safe: baseIndex], + let targetBtn = buttons[safe: targetIndex] else { return } + + let deltaX = (targetBtn.frame.minX - baseBtn.frame.minX) * abs(clamped) + selectedPill.transform = CGAffineTransform(translationX: deltaX, y: 0) + } + + // PageController 전환 결과 확정 시 호출. + // - 새 인덱스로 전환 성공: selectedIndex 갱신 + constraint 새 버튼으로 remake + transform reset + // - 복귀(전환 실패): transform만 reset + // selectedIndexChanged stream은 emit하지 않는다 — 외부 PageController swipe로 인한 전환이라 + // 부모로 다시 통지하면 루프 위험. + func commitInteractiveTransition(to newIndex: Int) { + guard newIndex != selectedIndex, + newIndex >= 0, newIndex < buttons.count else { + selectedPill.transform = .identity + return + } + selectedIndex = newIndex + buttons.enumerated().forEach { $0.element.isSelected = ($0.offset == newIndex) } + selectedPill.transform = .identity + updatePillPosition(animated: false) + } +} diff --git a/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift new file mode 100644 index 00000000..0936b412 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/MeetDetail/View/SubView/TooltipBalloonView.swift @@ -0,0 +1,102 @@ +// +// TooltipBalloonView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import SnapKit + +// 본체: figma 사양 (145×40, padding 16/10, corner 8, bg white, text Body1.SemiBold text02) +// tail: 위쪽 작은 삼각형 (figma엔 없지만 디자인 시안에 따라 어디를 가리키는지 명확히 하기 위해 추가) +// 본체 + tail을 하나의 UIBezierPath로 합쳐 CAShapeLayer로 그린다. +// 동일 path를 layer.shadowPath에 재사용해 그림자도 모양 그대로 따라가게. +final class TooltipBalloonView: UIView { + + // MARK: - Style + private let tailHeight: CGFloat = 6 + private let tailWidth: CGFloat = 12 + private let cornerRadius: CGFloat = 8 + private let horizontalPadding: CGFloat = 16 + private let verticalPadding: CGFloat = 10 + + // tail 가로 위치 (말풍선 좌표 기준). nil이면 중앙. + var tailCenterX: CGFloat? { + didSet { setNeedsLayout() } + } + + // MARK: - Subviews + private let shapeLayer = CAShapeLayer() + + private let label: UILabel = { + let lb = UILabel() + lb.font = FontStyle.Body1.semiBold + lb.textColor = .text02 + lb.numberOfLines = 0 + return lb + }() + + // MARK: - Init + init(text: String) { + super.init(frame: .zero) + label.text = text + setupLayer() + setupLabel() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayer() { + shapeLayer.fillColor = UIColor.bgPrimary.cgColor + layer.insertSublayer(shapeLayer, at: 0) + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.08 + layer.shadowRadius = 8 + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.masksToBounds = false + } + + private func setupLabel() { + addSubview(label) + label.snp.makeConstraints { make in + // 본체 영역(tail 아래)에 padding 적용 + make.top.equalToSuperview().offset(tailHeight + verticalPadding) + make.bottom.equalToSuperview().offset(-verticalPadding) + make.leading.equalToSuperview().offset(horizontalPadding) + make.trailing.equalToSuperview().offset(-horizontalPadding) + } + } + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + updateShapePath() + } + + private func updateShapePath() { + let w = bounds.width + let h = bounds.height + guard w > 0, h > tailHeight else { return } + + // 본체: tail 아래 영역의 둥근 사각형 + let bodyRect = CGRect(x: 0, y: tailHeight, width: w, height: h - tailHeight) + let path = UIBezierPath(roundedRect: bodyRect, cornerRadius: cornerRadius) + + // tail: 위쪽 정점, 밑변은 본체 상단에 닿음 + let cx = tailCenterX ?? (w / 2) + let tail = UIBezierPath() + tail.move(to: CGPoint(x: cx - tailWidth / 2, y: tailHeight)) + tail.addLine(to: CGPoint(x: cx, y: 0)) + tail.addLine(to: CGPoint(x: cx + tailWidth / 2, y: tailHeight)) + tail.close() + + path.append(tail) + + shapeLayer.path = path.cgPath + layer.shadowPath = path.cgPath + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift b/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift new file mode 100644 index 00000000..7696e4dd --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/NoticeFlowCoordinator.swift @@ -0,0 +1,71 @@ +// +// NoticeFlowCoordinator.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import UIKit +import Domain + +// 모임 공지 Flow. +// MeetDetail에서 두 가지 진입점(미리보기 카드 → 상세, 확성기 → 리스트)을 가지며 +// 리스트 안에서 작성 화면(모임장 전용)으로 push. +enum NoticeFlowEntry { + case detail(noticeId: Int) + case list(meetId: Int, isCreator: Bool) +} + +protocol NoticeFlowCoordination: AnyObject { + func pushComposeView() + func endFlow() +} + +final class NoticeFlowCoordinator: BaseCoordinator, NoticeFlowCoordination { + + private let dependencies: NoticeSceneDependencies + private let entry: NoticeFlowEntry + + init(dependencies: NoticeSceneDependencies, + entry: NoticeFlowEntry, + navigationController: AppNaviViewController) { + self.dependencies = dependencies + self.entry = entry + super.init(navigationController: navigationController) + setDismissGestureCompletion() + } + + override func start() { + switch entry { + case .detail(let noticeId): + let vc = dependencies.makeNoticeDetailViewController(noticeId: noticeId) + self.pushWithTracking(vc, animated: false) + + case .list(let meetId, let isCreator): + let vc = dependencies.makeNoticeListViewController(meetId: meetId, + isCreator: isCreator, + coordinator: self) + self.pushWithTracking(vc, animated: false) + } + } +} + +// MARK: - Compose Flow (리스트 → 작성) +extension NoticeFlowCoordinator { + func pushComposeView() { + guard case .list(let meetId, _) = entry else { return } + let vc = dependencies.makeNoticeComposeViewController(meetId: meetId) + self.pushWithTracking(vc, animated: true) + } +} + +// MARK: - End Flow +extension NoticeFlowCoordinator { + func endFlow() { + self.navigationController.dismiss(animated: true) { [weak self] in + guard let self else { return } + self.clearUp() + self.parentCoordinator?.didFinish(coordinator: self) + } + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift new file mode 100644 index 00000000..76bf5be1 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeComposeView.swift @@ -0,0 +1,28 @@ +// +// NoticeComposeView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import SwiftUI + +// Phase 1 placeholder. 공지 리스트 화면의 작성 버튼에서만 진입. 모임장만. +struct NoticeComposeView: View { + + let meetId: Int + + 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)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지 작성") + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift new file mode 100644 index 00000000..a0454403 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeDetailView.swift @@ -0,0 +1,28 @@ +// +// NoticeDetailView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import SwiftUI + +// Phase 1 placeholder. 미리보기 카드에서 진입. +struct NoticeDetailView: View { + + let noticeId: Int + + var body: some View { + VStack(spacing: 12) { + Text("공지 상세 화면 (구현 예정)") + .font(.custom(FontFamily.Pretendard.semiBold, size: FontStyle.Size.title3)) + .foregroundColor(Color(uiColor: .text01)) + Text("noticeId: \(noticeId)") + .font(.custom(FontFamily.Pretendard.medium, size: FontStyle.Size.body1)) + .foregroundColor(Color(uiColor: .text03)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지 상세") + } +} diff --git a/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift new file mode 100644 index 00000000..9b7edef5 --- /dev/null +++ b/Mople/Presentation/MainScene/Sub/Notice/View/NoticeListView.swift @@ -0,0 +1,48 @@ +// +// NoticeListView.swift +// Mople +// +// Created by CatSlave on 5/26/26. +// + +import SwiftUI + +// Phase 1 placeholder. 본 화면은 후속 PR에서 SwiftUI + @Observable + async/await 으로 구현. +// 확성기 버튼에서 진입. 작성 진입점은 isCreator일 때만 노출 예정. +struct NoticeListView: View { + + let meetId: Int + let isCreator: Bool + let onComposeTap: () -> Void + + 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) + } + .padding(.horizontal, 20) + .padding(.top, 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .bgPrimary)) + .customNavigationBar(title: "공지사항") + } +}