Skip to content

Design/#10 travel follow contents#15

Merged
KimNahun merged 21 commits into
developfrom
design/#10-Travel_Follow_Contents
Jan 29, 2026
Merged

Design/#10 travel follow contents#15
KimNahun merged 21 commits into
developfrom
design/#10-Travel_Follow_Contents

Conversation

@KimNahun
Copy link
Copy Markdown
Contributor

@KimNahun KimNahun commented Jan 28, 2026

🔗 연결된 이슈

📄 작업 내용

  • 따라가기 컨텐츠 상세 화면 구현 (FollowDetail RIBs)
  • 실제 API 호출 연동으로 따라가기 컨텐츠 데이터 표시
  • 새로운 여행 만들기 화면 구현 (캘린더 날짜 선택)
  • 커스텀 바텀시트 구현 (장소 상세 정보)
  • Sticky Header 구현 (스크롤 시 일차 선택 고정)
  • 여행 담기 완료 시 다가오는 여행 탭으로 자동 이동
구현 내용 스크린샷
따라가기 상세
캘린더
바텀시트

💻 주요 코드 설명

CalendarView

  • 날짜 범위 선택이 가능한 커스텀 캘린더 구현
  • 과거 날짜 선택 불가, 시작/종료일 선택 시 범위 하이라이트
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {                             
    guard let day = days[indexPath.item],                                                                                   
          let date = dateFor(day: day),                                                                                     
          !isPastDate(date) else { return }                                                                                 
                                                                                                                            
    if selectedStartDate == nil {                                                                                           
        selectedStartDate = date                                                                                            
    } else if let startDate = selectedStartDate, selectedEndDate == nil {                                                   
        if date < startDate {                                                                                               
            selectedEndDate = selectedStartDate                                                                             
            selectedStartDate = date                                                                                        
        } else {                                                                                                            
            selectedEndDate = date                                                                                          
        }                                                                                                                   
    }                                                                                                                       
}                                                                                                                           
                                                                                                                            
FollowDetailViewController                                                                                                  
- Sticky Header로 스크롤 시 일차 선택 CollectionView 고정                                                                   
func scrollViewDidScroll(_ scrollView: UIScrollView) {                                                                      
    let offsetY = scrollView.contentOffset.y                                                                                
    let threshold = dayCollectionViewOriginY - 16                                                                           
    stickyHeaderView.isHidden = offsetY < threshold                                                                         
}                                                                                                                           
                                                                                                                            
TabBarInteractor                                                                                                            
- 여행 담기 완료 후 Travel 탭으로 이동 및 데이터 전달                                                                       
func navigateToTravelWithNewTrip(_ trip: UpcomingTrip) {                                                                    
    router?.switchToTravelTab()                                                                                             
    travelInteractor?.addTrip(trip)                                                                                         
}                                     

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **새로운 기능**
* 팔로우 기반 여행 상세 화면 추가(미디어, 장소 목록, 지도, 예산, 상세 보기 및 하단 추가 버튼)
* 일정 선택용 대화형 캘린더 및 TripCalendar 흐름 추가(범위 선택 및 완료)
* Travel 탭과 예정된 여행 목록 UI 추가

* **개선 사항**
* 탭 바 내비게이션 연동 강화(홈↔팔로우↔여행 탭 전환)
* 하단 시트, 캘린더, 목록 등 UI 컴포넌트 개선 및 색상/레이아웃 조정

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@KimNahun KimNahun requested a review from ChoiAnYong January 28, 2026 18:05
@KimNahun KimNahun self-assigned this Jan 28, 2026
@KimNahun KimNahun added the ✨ design 디자인 개발 label Jan 28, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 28, 2026

Walkthrough

Follow 및 Travel 기능을 추가합니다. FollowFeature는 여행 상세·일정 조회, 달력 기반 일정 선택 및 하위 TripCalendar RIB을 도입하고, TravelFeature는 예정된 여행 목록을 제공하는 모듈을 추가합니다. 네트워크 DTO/서비스, 도메인 모델, 리포지토리와 DI도 함께 추가되었습니다.

Changes

Cohort / File(s) Summary
Dependency / Project helpers
Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift
TargetDependency.Features에 Follow, Travel 그룹/feature 항목 추가.
Networks - Follow API
Projects/Modules/Networks/Sources/TargetType/FollowAPI.swift, .../Service/FollowService.swift, .../DTO/Follow/FollowDTO.swift, .../ErrorMapping/FollowError+Mapping.swift
Follow API 엔드포인트·DTO·서비스·에러 매핑 추가 및 구현.
Domain - Models & Protocols
Projects/Domain/Sources/Model/Travel/TravelDetail.swift, .../Travel/TravelPlace.swift, .../Model/Follow/FollowError.swift, Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift, .../Model/Travel/PopularTrip.swift
TravelDetail/YouTubeInfo, TravelPlace/PlaceInfo 모델 추가, FollowError 타입 추가, FollowRepositoryProtocol 정의, PopularTrip 한글(rawValue) 오타 수정.
Data - Follow implementation
Projects/Data/Sources/Repository/Follow/FollowRepository.swift, Projects/Data/Sources/Transform/Follow/FollowTransform.swift, Projects/Data/Sources/DI/FollowRepositoryFactory.swift
FollowRepository 구현(서비스 주입), 응답→도메인 변환(toDomain) 유틸리티, DI 팩토리 추가.
Features - FollowFeature (RIBs & UI)
Projects/Features/FollowFeature/.../Project.swift, FollowDetailBuilder.swift, FollowDetailInteractor.swift, FollowDetailRouter.swift, FollowDetailViewController.swift, Sources/TripCalendar/*, Sources/Views/*, Sources/Views/Cells/*, Sources/Views/CollectionViews/*, Sources/Mock/MockFollowDetailRepository.swift
FollowDetail 화면 RIBs 구현, TripCalendar 서브 RIBs, 상세 UI 컴포넌트(미디어, 달력, 지도, 장소 리스트 등), Mock 리포지토리 제공.
Features - TravelFeature (RIBs & UI)
Projects/Features/TravelFeature/Project.swift, TravelBuilder.swift, TravelInteractor.swift, TravelRouter.swift, TravelViewController.swift, Sources/Views/UpcomingTripCell.swift, Model/UpcomingTrip.swift
새로운 TravelFeature 모듈과 예정된 여행 모델/뷰 추가.
홈/탭 통합 변경
Projects/Features/HomeFeature/..., Projects/Features/TabBarFeature/...
Home/TabBar 컴포넌트에 Follow/Travel 빌더/의존성 연결 및 라우팅/탭 전환 로직 추가, 일부 색상/레이아웃 제약 조정.
UI 컴포넌트 모듈(DSKit)
Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift, BottomSheetViewController.swift, NDGL* 파일군
하단 버튼 및 바텀시트 컴포넌트 추가/수정, 일부 색상 하드코딩 변경.
Housekeeping
Projects/Data/Sources/Repository/RepoEmpty.swift (삭제), Projects/Data/Sources/Transform/TransEmpty.swift (삭제)
사용되지 않는 플레이스홀더 파일 제거.

Sequence Diagram

sequenceDiagram
    participant User as 사용자
    participant VC as FollowDetailViewController
    participant Interactor as FollowDetailInteractor
    participant Repo as FollowRepository
    participant Service as FollowService
    participant API as FollowAPI

    User->>VC: 화면 로드
    VC->>Interactor: activate / viewReady
    Interactor->>Repo: fetchTravelDetail(id)
    Repo->>Service: getContentCard(id)
    Service->>API: 네트워크 요청
    API-->>Service: FollowContentCardResponse
    Service-->>Repo: NetworkResult
    Repo->>Repo: toDomain() 변환
    Repo-->>Interactor: TravelDetail?
    Interactor->>VC: updateTravelDetail(_:)

    User->>VC: 날짜 선택/일(day) 선택
    VC->>Interactor: didSelectDay(day)
    Interactor->>Repo: fetchPlaces(travelId, day)
    Repo->>Service: getItinerary(id, day)
    Service->>API: 네트워크 요청
    API-->>Service: FollowItineraryResponse
    Service-->>Repo: NetworkResult
    Repo->>Repo: toDomain() 변환
    Repo-->>Interactor: [TravelPlace]
    Interactor->>VC: updatePlaces(_:)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Suggested reviewers

  • ChoiAnYong

Poem

🐰 따라가는 길에 당근을 물고,
여행지도 위에 발자국을 그려요.
달력에 날을 집어 넣고, 별을 담아,
함께 떠나는 이야기 한 줌 씩! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 작업의 주요 내용인 '따라가기 컨텐츠' 및 '여행담기' 화면 구현을 명확하게 요약하고 있습니다.
Description check ✅ Passed PR 설명은 템플릿의 필수 섹션(연결된 이슈, 작업 내용, 스크린샷, 주요 코드 설명)을 포함하고 있으며 충분히 상세합니다.
Linked Issues check ✅ Passed FollowDetail RIBs, 캘린더 화면, 바텀시트 구현 등 이슈 #10의 모든 코딩 요구사항이 충족되었습니다.
Out of Scope Changes check ✅ Passed 변경사항들은 이슈 #10의 따라가기 컨텐츠 및 여행담기 UI 구현 범위 내에 있으며 불필요한 변경은 없습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Projects/Features/HomeFeature/Sources/HomeInteractor.swift (1)

125-139: 선택된 항목의 실제 id를 사용하여 라우팅하세요.

현재 두 메서드 모두 사용자 선택과 무관하게 항상 id=1로 이동하고 있습니다. PopularTrip과 Recommendation 모델 모두 id: Int 필드를 가지고 있으므로, 선택된 항목의 실제 id를 사용해야 합니다.

🐛 제안 수정
-        router?.routeToFollowDetail(with: 1)
+        router?.routeToFollowDetail(with: trips[index].id)

-        router?.routeToFollowDetail(with: 1)
+        router?.routeToFollowDetail(with: recommendations[index].id)
Projects/Features/TabBarFeature/Sources/TabBarViewController.swift (1)

99-103: UIGlassEffect API 사용 패턴 재검토 필요

UIGlassEffect는 iOS 26.0에서 실제로 사용 가능한 API입니다. 다만, 표준 사용 패턴은 UIVisualEffectView(effect: UIGlassEffect(style: .regular))이며, 코드에서 직접 isInteractivetintColor 속성을 설정하는 방식은 공식 문서에서 확인되지 않습니다. 이러한 속성들이 실제로 UIGlassEffect에서 지원하는지, 또는 사용자 정의 확장(extension)인지 확인하고, 필요시 표준 패턴으로 수정하세요.

🤖 Fix all issues with AI agents
In `@Projects/Data/Sources/Repository/Follow/FollowRepository.swift`:
- Around line 20-38: The protocol currently returns TravelDetail? and
[TravelPlace], which conflates "empty data" and error states; update the
FollowRepositoryProtocol to surface failures (either change fetchTravelDetail to
return Result<TravelDetail, Error> or make it async throws, and similarly return
Result<[TravelPlace], Error> or throws for fetchPlaces), then update the
implementing methods in FollowRepository (fetchTravelDetail and fetchPlaces) to
propagate the service errors instead of mapping all failures to nil/empty, and
adjust the caller FollowDetailInteractor to handle the new error-returning
signatures and present network errors to the user.

In `@Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift`:
- Around line 118-122: The updateBudgetForDay method can crash with a
divide-by-zero when travelDetail.days is 0; update the guard in
updateBudgetForDay to check that travelDetail exists and travelDetail.days > 0,
and if days is zero return a safe default (e.g., call presenter.updateBudget(0)
or compute with a fallback divisor) instead of performing the division;
reference symbols: updateBudgetForDay, travelDetail, detail.days, and
presenter.updateBudget.
- Around line 101-115: The loadPlaces(for:) function can apply stale async
results when the user rapidly selects days; after the await call to
repository.fetchPlaces(travelId:recommendationId, day:), verify that the
requested day still matches the UI state (e.g. compare day == currentDay) before
writing to placesByDay, calling presenter.updatePlaces(...) or
updateBudgetForDay(...); if it doesn’t match, simply return so a slower response
won’t overwrite the currently selected day’s data. Ensure the same check is
applied after any async boundary in loadPlaces(for:) and keep cached-path
behavior unchanged.

In `@Projects/Features/FollowFeature/Sources/Views/BudgetView.swift`:
- Around line 9-13: 정적 분석 경고를 해결하려면 파일 상단의 import 문들을 알파벳 또는 프로젝트 규칙에 맞게 정렬하세요:
UIKit, Core, DSKit, SnapKit, Then 등 현재 나열된 import들을 일관된 기준(예: Foundation/Apple
프레임워크 먼저: UIKit, 그 다음 내부/서드파티: Core, DSKit, SnapKit, Then 또는 오름차순 알파벳)으로 정렬하고
중복이나 사용되지 않는 import가 있는지(BudgetView.swift 내 사용 심볼 검사) 제거하세요; 수정 대상 식별자는 파일의
import 블록(예: import Core, import DSKit, import UIKit, import SnapKit, import
Then)입니다.

In `@Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift`:
- Around line 158-203: The ActionSheet created in showMonthYearPicker can crash
on iPad because alertController.preferredStyle = .actionSheet needs its
popoverPresentationController anchored; set
alertController.popoverPresentationController?.sourceView and .sourceRect (or
.barButtonItem) to the monthYearButton (or its bounds) before presenting from
parentVC so the popover has a valid anchor; keep the existing UIPickerView logic
but ensure you reference alertController.popoverPresentationController and
monthYearButton to avoid the iPad crash.

In `@Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift`:
- Around line 233-239: The travel time label currently uses a hardcoded string
in the PlaceCell view; update the logic around travelTimeContainerView,
travelTimeLabel and the isLast check so that if real travel time data is absent
the container is hidden or shows a neutral placeholder (e.g.,
empty/“—”/skeleton), and when the model provides travelTime/travelDistance
fields replace the hardcoded text with formatted values derived from those
fields; modify the code path that sets travelTimeLabel (in PlaceCell) to check
the model's travelTime/travelDistance properties and only set visible formatted
text when present, otherwise hide travelTimeContainerView or set a placeholder.

In
`@Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift`:
- Around line 72-86: applySnapshot can crash when totalDays <= 0 or selectedDay
is out of range; in applySnapshot(totalDays:selectedDay:) validate inputs up
front (guard totalDays > 0 else create empty snapshot or return), build items
using a safe range based on validated totalDays (e.g., (1...totalDays)), clamp
selectedDay into 1...totalDays before computing the IndexPath, and only call
selectItem(at: animated:scrollPosition:) if the clamped index is valid and
diffableDataSource/collection state contains that item; update references to
DayItem, applySnapshot, diffableDataSource and selectItem accordingly.

In `@Projects/Features/HomeFeature/Sources/HomeInteractor.swift`:
- Around line 163-166: followDetailDidAddTrip currently detaches the follow
detail and calls listener?.homeDidAddTrip(title:startDate:endDate:) but the
TabBarInteractor.homeDidAddTrip implementation only calls
presenter.switchToTab(at: 2) and drops the trip data; fix by wiring the trip
data into the Travel feature when switching tabs. Update
TabBarInteractor.homeDidAddTrip to forward the received title/startDate/endDate
to the Travel feature—either by calling the TravelInteractor.addTrip(_:) with a
constructed UpcomingTrip or by saving the trip into the shared state store used
by TravelInteractor before calling presenter.switchToTab(at: 2); ensure you
reference TabBarInteractor.homeDidAddTrip, presenter.switchToTab(at:), and
TravelInteractor.addTrip(_:) when making the change so the Travel tab receives
the new trip.

In `@Projects/Features/TravelFeature/Sources/TravelBuilder.swift`:
- Around line 31-32: The initializer declaration in TravelBuilder currently uses
"public override init(dependency: TravelDependency)" which violates SwiftLint's
modifier_order; change the modifier order to "override public" for the
initializer (i.e., update TravelBuilder's init signature) so override appears
before the access control modifier, keeping the same parameters and body.

In `@Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift`:
- Around line 108-123: The configure(with:) method can leave a previous
thumbnail when trip.thumbnailURL is nil/invalid; update the if-let handling
around trip.thumbnailURL / URL(string:) and add an else branch that clears any
ongoing Kingfisher download and resets the view (e.g., call
thumbnailImageView.kf.cancelDownloadTask() and set the image to nil or a default
placeholder) so previous images don’t persist when there is no valid URL.

In `@Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift`:
- Around line 173-193: The bottom-sheet height calculation uses
view.safeAreaInsets.bottom that may be zero at setup and not recalculated in
showBottomSheet()/hideBottomSheet(), causing incorrect offsets; update the same
height constraint when you update bottom offset (recompute totalHeight including
safeAreaInsets.bottom) or move the full constraint recalculation into
viewSafeAreaInsetsDidChange() so containerView's height and bottom constraints
are both updated before animating in showBottomSheet() and hideBottomSheet();
specifically, adjust the constraint logic used in
containerView.snp.updateConstraints in showBottomSheet()/hideBottomSheet()
(and/or implement viewSafeAreaInsetsDidChange to call that recalculation) so
totalHeight uses the current safeAreaInsets and you call view.layoutIfNeeded()
inside the animation block.
🧹 Nitpick comments (31)
Projects/Modules/Networks/Sources/TargetType/FollowAPI.swift (1)

56-58: GET 요청에 Content-Type 헤더가 불필요함 (선택적 개선)

GET 요청은 일반적으로 요청 본문이 없으므로 Content-Type 헤더가 필요하지 않습니다. 동작에는 문제가 없지만, 필요한 경우에만 헤더를 설정하도록 개선할 수 있습니다.

♻️ 제안된 수정
     public var headers: [String: String]? {
-        ["Content-Type": "application/json"]
+        nil
     }
Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift (3)

217-246: estimatedDuration이 0일 때 "0분 체류 예상" 표시됨

estimatedDuration이 0인 경우 else 분기에서 "0분 체류 예상"이 표시됩니다. 이 경우 체류 시간 정보를 숨기거나 다른 텍스트를 표시하는 것이 UX 측면에서 더 적절할 수 있습니다.

♻️ 제안된 수정
         let hours = place.estimatedDuration / 60
         let minutes = place.estimatedDuration % 60
         let durationText: String
-        if hours > 0 && minutes > 0 {
+        if place.estimatedDuration == 0 {
+            durationText = "체류 시간 미정"
+        } else if hours > 0 && minutes > 0 {
             durationText = "\(hours)시간 \(minutes)분 체류 예상"
         } else if hours > 0 {
             durationText = "\(hours)시간 체류 예상"

221-222: 카테고리가 하드코딩되어 있음

카테고리 라벨이 "🏔 관광명소"로 하드코딩되어 있습니다. TravelPlace 또는 PlaceInfo에 카테고리 정보가 있다면 동적으로 설정하는 것이 좋습니다.


82-88: findRouteButton에 액션 핸들러가 없음

"길찾기" 버튼이 정의되어 있지만 탭 액션이 연결되어 있지 않습니다. delegate 패턴이나 클로저를 통해 버튼 탭 이벤트를 처리할 수 있도록 구현이 필요합니다.

향후 구현 예정이라면 무시해도 됩니다. 필요하시면 delegate 패턴 구현을 도와드릴 수 있습니다.

Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift (1)

36-63: DSKit 스케일링 규칙 적용 필요
cornerRadius/spacing/아이콘 크기가 고정값(8/24)이라 디바이스 비율 스케일이 누락됩니다. DSKit 컴포넌트에서는 adjustedH/adjustedW/adjusted로 치환해 일관성을 맞춰주세요.

♻️ 제안 수정
-        layer.cornerRadius = 8
+        layer.cornerRadius = 8.adjusted

-        contentStackView.spacing = 8
+        contentStackView.spacing = 8.adjustedW
...
-        iconImageView.snp.makeConstraints {
-            $0.size.equalTo(24)
-        }
+        iconImageView.snp.makeConstraints {
+            $0.size.equalTo(24.adjusted)
+        }

Based on learnings: In the NDGL iOS design system (Projects/Modules/DSKit/Sources/Component), ensure all components use the defined scaling extensions: adjustedH scales by height ratio (screenHeight/852), and adjustedW and adjusted scale by width ratio (screenWidth/393). Spacing between components should consistently use horizontal spacing with adjustedW/adjusted and vertical spacing with adjustedH. Apply this guideline across all Swift files under DSKit Components to maintain consistent sizing and spacing; replace any hard-coded spacing with these extensions and verify visual consistency.

Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift (1)

103-103: dayLabel 텍스트 중복 설정 제거 제안
Line 103에서 text를 설정한 뒤 Line 138에서 setText로 다시 설정하고 있어 중복입니다. setText만 남겨도 동일하게 동작합니다.

♻️ 제안 수정
-        dayLabel.text = "\(day)"
Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift (1)

21-33: DSKit 컴포넌트 상수값 스케일링 적용 필요
cornerRadius/indicator/spacing 값(20/24/8/36/5 등)이 하드코딩되어 있어 디바이스 스케일이 누락됩니다. adjustedH/adjustedW/adjusted로 치환을 권장합니다(필요 시 Core import 추가).

♻️ 예시 수정
-        cornerRadius: CGFloat = 20,
+        cornerRadius: CGFloat = 20.adjusted,
...
-        let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0
+        let indicatorHeight: CGFloat = configuration.showIndicator ? 24.adjustedH : 0
...
-            $0.top.equalToSuperview().offset(8)
+            $0.top.equalToSuperview().offset(8.adjustedH)
-            $0.width.equalTo(36)
+            $0.width.equalTo(36.adjustedW)
-            $0.height.equalTo(5)
+            $0.height.equalTo(5.adjustedH)
-            $0.top.equalTo(indicatorView.snp.bottom).offset(8)
+            $0.top.equalTo(indicatorView.snp.bottom).offset(8.adjustedH)

Based on learnings: In the NDGL iOS design system (Projects/Modules/DSKit/Sources/Component), ensure all components use the defined scaling extensions: adjustedH scales by height ratio (screenHeight/852), and adjustedW and adjusted scale by width ratio (screenWidth/393). Spacing between components should consistently use horizontal spacing with adjustedW/adjusted and vertical spacing with adjustedH. Apply this guideline across all Swift files under DSKit Components to maintain consistent sizing and spacing; replace any hard-coded spacing with these extensions and verify visual consistency.

Also applies to: 135-156

Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift (1)

59-61: 탭 인덱스에 매직 넘버 대신 상수 또는 열거형 사용을 권장합니다.

현재 switchToTab(at: 2)가 Travel 탭을 올바르게 가리키지만, 매직 넘버를 사용하면 탭 순서가 변경될 때 조용히 버그가 발생할 수 있습니다.

♻️ 개선 제안
// TabBarViewController 또는 별도 파일에 정의
enum TabIndex: Int {
    case info = 0
    case home = 1
    case travel = 2
}

// 사용 시
func homeDidAddTrip(title: String, startDate: Date, endDate: Date) {
    presenter.switchToTab(at: TabIndex.travel.rawValue)
}
Projects/Features/TravelFeature/Project.swift (1)

8-10: Import 정렬 순서를 알파벳순으로 변경하세요.

SwiftLint sorted_imports 규칙에 따라 import 문을 알파벳순으로 정렬해야 합니다.

♻️ 제안된 수정
-import ProjectDescription
-import ProjectDescriptionHelpers
 import DependencyPlugin
+import ProjectDescription
+import ProjectDescriptionHelpers
Projects/Features/FollowFeature/Project.swift (1)

8-10: Import 정렬 순서를 알파벳순으로 변경하세요.

SwiftLint sorted_imports 규칙에 따라 import 문을 알파벳순으로 정렬해야 합니다.

♻️ 제안된 수정
-import ProjectDescription
-import ProjectDescriptionHelpers
 import DependencyPlugin
+import ProjectDescription
+import ProjectDescriptionHelpers
Projects/Features/FollowFeature/Sources/Views/BudgetView.swift (2)

53-54: 하드코딩된 문구는 로컬라이즈 리소스로 분리 권장
현재 문구는 고정 문자열이라 번역/문구 변경이 어렵습니다. Strings 리소스로 분리해주세요.


76-80: NumberFormatter 매 호출 생성 비용
configure가 자주 호출될 수 있어 포매터 생성 비용이 누적됩니다. NumberFormatter.localizedString 사용 또는 재사용 전략을 고려하세요.

♻️ 제안 수정안
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)"
+        let formattedNumber = NumberFormatter.localizedString(
+            from: NSNumber(value: budget),
+            number: .decimal
+        )
Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift (1)

11-16: 오류 전파가 없는 시그니처는 실패/빈 결과 구분이 어려움
nil/[]만으로는 실패와 “정상적인 빈 결과”가 구분되지 않습니다. async throws 또는 Result 반환을 고려해주세요.

♻️ 제안 시그니처
-    func fetchTravelDetail(id: Int) async -> TravelDetail?
+    func fetchTravelDetail(id: Int) async throws -> TravelDetail
...
-    func fetchPlaces(travelId: Int, day: Int) async -> [TravelPlace]
+    func fetchPlaces(travelId: Int, day: Int) async throws -> [TravelPlace]
Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift (1)

42-50: 연도 경계의 표시 모호성
start/end가 다른 연도일 때 현재 포맷은 연도 정보가 없어 혼동될 수 있습니다. 연도 포함 포맷 분기 처리도 검토해 주세요.

Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift (2)

62-73: 캘린더 높이 고정은 작은 기기/큰 글꼴에서 잘릴 수 있음
버튼과의 상하 제약으로 동적 높이를 잡거나 스크롤 컨테이너를 두는 쪽이 안전합니다.

♻️ 제약 변경 예시
-        calendarView.snp.makeConstraints {
-            $0.top.equalTo(view.safeAreaLayoutGuide).offset(16)
-            $0.leading.trailing.equalToSuperview()
-            $0.height.equalTo(350)
-        }
+        calendarView.snp.makeConstraints {
+            $0.top.equalTo(view.safeAreaLayoutGuide).offset(16)
+            $0.leading.trailing.equalToSuperview()
+            $0.bottom.equalTo(completeButton.snp.top).offset(-16)
+        }

91-99: 활성 상태 색상도 디자인 토큰으로 통일 권장
활성 색이 하드코딩(hex)이라 테마/다크모드 대응이 어렵습니다. 디자인 토큰으로 통일하면 유지보수성이 좋아집니다.

Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift (1)

69-86: 하드코딩 텍스트 로컬라이징 및 폰트 설정 중복 정리
“일차” 문구는 로컬라이즈 리소스로 분리하고, 선택/비선택 모두 동일한 폰트 설정은 setup 단계로 이동해 중복을 줄이는 편이 좋습니다.

Projects/Features/HomeFeature/Sources/HomeBuilder.swift (1)

28-31: Repository 인스턴스가 접근할 때마다 새로 생성됩니다.

현재 computed property로 구현되어 있어 followRepository에 접근할 때마다 새로운 FollowServiceFollowRepository 인스턴스가 생성됩니다. 일관된 상태 관리와 메모리 효율을 위해 lazy var로 변경하거나 인스턴스를 캐싱하는 것이 좋습니다.

♻️ 수정 제안
-    var followRepository: FollowRepositoryProtocol {
-        let service = makeFollowService()
-        return makeFollowRepository(service: service)
-    }
+    lazy var followRepository: FollowRepositoryProtocol = {
+        let service = makeFollowService()
+        return makeFollowRepository(service: service)
+    }()
Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift (1)

92-100: 지도 영역 설정 시 모든 장소가 표시되지 않을 수 있습니다.

현재 첫 번째 좌표를 중심으로 고정된 10km 반경의 영역을 설정하고 있습니다. 장소들이 넓게 분포된 경우 일부 장소가 지도에 표시되지 않을 수 있습니다. MKMapRect를 사용하여 모든 좌표를 포함하는 영역을 계산하는 것을 고려해 보세요.

♻️ 모든 장소를 포함하는 영역 설정 제안
         // 지도 영역 설정
-        if let firstCoordinate = coordinates.first {
-            let region = MKCoordinateRegion(
-                center: firstCoordinate,
-                latitudinalMeters: 10000,
-                longitudinalMeters: 10000
-            )
-            mapView.setRegion(region, animated: false)
-        }
+        let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
+        let rect = polyline.boundingMapRect
+        let insets = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
+        mapView.setVisibleMapRect(rect, edgePadding: insets, animated: false)
Projects/Features/TravelFeature/Sources/TravelInteractor.swift (1)

58-60: 불필요한 오버라이드 제거 권장
willResignActive()super만 호출하므로 제거해도 동작 동일합니다. 코드 노이즈와 경고를 줄일 수 있어요.

♻️ 제안 변경
-    override func willResignActive() {
-        super.willResignActive()
-    }
Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarInteractor.swift (1)

45-51: 불필요한 라이프사이클 오버라이드 제거 권장
didBecomeActive/willResignActivesuper만 호출하므로 삭제해도 동작 동일합니다.

♻️ 제안 변경
-    override func didBecomeActive() {
-        super.didBecomeActive()
-    }
-
-    override func willResignActive() {
-        super.willResignActive()
-    }
Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift (2)

52-63: 데이터 소스 클로저에서 self?.places 사용 시 데이터 불일치 가능성

diffableDataSource 클로저 내에서 self?.places를 참조하여 isLast를 계산하고 있습니다. 하지만 diffable data source는 자체적으로 스냅샷의 아이템을 관리하므로, self.places 배열과 스냅샷 데이터가 일시적으로 불일치할 수 있습니다.

스냅샷의 아이템 수를 직접 사용하는 것이 더 안전합니다:

♻️ 개선 제안
 diffableDataSource = UICollectionViewDiffableDataSource<Int, TravelPlace>(
     collectionView: self
-) { [weak self] collectionView, indexPath, place in
+) { collectionView, indexPath, place in
     guard let cell = collectionView.dequeueReusableCell(
         withReuseIdentifier: PlaceCell.identifier,
         for: indexPath
     ) as? PlaceCell else {
         return UICollectionViewCell()
     }

-    let isLast = indexPath.item == (self?.places.count ?? 0) - 1
+    let itemCount = collectionView.numberOfItems(inSection: indexPath.section)
+    let isLast = indexPath.item == itemCount - 1
     cell.configure(with: place, isLast: isLast)
     return cell
 }

92-94: SwiftLint: 암시적 반환 사용 권장

단일 표현식 함수에서는 암시적 반환을 사용하는 것이 Swift 스타일 가이드에 부합합니다.

♻️ 개선 제안
 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
-    return CGSize(width: collectionView.bounds.width, height: 135)
+    CGSize(width: collectionView.bounds.width, height: 135)
 }
Projects/Features/TravelFeature/Sources/TravelViewController.swift (2)

142-146: 빈 상태일 때 컬렉션 뷰 숨김 처리 고려

trips가 비어있을 때 emptyStateLabel만 표시하고 collectionView는 그대로 남아있습니다. 현재 구현에서는 동작에 문제가 없지만, 빈 컬렉션 뷰를 숨기면 접근성 측면에서 더 좋을 수 있습니다.

♻️ 개선 제안
 func updateTrips(_ trips: [UpcomingTrip]) {
     self.trips = trips
     emptyStateLabel.isHidden = !trips.isEmpty
+    collectionView.isHidden = trips.isEmpty
     collectionView.reloadData()
 }

153-155: SwiftLint: 암시적 반환 사용 권장

♻️ 개선 제안
 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
-    return trips.count
+    trips.count
 }
Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift (1)

36-38: SwiftLint: 수정자 순서 수정 필요

Swift 스타일 가이드에 따르면 override 수정자는 public 앞에 와야 합니다.

♻️ 개선 제안
-public override init(dependency: FollowDetailDependency) {
+override public init(dependency: FollowDetailDependency) {
     super.init(dependency: dependency)
 }
Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift (1)

313-318: 중복 코드: formatBudget 메서드가 BudgetView.swift와 동일

BudgetView.swift(라인 75-80)에 동일한 예산 포맷팅 로직이 존재합니다. 중복을 피하기 위해 공통 유틸리티로 추출하는 것을 고려해 주세요.

♻️ 공통 유틸리티 추출 제안

예를 들어, Core 또는 DSKit 모듈에 확장 메서드를 추가할 수 있습니다:

// NumberFormatter+Budget.swift
extension NumberFormatter {
    static func formatBudget(_ budget: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)"
        return "\(formattedNumber)"
    }
}
Projects/Features/TabBarFeature/Sources/TabBarViewController.swift (3)

41-46: SwiftLint: 수정자 순서 수정 필요

override 수정자는 public 앞에 와야 합니다.

♻️ 개선 제안
-public override func viewDidLoad() {
+override public func viewDidLoad() {
     super.viewDidLoad()
     setupStyle()
     setupUI()
     setupConstraints()
 }

48-51: SwiftLint: 수정자 순서 수정 필요

♻️ 개선 제안
-public override func viewWillLayoutSubviews() {
+override public func viewWillLayoutSubviews() {
     super.viewWillLayoutSubviews()
     tabBar.isHidden = true
 }

55-73: setViewControllers에서 뷰 컨트롤러 개수 부족 시 조용히 반환

뷰 컨트롤러가 2개 미만일 때 guard가 조용히 반환됩니다. 이는 디버깅을 어렵게 만들 수 있으므로, 최소한 assertion이나 로그를 추가하는 것이 좋습니다.

♻️ 개선 제안
 public func setViewControllers(_ viewControllers: [ViewControllable]) {
-    guard viewControllers.count >= 2 else {
-        return
-    }
+    guard viewControllers.count >= 2 else {
+        assertionFailure("TabBarViewController requires at least 2 view controllers")
+        return
+    }
Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift (1)

267-273: 메서드 이름이 실제 동작과 불일치

presentdismiss는 일반적으로 모달 프레젠테이션을 의미하지만, 실제로는 navigation push/pop을 수행합니다. 가독성과 유지보수성을 위해 메서드 이름 변경을 고려해보세요.

♻️ 메서드 이름 변경 제안
-func present(_ viewController: ViewControllable) {
+func push(_ viewController: ViewControllable) {
     navigationController?.pushViewController(viewController.uiviewController, animated: true)
 }

-func dismiss(_ viewController: ViewControllable) {
+func pop(_ viewController: ViewControllable) {
     navigationController?.popViewController(animated: true)
 }

프로토콜 FollowDetailViewControllable도 함께 수정이 필요합니다 (FollowDetailRouter.swift 라인 21-22).

Comment on lines +20 to +38
public func fetchTravelDetail(id: Int) async -> TravelDetail? {
let result = await service.getContentCard(id: id)

switch result {
case .success(let response):
return response.toDomain()
case .failure, .networkFailure:
return nil
}
}

public func fetchPlaces(travelId: Int, day: Int) async -> [TravelPlace] {
let result = await service.getItinerary(id: travelId, day: day)

switch result {
case .success(let response):
return response.toDomain()
case .failure, .networkFailure:
return []
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for FollowRepositoryProtocol definition
rg -n "protocol FollowRepositoryProtocol" -C5 --type swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1183


🏁 Script executed:

# Search for callers of fetchTravelDetail and fetchPlaces
rg -n "fetchTravelDetail|fetchPlaces" -C3 --type swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 5172


🏁 Script executed:

# Look at the complete FollowRepository file to understand context
fd "FollowRepository.swift" --type f -x head -60 {}

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1119


🏁 Script executed:

# Check for other repository patterns in the codebase
rg -n "public func fetch.*async.*->" --type swift -C2 | head -100

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1029


프로토콜 정의 자체가 오류 상태를 구분하지 못하도록 설계되어 있습니다.

FollowRepositoryProtocolTravelDetail?[TravelPlace]를 반환하도록 정의되어 있어서, 구현체에서 네트워크 실패와 실제 빈 데이터를 모두 nil/빈 배열로 처리할 수밖에 없습니다. 호출부(FollowDetailInteractor)에서도 이를 구분할 수 없어서, fetchTravelDetail 실패 시 에러 메시지 없이 조용히 돌아가고, fetchPlaces 실패도 빈 배열처럼 표시됩니다.

프로토콜을 Result<TravelDetail, Error>throws로 변경하거나, 별도의 오류 상태 콜백을 추가해야 사용자에게 네트워크 오류를 알릴 수 있습니다.

🤖 Prompt for AI Agents
In `@Projects/Data/Sources/Repository/Follow/FollowRepository.swift` around lines
20 - 38, The protocol currently returns TravelDetail? and [TravelPlace], which
conflates "empty data" and error states; update the FollowRepositoryProtocol to
surface failures (either change fetchTravelDetail to return Result<TravelDetail,
Error> or make it async throws, and similarly return Result<[TravelPlace],
Error> or throws for fetchPlaces), then update the implementing methods in
FollowRepository (fetchTravelDetail and fetchPlaces) to propagate the service
errors instead of mapping all failures to nil/empty, and adjust the caller
FollowDetailInteractor to handle the new error-returning signatures and present
network errors to the user.

Comment on lines +101 to +115
@MainActor
private func loadPlaces(for day: Int) async {
// 캐시된 데이터가 있으면 사용
if let cachedPlaces = placesByDay[day] {
presenter.updatePlaces(cachedPlaces)
updateBudgetForDay(day)
return
}

let places = await repository.fetchPlaces(travelId: recommendationId, day: day)
placesByDay[day] = places

presenter.updatePlaces(places)
updateBudgetForDay(day)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

일차 선택 연타 시 이전 요청이 최신 화면을 덮어쓸 수 있음
didSelectDay에서 비동기 요청이 겹치면 느린 응답이 현재 선택과 다른 날짜의 장소/예산을 표시할 수 있습니다. 결과 적용 전에 day == currentDay 확인이 필요합니다.

🐛 제안 변경
         if let cachedPlaces = placesByDay[day] {
-            presenter.updatePlaces(cachedPlaces)
-            updateBudgetForDay(day)
+            guard day == currentDay else { return }
+            presenter.updatePlaces(cachedPlaces)
+            updateBudgetForDay(day)
             return
         }

         let places = await repository.fetchPlaces(travelId: recommendationId, day: day)
         placesByDay[day] = places

-        presenter.updatePlaces(places)
-        updateBudgetForDay(day)
+        guard day == currentDay else { return }
+        presenter.updatePlaces(places)
+        updateBudgetForDay(day)
🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift` around
lines 101 - 115, The loadPlaces(for:) function can apply stale async results
when the user rapidly selects days; after the await call to
repository.fetchPlaces(travelId:recommendationId, day:), verify that the
requested day still matches the UI state (e.g. compare day == currentDay) before
writing to placesByDay, calling presenter.updatePlaces(...) or
updateBudgetForDay(...); if it doesn’t match, simply return so a slower response
won’t overwrite the currently selected day’s data. Ensure the same check is
applied after any async boundary in loadPlaces(for:) and keep cached-path
behavior unchanged.

Comment on lines +118 to +122
private func updateBudgetForDay(_ day: Int) {
// 일차별 예산 계산 (전체 예산을 일수로 나눔 - Mock)
guard let detail = travelDetail else { return }
let dailyBudget = detail.budgetPerPerson / detail.days
presenter.updateBudget(dailyBudget)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

일수 0일 때 분모 0으로 크래시 가능
detail.days가 0이면 나눗셈에서 런타임 크래시가 발생합니다. 방어 로직이 필요합니다.

🐛 제안 변경
         guard let detail = travelDetail else { return }
-        let dailyBudget = detail.budgetPerPerson / detail.days
+        guard detail.days > 0 else {
+            presenter.updateBudget(0)
+            return
+        }
+        let dailyBudget = detail.budgetPerPerson / detail.days
         presenter.updateBudget(dailyBudget)
🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift` around
lines 118 - 122, The updateBudgetForDay method can crash with a divide-by-zero
when travelDetail.days is 0; update the guard in updateBudgetForDay to check
that travelDetail exists and travelDetail.days > 0, and if days is zero return a
safe default (e.g., call presenter.updateBudget(0) or compute with a fallback
divisor) instead of performing the division; reference symbols:
updateBudgetForDay, travelDetail, detail.days, and presenter.updateBudget.

Comment on lines +9 to +13
import Core
import DSKit
import UIKit
import SnapKit
import Then
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

import 정렬 경고 수정 필요
정적 분석에서 import 정렬 경고가 있으니 순서를 정리해주세요.

🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 12-12: Imports should be sorted

(sorted_imports)

🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/Views/BudgetView.swift` around lines
9 - 13, 정적 분석 경고를 해결하려면 파일 상단의 import 문들을 알파벳 또는 프로젝트 규칙에 맞게 정렬하세요: UIKit, Core,
DSKit, SnapKit, Then 등 현재 나열된 import들을 일관된 기준(예: Foundation/Apple 프레임워크 먼저:
UIKit, 그 다음 내부/서드파티: Core, DSKit, SnapKit, Then 또는 오름차순 알파벳)으로 정렬하고 중복이나 사용되지 않는
import가 있는지(BudgetView.swift 내 사용 심볼 검사) 제거하세요; 수정 대상 식별자는 파일의 import 블록(예:
import Core, import DSKit, import UIKit, import SnapKit, import Then)입니다.

Comment on lines +158 to +203
private func showMonthYearPicker() {
guard let parentVC = findViewController() else { return }

let alertController = UIAlertController(title: "년월 선택\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)

let pickerView = UIPickerView()
pickerView.delegate = self
pickerView.dataSource = self

// 현재 선택된 년월로 초기화
let currentYear = calendar.component(.year, from: currentDate)
let currentMonth = calendar.component(.month, from: currentDate)
let minYear = calendar.component(.year, from: Date())

pickerView.selectRow(currentYear - minYear, inComponent: 0, animated: false)
pickerView.selectRow(currentMonth - 1, inComponent: 1, animated: false)

alertController.view.addSubview(pickerView)
pickerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
pickerView.centerXAnchor.constraint(equalTo: alertController.view.centerXAnchor),
pickerView.topAnchor.constraint(equalTo: alertController.view.topAnchor, constant: 30),
pickerView.widthAnchor.constraint(equalToConstant: 250),
pickerView.heightAnchor.constraint(equalToConstant: 150)
])

let selectAction = UIAlertAction(title: "선택", style: .default) { [weak self] _ in
guard let self = self else { return }
let selectedYear = minYear + pickerView.selectedRow(inComponent: 0)
let selectedMonth = pickerView.selectedRow(inComponent: 1) + 1

var components = DateComponents()
components.year = selectedYear
components.month = selectedMonth
components.day = 1

if let date = self.calendar.date(from: components) {
self.currentDate = date
self.updateCalendar()
}
}
alertController.addAction(selectAction)
alertController.addAction(UIAlertAction(title: "취소", style: .cancel))

parentVC.present(alertController, animated: true)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

iPad에서 ActionSheet 크래시 가능
.actionSheet는 iPad에서 popoverPresentationController의 source 설정이 없으면 크래시가 발생합니다. monthYearButton을 앵커로 지정해 주세요.

🐛 제안 변경
         let alertController = UIAlertController(title: "년월 선택\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)

+        if let popover = alertController.popoverPresentationController {
+            popover.sourceView = monthYearButton
+            popover.sourceRect = monthYearButton.bounds
+            popover.permittedArrowDirections = .up
+        }
🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift`
around lines 158 - 203, The ActionSheet created in showMonthYearPicker can crash
on iPad because alertController.preferredStyle = .actionSheet needs its
popoverPresentationController anchored; set
alertController.popoverPresentationController?.sourceView and .sourceRect (or
.barButtonItem) to the monthYearButton (or its bounds) before presenting from
parentVC so the popover has a valid anchor; keep the existing UIPickerView logic
but ensure you reference alertController.popoverPresentationController and
monthYearButton to avoid the iPad crash.

Comment on lines +72 to +86
func applySnapshot(totalDays: Int, selectedDay: Int = 1) {
self.totalDays = totalDays

var snapshot = NSDiffableDataSourceSnapshot<Int, DayItem>()
snapshot.appendSections([0])

let items = (1...totalDays).map { DayItem(day: $0) }
snapshot.appendItems(items, toSection: 0)

diffableDataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in
// 선택 상태 설정
let indexPath = IndexPath(item: selectedDay - 1, section: 0)
self?.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

totalDays가 0 이하일 때 런타임 크래시 가능성

totalDays가 0 이하인 경우 1...totalDays 범위가 유효하지 않아 크래시가 발생합니다. 또한 selectedDay가 유효 범위를 벗어날 경우에도 잘못된 인덱스가 생성될 수 있습니다.

🐛 경계 조건 처리 제안
 func applySnapshot(totalDays: Int, selectedDay: Int = 1) {
+    guard totalDays > 0 else { return }
     self.totalDays = totalDays

     var snapshot = NSDiffableDataSourceSnapshot<Int, DayItem>()
     snapshot.appendSections([0])

     let items = (1...totalDays).map { DayItem(day: $0) }
     snapshot.appendItems(items, toSection: 0)

+    let validSelectedDay = min(max(selectedDay, 1), totalDays)
     diffableDataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in
-        let indexPath = IndexPath(item: selectedDay - 1, section: 0)
+        let indexPath = IndexPath(item: validSelectedDay - 1, section: 0)
         self?.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func applySnapshot(totalDays: Int, selectedDay: Int = 1) {
self.totalDays = totalDays
var snapshot = NSDiffableDataSourceSnapshot<Int, DayItem>()
snapshot.appendSections([0])
let items = (1...totalDays).map { DayItem(day: $0) }
snapshot.appendItems(items, toSection: 0)
diffableDataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in
// 선택 상태 설정
let indexPath = IndexPath(item: selectedDay - 1, section: 0)
self?.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
}
}
func applySnapshot(totalDays: Int, selectedDay: Int = 1) {
guard totalDays > 0 else { return }
self.totalDays = totalDays
var snapshot = NSDiffableDataSourceSnapshot<Int, DayItem>()
snapshot.appendSections([0])
let items = (1...totalDays).map { DayItem(day: $0) }
snapshot.appendItems(items, toSection: 0)
let validSelectedDay = min(max(selectedDay, 1), totalDays)
diffableDataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in
// 선택 상태 설정
let indexPath = IndexPath(item: validSelectedDay - 1, section: 0)
self?.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
}
}
🤖 Prompt for AI Agents
In
`@Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift`
around lines 72 - 86, applySnapshot can crash when totalDays <= 0 or selectedDay
is out of range; in applySnapshot(totalDays:selectedDay:) validate inputs up
front (guard totalDays > 0 else create empty snapshot or return), build items
using a safe range based on validated totalDays (e.g., (1...totalDays)), clamp
selectedDay into 1...totalDays before computing the IndexPath, and only call
selectItem(at: animated:scrollPosition:) if the clamped index is valid and
diffableDataSource/collection state contains that item; update references to
DayItem, applySnapshot, diffableDataSource and selectItem accordingly.

Comment on lines +163 to +166
func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) {
router?.detachFollowDetail()
// TabBar에 알려서 Travel 탭으로 이동
listener?.homeDidAddTrip(title: title, startDate: startDate, endDate: endDate)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "homeDidAddTrip" -A 5 -B 2

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1803


🏁 Script executed:

rg -n "TravelInteractor\|TravelListener\|TravelPresenter" -B 2 -A 5 | head -100

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 57


🏁 Script executed:

fd -t f "*Travel*" | grep -i "interactor\|listener" | head -20

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 362


🏁 Script executed:

fd --glob "*Travel*" -t f | grep -E "(Interactor|Listener|Presenter)" | head -20

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 137


🏁 Script executed:

rg "switchToTab" -B 5 -A 5

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 2900


🏁 Script executed:

cat Projects/Features/TravelFeature/Sources/TravelInteractor.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 3090


🏁 Script executed:

rg "Trip\|addTrip" -B 2 -A 3 | head -60

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 57


Travel 탭으로 추가된 여행 데이터가 전달되지 않는 버그입니다.

TabBarInteractor.homeDidAddTrip()은 여행 데이터(title, startDate, endDate)를 수신하지만 presenter.switchToTab(at: 2) 호출만 수행하고 매개변수를 전혀 사용하지 않습니다. 반면 TravelInteractor에는 addTrip(_ trip: UpcomingTrip) 메서드가 존재하여 여행을 추가할 수 있습니다.

homeDidAddTrip()에서 수신한 데이터로 TravelInteractoraddTrip() 메서드를 호출하거나, 공유 상태 저장소를 통해 데이터를 전달하는 메커니즘을 구현해야 합니다.

🤖 Prompt for AI Agents
In `@Projects/Features/HomeFeature/Sources/HomeInteractor.swift` around lines 163
- 166, followDetailDidAddTrip currently detaches the follow detail and calls
listener?.homeDidAddTrip(title:startDate:endDate:) but the
TabBarInteractor.homeDidAddTrip implementation only calls
presenter.switchToTab(at: 2) and drops the trip data; fix by wiring the trip
data into the Travel feature when switching tabs. Update
TabBarInteractor.homeDidAddTrip to forward the received title/startDate/endDate
to the Travel feature—either by calling the TravelInteractor.addTrip(_:) with a
constructed UpcomingTrip or by saving the trip into the shared state store used
by TravelInteractor before calling presenter.switchToTab(at: 2); ensure you
reference TabBarInteractor.homeDidAddTrip, presenter.switchToTab(at:), and
TravelInteractor.addTrip(_:) when making the change so the Travel tab receives
the new trip.

Comment on lines +31 to +32
public override init(dependency: TravelDependency) {
super.init(dependency: dependency)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# 대상 파일 확인
cat -n Projects/Features/TravelFeature/Sources/TravelBuilder.swift | sed -n '25,40p'

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 707


🏁 Script executed:

# SwiftLint 설정 파일 확인
fd -e yml -e yaml | head -20

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 57


🏁 Script executed:

# modifier_order 규칙 설정 찾기
find . -name ".swiftlint*" -o -name "swiftlint*" | grep -E "\.(yml|yaml)$"

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 91


🏁 Script executed:

# SwiftLint 설정 읽기
cat .swiftlint.yml

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1222


🏁 Script executed:

# 다른 파일에서 public override 또는 override public 패턴 찾기
rg -n "public override|override public" --type swift -C1

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 2665


🌐 Web query:

SwiftLint modifier_order rule correct order public override

💡 Result:

With SwiftLint’s modifier_order rule (default preferred_modifier_order), override must come before the access-control modifier (public, internal, etc.). (realm.github.io)

So the correct order is:

override public func foo() { }

—not:

public override func foo() { }

Citations:


SwiftLint modifier_order 규칙을 수정하세요.
public override 대신 override public 순서로 변경해야 합니다. SwiftLint modifier_order 규칙에 따르면 override는 접근 제어 수정자(public, internal 등)보다 앞에 위치해야 합니다.

🔧 제안 수정
-    public override init(dependency: TravelDependency) {
+    override public init(dependency: TravelDependency) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public override init(dependency: TravelDependency) {
super.init(dependency: dependency)
override public init(dependency: TravelDependency) {
super.init(dependency: dependency)
🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 31-31: override modifier should come before public

(modifier_order)

🤖 Prompt for AI Agents
In `@Projects/Features/TravelFeature/Sources/TravelBuilder.swift` around lines 31
- 32, The initializer declaration in TravelBuilder currently uses "public
override init(dependency: TravelDependency)" which violates SwiftLint's
modifier_order; change the modifier order to "override public" for the
initializer (i.e., update TravelBuilder's init signature) so override appears
before the access control modifier, keeping the same parameters and body.

Comment on lines +108 to +123
func configure(with trip: UpcomingTrip) {
// D-day
let dDay = trip.dDay
let dDayText = dDay > 0 ? "D-\(dDay)" : (dDay == 0 ? "D-Day" : "D+\(abs(dDay))")
dDayLabel.setText(.bodySSB, text: dDayText, color: .white)

titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor.NDGL.Text.primary)

dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor.NDGL.Text.tertiary)

if let urlString = trip.thumbnailURL, let url = URL(string: urlString) {
thumbnailImageView.kf.setImage(
with: url,
options: [.transition(.fade(0.2)), .cacheOriginalImage]
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

썸네일 URL 없을 때 이전 이미지가 남을 수 있음
URL이 nil/invalid인 경우 이미지 초기화가 없어 이전 데이터가 남을 수 있습니다. else 처리로 초기화해 주세요.

🐛 제안 변경
-        if let urlString = trip.thumbnailURL, let url = URL(string: urlString) {
-            thumbnailImageView.kf.setImage(
-                with: url,
-                options: [.transition(.fade(0.2)), .cacheOriginalImage]
-            )
-        }
+        if let urlString = trip.thumbnailURL, let url = URL(string: urlString) {
+            thumbnailImageView.kf.setImage(
+                with: url,
+                options: [.transition(.fade(0.2)), .cacheOriginalImage]
+            )
+        } else {
+            thumbnailImageView.kf.cancelDownloadTask()
+            thumbnailImageView.image = nil
+            thumbnailImageView.backgroundColor = .systemGray5
+        }
🤖 Prompt for AI Agents
In `@Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift` around
lines 108 - 123, The configure(with:) method can leave a previous thumbnail when
trip.thumbnailURL is nil/invalid; update the if-let handling around
trip.thumbnailURL / URL(string:) and add an else branch that clears any ongoing
Kingfisher download and resets the view (e.g., call
thumbnailImageView.kf.cancelDownloadTask() and set the image to nil or a default
placeholder) so previous images don’t persist when there is no valid URL.

Comment on lines +173 to +193
private func showBottomSheet() {
let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0
let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom

containerView.snp.updateConstraints {
$0.bottom.equalToSuperview().offset(0)
}

UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in
self?.dimView.alpha = 1
self?.view.layoutIfNeeded()
}
}

private func hideBottomSheet(completion: (() -> Void)? = nil) {
let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0
let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom

containerView.snp.updateConstraints {
$0.bottom.equalToSuperview().offset(totalHeight)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "BottomSheetViewController.swift" -t f

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 147


🏁 Script executed:

cat -n "Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift" | head -250

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 9353


🏁 Script executed:

cat -n "Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift" | tail -150

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 5834


setupConstraints에서 계산한 높이가 보여질 때 달라질 수 있음
safeAreaInsets.bottom은 viewDidLoad에서 0일 수 있는데(특히 홈 인디케이터 기기), 이 값으로 높이가 한 번 설정된 후 showBottomSheet/hideBottomSheet에서는 bottom offset만 업데이트되고 높이는 갱신되지 않습니다. viewDidAppear에서 실제 safeAreaInsets.bottom이 반영되면 높이 계산이 맞지 않을 수 있습니다.

show/hide 시점에 높이도 함께 업데이트하거나, viewSafeAreaInsetsDidChange에서 전체를 재계산하세요.

🐛 제안 수정
     private func showBottomSheet() {
         let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0
         let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom
 
         containerView.snp.updateConstraints {
             $0.bottom.equalToSuperview().offset(0)
+            $0.height.equalTo(totalHeight)
         }
...
     private func hideBottomSheet(completion: (() -> Void)? = nil) {
         let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0
         let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom
 
         containerView.snp.updateConstraints {
             $0.bottom.equalToSuperview().offset(totalHeight)
+            $0.height.equalTo(totalHeight)
         }
🤖 Prompt for AI Agents
In `@Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift`
around lines 173 - 193, The bottom-sheet height calculation uses
view.safeAreaInsets.bottom that may be zero at setup and not recalculated in
showBottomSheet()/hideBottomSheet(), causing incorrect offsets; update the same
height constraint when you update bottom offset (recompute totalHeight including
safeAreaInsets.bottom) or move the full constraint recalculation into
viewSafeAreaInsetsDidChange() so containerView's height and bottom constraints
are both updated before animating in showBottomSheet() and hideBottomSheet();
specifically, adjust the constraint logic used in
containerView.snp.updateConstraints in showBottomSheet()/hideBottomSheet()
(and/or implement viewSafeAreaInsetsDidChange to call that recalculation) so
totalHeight uses the current safeAreaInsets and you call view.layoutIfNeeded()
inside the animation block.

Copy link
Copy Markdown
Contributor

@ChoiAnYong ChoiAnYong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task 전체 MainActor로 감싼 부분 수정해주시면 될 거 같아요
고생하셨습니다!

Comment on lines +112 to +122
textColor = UIColor.NDGL.Text.disabled
} else if isPastDate {
if isSunday {
textColor = DSKitAsset.Colors.red300.color
} else {
textColor = UIColor.NDGL.Text.disabled
}
} else if isSunday {
textColor = DSKitAsset.Colors.red500.color
} else {
textColor = UIColor.NDGL.Text.primary
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컬러 부분 머지 전에 UIColor(hexCode: "")로 변경해주실 수 있나요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UIColor.NDGL 쓰는 모든 부분에 다 수정하면 될까요 ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그게 좋을 거 같아요 지금 상태로 머지하면 컴파일 오류 엄청 뜰거라
머지된 후에 각자 뷰에서 색상 다시 설정하는게 좋을 거 같아요

Comment on lines +82 to +99
private func loadTravelDetail() {
Task { @MainActor in
presenter.showLoading()

// 여행 상세 정보 로드
guard let detail = await repository.fetchTravelDetail(id: recommendationId) else {
presenter.hideLoading()
return
}

self.travelDetail = detail
presenter.updateTravelDetail(detail)

// 1일차 장소 로드
await loadPlaces(for: 1)
presenter.hideLoading()
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

밑 부분도 동일하지만 API 통신하는 부분인데 Task 블록 전체가 @mainactor로 되어 있으면 안 될 거 같습니다.
Task 자체를 MainActor로 묶기보다 API통신은 비동기로 처리하고 UI 업데이트만 메인 스레드로 보내는 방식이 좋을 거 같아요

Suggested change
private func loadTravelDetail() {
Task { @MainActor in
presenter.showLoading()
// 여행 상세 정보 로드
guard let detail = await repository.fetchTravelDetail(id: recommendationId) else {
presenter.hideLoading()
return
}
self.travelDetail = detail
presenter.updateTravelDetail(detail)
// 1일차 장소 로드
await loadPlaces(for: 1)
presenter.hideLoading()
}
}
private func loadTravelDetail() {
Task {
await MainActor.run { presenter.showLoading() }
// 여행 상세 정보 로드
guard let detail = await repository.fetchTravelDetail(id: recommendationId) else {
await MainActor.run { presenter.hideLoading() }
return
}
await MainActor.run {
self.travelDetail = detail
presenter.updateTravelDetail(detail)
}
// 1일차 장소 로드
await loadPlaces(for: 1)
await MainActor.run { presenter.hideLoading() }
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 이부분은 제가 놓쳤네요.. 수정하겠습니다!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift`:
- Around line 199-204: Validate the incoming day in syncDaySelection(day:)
before using it: ensure day is within 1...totalDays (or clamp/early-return if
out of range) so currentSelectedDay, IndexPath(item: day - 1, section: 0), and
the calls to dayCollectionView.selectItem(...) and
stickyDayCollectionView.selectItem(...) cannot be invoked with an invalid index;
update the function (referencing syncDaySelection(day:), currentSelectedDay,
dayCollectionView, stickyDayCollectionView, and totalDays) to perform this guard
and only proceed when the computed indexPath is valid.

In
`@Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift`:
- Around line 44-48: The viewDidDisappear implementation only checks
isMovingFromParent, so didTapBackButton() is not called for iOS13+ modal swipe
dismissals; update TripCalendarViewController.viewDidDisappear(_:) to call
listener?.didTapBackButton() when either isMovingFromParent or isBeingDismissed
is true (i.e., check both isMovingFromParent || isBeingDismissed before invoking
listener?.didTapBackButton()) so the child RIB is cleaned up on modal swipe
dismiss as well.

In
`@Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift`:
- Around line 53-58: prepareForReuse currently hides views and clears the label
but doesn't restore rangeBackgroundView constraints, so previous half-width
constraints from configureRangeBackground can persist; update prepareForReuse
(or add a helper called resetRangeBackgroundConstraints) to reinstate the
default full-width constraint on rangeBackgroundView (remove/disable any
half-width constraint set by configureRangeBackground and enable/set the
full-width constraint), ensuring rangeBackgroundView's layout returns to its
original state before reuse.

In `@Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift`:
- Around line 286-310: Before starting new Kingfisher downloads, cancel any
previous tasks to avoid overlapping downloads: in MediaInfoView.swift update the
profile and thumbnail loading blocks to call
profileImageView.kf.cancelDownloadTask() immediately before
profileImageView.kf.setImage(...) and thumbnailImageView.kf.cancelDownloadTask()
immediately before thumbnailImageView.kf.setImage(...); preserve the existing
URL guards (profileURLString/profileURL and thumbnailURLString/thumbnailURL) and
the same setImage options (.transition, .cacheOriginalImage).

In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift`:
- Around line 41-51: The SwiftLint warning is due to modifier order: move the
override modifier before access control for the methods viewDidLoad() and
viewWillLayoutSubviews(); change declarations from "public override func
viewDidLoad()" and "public override func viewWillLayoutSubviews()" to "override
public func viewDidLoad()" and "override public func viewWillLayoutSubviews()"
(or simply "override func ..." if public is redundant) so the modifiers follow
the recommended order.
- Around line 185-207: The updateSelection(at:animated:) method directly
accesses tabItems[index] which can crash if index is out of bounds; add a
defensive guard at the start of updateSelection(at:animated:) to ensure index is
>= 0 and < tabItems.count and early-return if not, and use the safely-guarded
targetItem afterward (reference: updateSelection(at:animated:), selectedIndex,
tabItems, indicatorView). Also update any callers that forward sender.tag to
call updateSelection only when the tag is within valid range.

In `@Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift`:
- Around line 85-89: The declaration of the isHighlighted computed property uses
the wrong modifier order; change "public override var isHighlighted" to place
the override modifier before the access level (i.e. "override public var
isHighlighted") in BottomPlacedButton.swift so it satisfies the SwiftLint
modifier_order rule; update the property declaration with the same didSet body
(alpha = isHighlighted ? 0.7 : 1.0) and run SwiftLint to confirm.
🧹 Nitpick comments (16)
Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift (3)

25-28: 버튼 인터랙션 처리 누락

chevronButtonfindRouteButton이 정의되어 있지만, 탭 이벤트를 외부로 전달할 수 있는 delegate나 closure가 없습니다. 현재 상태에서는 버튼이 시각적으로만 존재하고 실제로 동작하지 않습니다.

♻️ 버튼 액션 처리를 위한 제안
 final class PlaceDetailBottomSheetView: UIView {
+
+    // MARK: - Actions
+    var onChevronTapped: (() -> Void)?
+    var onFindRouteTapped: (() -> Void)?
 
     // MARK: - UI Components

setupUI()에서 액션 연결:

chevronButton.addTarget(self, action: `#selector`(chevronTapped), for: .touchUpInside)
findRouteButton.addTarget(self, action: `#selector`(findRouteTapped), for: .touchUpInside)

`@objc` private func chevronTapped() {
    onChevronTapped?()
}

`@objc` private func findRouteTapped() {
    onFindRouteTapped?()
}

Also applies to: 83-88


221-222: 카테고리가 하드코딩되어 있음

place 객체에 실제 카테고리 정보가 있을 수 있지만, 항상 "🏔 관광명소"로 고정 표시됩니다. TravelPlace나 PlaceInfo에서 카테고리 데이터를 가져와 동적으로 표시하는 것이 적절합니다.


225-234: estimatedDuration이 0일 때 엣지 케이스 처리

estimatedDuration이 0인 경우 "0분 체류 예상"이 표시됩니다. 데이터가 없거나 0인 경우에 대한 별도 처리(예: 라벨 숨김 또는 "-" 표시)를 고려해 보세요.

♻️ 0 duration 처리 예시
         // 체류 예상 시간
         let hours = place.estimatedDuration / 60
         let minutes = place.estimatedDuration % 60
         let durationText: String
-        if hours > 0 && minutes > 0 {
+        if place.estimatedDuration == 0 {
+            durationText = "-"
+        } else if hours > 0 && minutes > 0 {
             durationText = "\(hours)시간 \(minutes)분 체류 예상"
         } else if hours > 0 {
             durationText = "\(hours)시간 체류 예상"
         } else {
             durationText = "\(minutes)분 체류 예상"
         }
Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift (1)

108-124: 빠른 스크롤 시 이미지 교차 표시 가능성

YoutuberContentCell에서는 currentThumbnailURL을 저장하고 Kingfisher 완료 콜백에서 URL을 검증하여 느린 요청이 빠른 요청을 덮어쓰지 않도록 합니다. 현재 구현에서는 이 패턴이 없어 빠른 스크롤 시 이전 셀의 이미지가 잘못 표시될 수 있습니다.

♻️ URL 검증 패턴 적용 제안
 final class UpcomingTripCell: UICollectionViewCell {
 
     static let identifier = "UpcomingTripCell"
+    
+    private var currentThumbnailURL: String?
 
     // MARK: - UI Components
     override func prepareForReuse() {
         super.prepareForReuse()
+        currentThumbnailURL = nil
         thumbnailImageView.kf.cancelDownloadTask()
         thumbnailImageView.image = nil
         thumbnailImageView.backgroundColor = .systemGray5
     }
     func configure(with trip: UpcomingTrip) {
         // D-day
         let dDay = trip.dDay
         let dDayText = dDay > 0 ? "D-\(dDay)" : (dDay == 0 ? "D-Day" : "D+\(abs(dDay))")
         dDayLabel.setText(.bodySSB, text: dDayText, color: .white)
 
         titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor(hexCode: "#111111"))
 
         dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor(hexCode: "#444444"))
 
+        currentThumbnailURL = trip.thumbnailURL
         if let urlString = trip.thumbnailURL, let url = URL(string: urlString) {
             thumbnailImageView.kf.setImage(
                 with: url,
-                options: [.transition(.fade(0.2)), .cacheOriginalImage]
-            )
+                options: [.transition(.fade(0.2)), .cacheOriginalImage]
+            ) { [weak self] result in
+                guard let self = self else { return }
+                switch result {
+                case .success(let value):
+                    if self.currentThumbnailURL != value.source.url?.absoluteString {
+                        return
+                    }
+                case .failure:
+                    self.thumbnailImageView.backgroundColor = .systemGray5
+                }
+            }
+        } else {
+            thumbnailImageView.kf.cancelDownloadTask()
+            thumbnailImageView.image = nil
+            thumbnailImageView.backgroundColor = .systemGray5
         }
     }

YoutuberContentCell(Projects/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift lines 92-130)의 패턴을 참고하세요.

Projects/Features/TravelFeature/Sources/TravelViewController.swift (2)

25-25: 사용되지 않는 disposeBag 선언

disposeBag이 선언되어 있지만 현재 파일에서 사용되지 않습니다. 향후 RxSwift 바인딩을 위해 남겨둔 것이라면 무시해도 되지만, 사용 계획이 없다면 제거를 고려해 주세요.


153-155: SwiftLint: implicit return 권장

단일 표현식 반환에 return 키워드를 생략하면 더 간결해집니다.

♻️ 제안 수정
     func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
-        return trips.count
+        trips.count
     }
Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift (2)

103-139: dayLabel.text 중복 설정 제거 고려
setText가 최종 텍스트/속성을 모두 설정하므로 앞의 dayLabel.text는 중복입니다. 불필요한 업데이트를 줄일 수 있습니다.

♻️ 간단 정리 예시
-    dayLabel.text = "\(day)"
+    // setText에서 텍스트/속성을 설정하므로 중복 제거

141-169: 제약 재생성(remake) 빈도 최적화 고려
스크롤이 잦은 캘린더에서 snp.remakeConstraints를 매번 호출하면 레이아웃 비용이 커질 수 있습니다. 상/하위 제약을 미리 저장해 활성/비활성 전환 또는 updateConstraints로 최소 변경만 하는 방식도 고려해 주세요.

Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift (2)

17-17: 재사용 식별자를 클래스명 기반으로 생성하는 편이 안전합니다.
클래스 리네임 시 문자열 불일치 위험을 줄일 수 있습니다.

✅ 제안 변경
-    static let identifier = "DayCell"
+    static let identifier = String(describing: DayCell.self)

69-72: VoiceOver 접근성을 위해 셀 라벨 지정 고려
셀 자체를 접근성 요소로 만들면 포커싱이 쉬워집니다.

✅ 제안 변경
     func configure(day: Int) {
         dayLabel.text = "\(day)일차"
+        contentView.isAccessibilityElement = true
+        contentView.accessibilityLabel = dayLabel.text
         updateSelectionState()
     }
Projects/Features/FollowFeature/Sources/Views/BudgetView.swift (1)

76-80: NumberFormatter 재사용으로 비용 절감 권장.
configure 호출마다 포매터를 생성하면 불필요한 비용이 발생합니다. 정적 캐시로 재사용하세요.

♻️ 제안 변경
 final class BudgetView: UIView {
+    private static let budgetFormatter: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        return formatter
+    }()
 ...
     func configure(budget: Int) {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)"
+        let formattedNumber = Self.budgetFormatter.string(from: NSNumber(value: budget)) ?? "\(budget)"
         budgetLabel.setText(.bodyMSB, text: "\(formattedNumber)원", color: UIColor(hexCode: "#111111"))
     }
 }
Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift (1)

69-90: 경로 순서 보장을 위해 sequence 정렬 권장.
입력 배열의 순서가 보장되지 않으면 polyline 경로가 뒤섞일 수 있습니다. sequence 기준 정렬 후 좌표/어노테이션을 생성하는 편이 안전합니다.

♻️ 제안 변경
     func configure(with places: [TravelPlace]) {
-        self.places = places
+        let sortedPlaces = places.sorted { $0.sequence < $1.sequence }
+        self.places = sortedPlaces
 ...
-        for place in places {
+        for place in sortedPlaces {
             let coordinate = CLLocationCoordinate2D(
                 latitude: place.place.latitude,
                 longitude: place.place.longitude
             )
Projects/Features/TabBarFeature/Sources/TabBarViewController.swift (1)

55-73: 입력 VC 개수 검증을 명확히 해 오용을 방지하세요
현재 2개 이상이면 추가 VC가 조용히 무시됩니다. 의도라면 명시적으로 경고/검증해 주세요.

✅ 개선 제안
 public func setViewControllers(_ viewControllers: [ViewControllable]) {
-    guard viewControllers.count >= 2 else {
-        return
-    }
+    guard viewControllers.count >= 2 else {
+        assertionFailure("TabBarViewController expects at least 2 view controllers.")
+        return
+    }
+    if viewControllers.count > 2 {
+        assertionFailure("Extra view controllers are ignored.")
+    }
 
     let homeVC = viewControllers[0].uiviewController
     let travelVC = viewControllers[1].uiviewController
Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift (1)

35-38: UIColor 초기화 방식 불일치

Line 35에서는 UIColor(hexCode:)를 사용하고, Line 38에서는 UIColor.init(hexCode:)를 사용하고 있습니다. 일관성을 위해 동일한 방식으로 통일하는 것이 좋습니다.

♻️ 제안된 수정
-        backgroundColor = UIColor(hexCode: "#FFFFFF")
+        backgroundColor = UIColor(hexCode: "#FFFFFF")
         layer.cornerRadius = 4
         layer.borderWidth = 1.0
-        layer.borderColor = UIColor.init(hexCode: "#F1F1F1").cgColor
+        layer.borderColor = UIColor(hexCode: "#F1F1F1").cgColor
Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift (1)

37-42: DSKit 스케일링 익스텐션 사용 권장

DSKit 컴포넌트에서는 하드코딩된 값 대신 adjustedH, adjustedW, adjusted 스케일링 익스텐션을 사용해야 합니다. 현재 cornerRadius, spacing, iconImageView 사이즈에 하드코딩된 값이 사용되고 있습니다.

♻️ 스케일링 적용 제안
     private func setupUI() {
-        backgroundColor = UIColor.init(hexCode: "#111111")
-        layer.cornerRadius = 8
+        backgroundColor = UIColor(hexCode: "#111111")
+        layer.cornerRadius = 8.adjusted

         contentStackView.axis = .horizontal
-        contentStackView.spacing = 8
+        contentStackView.spacing = 8.adjusted
         contentStackView.alignment = .center
         ...
     }

     private func setupConstraints() {
         contentStackView.snp.makeConstraints {
             $0.center.equalToSuperview()
         }

         iconImageView.snp.makeConstraints {
-            $0.size.equalTo(24)
+            $0.size.equalTo(24.adjusted)
         }
     }

Based on learnings: "DSKit 컴포넌트에서는 adjustedH(높이 비율), adjustedW/adjusted(너비 비율) 스케일링 익스텐션을 사용해야 합니다."

Also applies to: 56-63

Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift (1)

76-78: 불필요한 override 제거 권장.
super만 호출하는 override는 유지보수성만 떨어뜨립니다.

♻️ 제안 변경
-    override func willResignActive() {
-        super.willResignActive()
-    }

Comment on lines +199 to +204
private func syncDaySelection(day: Int) {
currentSelectedDay = day
let indexPath = IndexPath(item: day - 1, section: 0)
dayCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
stickyDayCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

day 범위 검증 누락으로 선택 예외 가능.
day가 1...totalDays 범위를 벗어나면 selectItem에서 예외가 발생할 수 있습니다. 방어 로직을 추가하세요.

🛡️ 제안 변경
     private func syncDaySelection(day: Int) {
-        currentSelectedDay = day
+        guard totalDays > 0, (1...totalDays).contains(day) else { return }
+        currentSelectedDay = day
         let indexPath = IndexPath(item: day - 1, section: 0)
         dayCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
         stickyDayCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
     }
🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift`
around lines 199 - 204, Validate the incoming day in syncDaySelection(day:)
before using it: ensure day is within 1...totalDays (or clamp/early-return if
out of range) so currentSelectedDay, IndexPath(item: day - 1, section: 0), and
the calls to dayCollectionView.selectItem(...) and
stickyDayCollectionView.selectItem(...) cannot be invoked with an invalid index;
update the function (referencing syncDaySelection(day:), currentSelectedDay,
dayCollectionView, stickyDayCollectionView, and totalDays) to perform this guard
and only proceed when the computed indexPath is valid.

Comment on lines +44 to +48
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent {
listener?.didTapBackButton()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

UIViewController isMovingFromParent modal swipe dismiss iOS

💡 Result:

isMovingFromParent is not for modal dismissal. It becomes true when a view controller is being removed from its parent container (most commonly: popped from a UINavigationController / removed from a parent VC), not when a presented VC is dismissed. Use isBeingDismissed for modals. [1][2]

Detect “swipe down to dismiss” (iOS 13+ sheets)

Interactive swipe dismissal of .pageSheet / .formSheet still counts as a modal dismissal, so isBeingDismissed is the right lifecycle flag. [2][4]

Typical pattern:

override func viewDidDisappear(_ animated: Bool) {
  super.viewDidDisappear(animated)

  if isBeingDismissed || isMovingFromParent {
    // dismissed (including swipe) OR popped
  }
}

If your presented screen is a UINavigationController, the dismissal flag is usually on the navigation controller:

override func viewDidDisappear(_ animated: Bool) {
  super.viewDidDisappear(animated)

  if navigationController?.isBeingDismissed == true || isMovingFromParent {
    // modal dismissed (including swipe) OR popped
  }
}

If you specifically need a callback when the swipe-dismiss finishes

Adopt UIAdaptivePresentationControllerDelegate and use the dismissal delegate methods (e.g. presentationControllerDidDismiss). [3]

If you want to prevent swipe-to-dismiss

Set isModalInPresentation = true. [3][4]

Sources: [1] StackOverflow pattern using isBeingDismissed || isMovingFromParent; [2] isBeingDismissed semantics; [3] iOS 13 modal + UIAdaptivePresentationControllerDelegate + isModalInPresentation; [4] iOS 13 swipe-to-dismiss + isModalInPresentation.


🌐 Web query:

UIViewController isBeingDismissed swipe gesture dismissal

💡 Result:

isBeingDismissed becomes true as soon as an interactive swipe-to-dismiss begins (e.g., a .pageSheet pull-down), even if the user later cancels and the controller never actually goes away. (stackoverflow.com)

If you only care about completed dismissal

Use viewDidDisappear (not viewWillDisappear) and check isBeingDismissed there—viewDidDisappear only happens after the dismissal finishes (so a canceled swipe won’t trigger it). (stackoverflow.com)

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)

    if isBeingDismissed {
        // dismissal completed
    }
}

If your presented UI is wrapped in a UINavigationController, the dismissal flag may be on the navigation controller, so check that too: (stackoverflow.com)

var isActuallyClosing: Bool {
    isBeingDismissed
    || isMovingFromParent
    || (navigationController?.isBeingDismissed ?? false)
}

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    if isActuallyClosing {
        // cleanup
    }
}

If you want the “sheet dismissed” callback (recommended for iOS 13+ sheets)

Set a UIAdaptivePresentationControllerDelegate and handle presentationControllerDidDismiss(_:) (this is specifically for interactive dismissals like swipe-down). (developer.apple.com)

Citations:


🏁 Script executed:

find Projects/Features/FollowFeature/Sources/TripCalendar -name "*.swift" | head -20

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 399


🏁 Script executed:

cat -n Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift | head -80

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 2559


🏁 Script executed:

cat -n Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarBuilder.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1569


🏁 Script executed:

cat -n Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarRouter.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1284


🏁 Script executed:

rg "TripCalendarViewController|TripCalendarRouting" Projects/Features/FollowFeature --type swift -B 2 -A 2

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 5987


🏁 Script executed:

rg "didTapBackButton" --type swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 423


🏁 Script executed:

find Projects -name "*.swift" -type f | xargs rg "viewDidDisappear" -B 2 -A 4 | head -60

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1734


🏁 Script executed:

cat -n Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarInteractor.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 2120


모달 스와이프 종료 시 cancel 콜백 누락 해결 필요.

isMovingFromParent는 네비게이션 스택 pop에서만 true가 됩니다. iOS 13+ 모달 스와이프 dismiss 시에는 isBeingDismissed를 확인해야 didTapBackButton() 콜백이 호출되어 자식 RIB가 올바르게 정리됩니다.

수정 제안
     override func viewDidDisappear(_ animated: Bool) {
         super.viewDidDisappear(animated)
-        if isMovingFromParent {
+        if isMovingFromParent || isBeingDismissed {
             listener?.didTapBackButton()
         }
     }
🤖 Prompt for AI Agents
In
`@Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift`
around lines 44 - 48, The viewDidDisappear implementation only checks
isMovingFromParent, so didTapBackButton() is not called for iOS13+ modal swipe
dismissals; update TripCalendarViewController.viewDidDisappear(_:) to call
listener?.didTapBackButton() when either isMovingFromParent or isBeingDismissed
is true (i.e., check both isMovingFromParent || isBeingDismissed before invoking
listener?.didTapBackButton()) so the child RIB is cleaned up on modal swipe
dismiss as well.

Comment on lines +53 to +58
override func prepareForReuse() {
super.prepareForReuse()
backgroundCircleView.isHidden = true
rangeBackgroundView.isHidden = true
dayLabel.text = nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

재사용 시 rangeBackgroundView 제약 기본값 복원 고려
configureRangeBackground에서 제약을 절반으로 바꾸는 경우가 있으니, 재사용 시 기본(전체폭) 제약으로 되돌려 두는 편이 안전합니다. 호출 측에서 항상 configureRangeBackground를 보장하지 않으면 이전 제약이 남아 시각적 깨짐이 생길 수 있습니다.

✅ 제약 복원 헬퍼 추가 예시
 override func prepareForReuse() {
     super.prepareForReuse()
     backgroundCircleView.isHidden = true
     rangeBackgroundView.isHidden = true
     dayLabel.text = nil
+    resetRangeBackgroundConstraints()
 }
+
+private func resetRangeBackgroundConstraints() {
+    rangeBackgroundView.snp.remakeConstraints {
+        $0.leading.trailing.equalToSuperview()
+        $0.centerY.equalToSuperview()
+        $0.height.equalTo(36)
+    }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override func prepareForReuse() {
super.prepareForReuse()
backgroundCircleView.isHidden = true
rangeBackgroundView.isHidden = true
dayLabel.text = nil
}
override func prepareForReuse() {
super.prepareForReuse()
backgroundCircleView.isHidden = true
rangeBackgroundView.isHidden = true
dayLabel.text = nil
resetRangeBackgroundConstraints()
}
private func resetRangeBackgroundConstraints() {
rangeBackgroundView.snp.remakeConstraints {
$0.leading.trailing.equalToSuperview()
$0.centerY.equalToSuperview()
$0.height.equalTo(36)
}
}
🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift`
around lines 53 - 58, prepareForReuse currently hides views and clears the label
but doesn't restore rangeBackgroundView constraints, so previous half-width
constraints from configureRangeBackground can persist; update prepareForReuse
(or add a helper called resetRangeBackgroundConstraints) to reinstate the
default full-width constraint on rangeBackgroundView (remove/disable any
half-width constraint set by configureRangeBackground and enable/set the
full-width constraint), ensuring rangeBackgroundView's layout returns to its
original state before reuse.

Comment on lines +286 to +310
// 프로필 이미지 로딩
if let profileURLString = detail.youtube.profileImage,
let profileURL = URL(string: profileURLString) {
profileImageView.kf.setImage(
with: profileURL,
placeholder: nil,
options: [
.transition(.fade(0.2)),
.cacheOriginalImage
]
)
}

// 썸네일 이미지 로딩
if let thumbnailURLString = detail.youtube.thumbnail,
let thumbnailURL = URL(string: thumbnailURLString) {
thumbnailImageView.kf.setImage(
with: thumbnailURL,
placeholder: nil,
options: [
.transition(.fade(0.2)),
.cacheOriginalImage
]
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "MediaInfoView.swift" -type f

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 142


🏁 Script executed:

fd "MediaInfoView" --type f

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 140


🏁 Script executed:

git ls-files | grep -i mediainfo

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 140


🏁 Script executed:

cat -n Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift | head -100

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 3494


🏁 Script executed:

wc -l Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 144


🏁 Script executed:

sed -n '280,320p' Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift | cat -n

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1798


🏁 Script executed:

sed -n '1,50p' Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift | grep -i "class\|func configure"

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 110


🏁 Script executed:

sed -n '150,250p' Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift | cat -n

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 4296


🏁 Script executed:

sed -n '260,290p' Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift | cat -n

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1558


🏁 Script executed:

grep -n "func configure" Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 126


🏁 Script executed:

grep -n "setImage\|cancelDownload" Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 300


🏁 Script executed:

rg "MediaInfoView" --type swift -B 2 -A 2

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 2992


🏁 Script executed:

grep -r "configure(with" Projects/Features/FollowFeature/Sources --include="*.swift" -B 3 -A 3

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 5944


🏁 Script executed:

rg "updateTravelDetail" --type swift -B 5 -A 3

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 2918


🌐 Web query:

Kingfisher setImage cancel previous download automatically

💡 Result:

No—calling imageView.kf.setImage(...) does not automatically cancel any previous in-flight download for that image view. The earlier download may continue (and be cached), but its result will not be applied if a newer setImage call has replaced it. (github.com)

If you want to actually stop the previous request, cancel it yourself:

imageView.kf.cancelDownloadTask()
imageView.kf.setImage(with: url)

(github.com)

Or keep the returned task and cancel it:

let task = imageView.kf.setImage(with: url)
// ...
task?.cancel()

(github.com)

Citations:


🏁 Script executed:

rg "FollowDetailViewController\|updateTravelDetail" --type swift -l | head -20

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 57


🏁 Script executed:

cat -n Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift | grep -i "init\|deinit\|viewdidload\|refresh" | head -20

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 234


이미지 로딩 시 명시적 취소 패턴 추가 권장.
Kingfisher의 setImage()는 이전 다운로드를 자동으로 취소하지 않습니다. 현재 코드에서는 수동으로 취소해야 하므로, 데이터 갱신 시나리오에 대비하여 다음 패턴을 적용하는 것을 권장합니다.

imageView.kf.cancelDownloadTask()
imageView.kf.setImage(with: url, ...)
🤖 Prompt for AI Agents
In `@Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift` around
lines 286 - 310, Before starting new Kingfisher downloads, cancel any previous
tasks to avoid overlapping downloads: in MediaInfoView.swift update the profile
and thumbnail loading blocks to call profileImageView.kf.cancelDownloadTask()
immediately before profileImageView.kf.setImage(...) and
thumbnailImageView.kf.cancelDownloadTask() immediately before
thumbnailImageView.kf.setImage(...); preserve the existing URL guards
(profileURLString/profileURL and thumbnailURLString/thumbnailURL) and the same
setImage options (.transition, .cacheOriginalImage).

Comment on lines +41 to 51
public override func viewDidLoad() {
super.viewDidLoad()

setupBaseTabBar()
setStyle()
setUI()
setLayout()
setupStyle()
setupUI()
setupConstraints()
}

public override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
tabBar.isHidden = true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

modifier_order 경고: overridepublic 앞에 배치하세요
SwiftLint 경고가 Line 41, Line 48에서 발생합니다. 순서를 바꿔주세요.

🧹 수정 제안
-public override func viewDidLoad() {
+override public func viewDidLoad() {
     super.viewDidLoad()
     setupStyle()
     setupUI()
     setupConstraints()
 }
 
-public override func viewWillLayoutSubviews() {
+override public func viewWillLayoutSubviews() {
     super.viewWillLayoutSubviews()
     tabBar.isHidden = true
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public override func viewDidLoad() {
super.viewDidLoad()
setupBaseTabBar()
setStyle()
setUI()
setLayout()
setupStyle()
setupUI()
setupConstraints()
}
public override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
tabBar.isHidden = true
}
override public func viewDidLoad() {
super.viewDidLoad()
setupStyle()
setupUI()
setupConstraints()
}
override public func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
tabBar.isHidden = true
}
🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 41-41: override modifier should come before public

(modifier_order)


[Warning] 48-48: override modifier should come before public

(modifier_order)

🤖 Prompt for AI Agents
In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift` around
lines 41 - 51, The SwiftLint warning is due to modifier order: move the override
modifier before access control for the methods viewDidLoad() and
viewWillLayoutSubviews(); change declarations from "public override func
viewDidLoad()" and "public override func viewWillLayoutSubviews()" to "override
public func viewDidLoad()" and "override public func viewWillLayoutSubviews()"
(or simply "override func ..." if public is redundant) so the modifiers follow
the recommended order.

Comment on lines 185 to 207
func updateSelection(at index: Int, animated: Bool = true) {
self.selectedIndex = index
selectedIndex = index
let targetItem = tabItems[index]

tabItems.enumerated().forEach { tabIndex, item in
item.isTabSelected = (tabIndex == index)
}

let duration = animated ? 0.4 : 0.0

UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) {
self.indicatorView.snp.remakeConstraints {
$0.center.equalTo(targetItem)
$0.width.equalTo(targetItem.snp.width)
$0.height.equalTo(56.adjustedH)
}

self.customTabBarContainer.contentView.layoutIfNeeded()
}

if animated {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

탭 인덱스 가드 없이 접근하면 크래시 가능성
sender.tag 값이 배열 인덱스와 어긋나면 tabItems[index] 접근이 크래시로 이어질 수 있습니다. 방어 가드를 추가해 주세요.

🛡️ 수정 제안
 func updateSelection(at index: Int, animated: Bool = true) {
-    selectedIndex = index
-    let targetItem = tabItems[index]
+    guard index >= 0, index < tabItems.count else { return }
+    guard let vcs = viewControllers, index < vcs.count else { return }
+    selectedIndex = index
+    let targetItem = tabItems[index]
🤖 Prompt for AI Agents
In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift` around
lines 185 - 207, The updateSelection(at:animated:) method directly accesses
tabItems[index] which can crash if index is out of bounds; add a defensive guard
at the start of updateSelection(at:animated:) to ensure index is >= 0 and <
tabItems.count and early-return if not, and use the safely-guarded targetItem
afterward (reference: updateSelection(at:animated:), selectedIndex, tabItems,
indicatorView). Also update any callers that forward sender.tag to call
updateSelection only when the tag is within valid range.

Comment on lines +85 to +89
public override var isHighlighted: Bool {
didSet {
alpha = isHighlighted ? 0.7 : 1.0
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SwiftLint: 수정자 순서 오류

override 수정자가 public 앞에 와야 합니다.

🔧 수정 제안
-    public override var isHighlighted: Bool {
+    override public var isHighlighted: Bool {
         didSet {
             alpha = isHighlighted ? 0.7 : 1.0
         }
     }
🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 85-85: override modifier should come before public

(modifier_order)

🤖 Prompt for AI Agents
In `@Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift` around
lines 85 - 89, The declaration of the isHighlighted computed property uses the
wrong modifier order; change "public override var isHighlighted" to place the
override modifier before the access level (i.e. "override public var
isHighlighted") in BottomPlacedButton.swift so it satisfies the SwiftLint
modifier_order rule; update the property declaration with the same didSet body
(alpha = isHighlighted ? 0.7 : 1.0) and run SwiftLint to confirm.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In
`@Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift`:
- Around line 83-151: The findRouteButton currently holds its visible text
inside subviews (findRouteLabel) so VoiceOver may not announce it; set the
button's accessibilityLabel and traits and/or make the label a
accessibilityElement of the button: assign findRouteButton.accessibilityLabel =
"길찾기" (or use findRouteLabel.text), set findRouteButton.accessibilityTraits =
.button, and ensure findRouteLabel.isAccessibilityElement = false (or
alternatively set findRouteButton.accessibilityElements = [findRouteLabel,
findRouteIconImageView] with appropriate labels) inside setupUI (references:
findRouteButton, findRouteLabel, findRouteIconImageView, setupUI).

In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift`:
- Around line 75-90: The switchToTab(at:) method currently only checks upper
bound and can accept negative indexes; update the guard to ensure index is
within 0 ..< tabItems.count (e.g. guard index >= 0 && index < tabItems.count
else { return }) so updateSelection(at:) is never called with a negative index,
and when you access viewControllers ensure you only operate on valid indices
(use the safe bounds check above before calling updateSelection(at:) or
accessing tabItems/viewControllers).
- Around line 55-73: The setViewControllers(_ viewControllers:
[ViewControllable]) implementation assumes exactly two inputs but only checks
for >= 2; change the validation to require exactly two items (e.g., guard
viewControllers.count == 2 else { assertionFailure("expected exactly 2
viewControllers: home and travel"); return }) so extra inputs aren't silently
ignored, and keep using viewControllers[0]/[1] (homeVC, travelVC) and the
internally created infoDummy/infoNav as before.
- Around line 30-31: setupTabItems() currently iterates TabBarItemType.allCases
and uses each case's rawValue for the UITabBarItem.tag which risks mismatches if
enum order changes; update setupTabItems() to iterate the declared tabTypes
array instead and set each created UITabBarItem.tag to the loop index (the
enum's position) rather than rawValue, referencing tabTypes and setupTabItems()
and replacing uses of TabBarItemType.allCases and rawValue with the index-based
tag assignment.
🧹 Nitpick comments (2)
Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift (1)

112-117: 하드코딩된 hex 색상 값에 대한 유지보수성 고려

DSKitAsset 컬러 토큰에서 명시적 hex 값으로의 마이그레이션은 일관되게 적용되었습니다. 그러나 동일한 hex 값(#2C2C2C, #383838 등)이 여러 컴포넌트에 분산되어 있어 향후 디자인 시스템 색상 변경 시 모든 인스턴스를 수동으로 찾아 수정해야 합니다.

중앙화된 색상 정의(예: DSKitColors enum 또는 extension)를 고려해 보세요:

♻️ 중앙화된 색상 관리 제안
// DSKitColors.swift (새 파일)
public enum DSKitColors {
    public static let textPrimary = UIColor(hexCode: "#2C2C2C")
    public static let iconDefault = UIColor(hexCode: "#383838")
    public static let textSecondary = UIColor(hexCode: "#757575")
    public static let backgroundLight = UIColor(hexCode: "#F5F5F5")
    // ...
}

그 후 컴포넌트에서 참조:

-let normalColor = UIColor(hexCode: "#383838")
+let normalColor = DSKitColors.iconDefault
Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift (1)

148-245: 사용자 노출 문자열 로컬라이즈 권장

하드코딩 문자열이 다수 있어 다국어 확장/일관성에 취약합니다. 공통 로컬라이징 경로로 묶는 것을 권장합니다.

✅ 예시 (NSLocalizedString)
- findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111"))
+ findRouteLabel.setText(.bodyMSB, text: NSLocalizedString("follow.place.find_route", comment: "길찾기"), color: UIColor(hexCode: "#111111"))

- categoryLabel.setText(.bodySR, text: "🏔 관광명소", color: UIColor(hexCode: "#757575"))
+ categoryLabel.setText(.bodySR, text: NSLocalizedString("follow.place.category.default", comment: "기본 카테고리"), color: UIColor(hexCode: "#757575"))

- openingHoursLabel.setText(.bodySR, text: "영업시간 \(openingHours)", color: UIColor(hexCode: "#2C2C2C"))
+ let openingHoursText = String(
+     format: NSLocalizedString("follow.place.opening_hours", comment: "영업시간 표시"),
+     openingHours
+ )
+ openingHoursLabel.setText(.bodySR, text: openingHoursText, color: UIColor(hexCode: "#2C2C2C"))

- timeLabel.setText(.bodySR, text: "시간 추가", color: UIColor(hexCode: "#444444"))
+ timeLabel.setText(.bodySR, text: NSLocalizedString("follow.place.add_time", comment: "시간 추가"), color: UIColor(hexCode: "#444444"))

- costLabel.setText(.bodySR, text: "비용 추가", color: UIColor(hexCode: "#444444"))
+ costLabel.setText(.bodySR, text: NSLocalizedString("follow.place.add_cost", comment: "비용 추가"), color: UIColor(hexCode: "#444444"))

Comment on lines +83 to +151
private let findRouteButton = UIButton(type: .system).then {
$0.backgroundColor = UIColor(hexCode: "#FFFFFF")
$0.layer.cornerRadius = 8
$0.layer.borderWidth = 1
$0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor
}

private let findRouteStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 4
$0.alignment = .center
$0.isUserInteractionEnabled = false
}

private let findRouteLabel = UILabel()

private let findRouteIconImageView = UIImageView().then {
$0.image = DSKitAsset.Assets.icMap1.image
$0.tintColor = UIColor(hexCode: "#111111")
$0.contentMode = .scaleAspectFit
}

// MARK: - Initialization

override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Setup

private func setupUI() {
backgroundColor = .white

[titleLabel, chevronButton, categoryInfoStackView, openingHoursLabel,
timeStackView, costStackView, findRouteButton].forEach {
addSubview($0)
}

// 카테고리 스택뷰
[categoryLabel, dotLabel, durationLabel, categoryChevronImageView].forEach {
categoryInfoStackView.addArrangedSubview($0)
}

// 시간 스택뷰
[timeIconImageView, timeLabel].forEach {
timeStackView.addArrangedSubview($0)
}

// 비용 스택뷰
[costIconImageView, costLabel].forEach {
costStackView.addArrangedSubview($0)
}

// 길찾기 버튼 내부
findRouteButton.addSubview(findRouteStackView)
[findRouteLabel, findRouteIconImageView].forEach {
findRouteStackView.addArrangedSubview($0)
}

// 기본 텍스트 설정
dotLabel.setText(.bodySR, text: "•", color: UIColor(hexCode: "#444444"))
findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111"))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

버튼 접근성 라벨 누락으로 VoiceOver에서 식별 어려움

findRouteButton에 텍스트가 서브뷰로만 존재해 접근성 라벨이 비어 있을 가능성이 큽니다. 버튼 라벨/트레이트를 지정해 주세요.

✅ 제안 수정안
 private func setupUI() {
     backgroundColor = .white
@@
     // 기본 텍스트 설정
     dotLabel.setText(.bodySR, text: "•", color: UIColor(hexCode: "#444444"))
     findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111"))
+
+    // 접근성
+    findRouteButton.isAccessibilityElement = true
+    findRouteButton.accessibilityLabel = "길찾기"
+    findRouteButton.accessibilityTraits = .button
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private let findRouteButton = UIButton(type: .system).then {
$0.backgroundColor = UIColor(hexCode: "#FFFFFF")
$0.layer.cornerRadius = 8
$0.layer.borderWidth = 1
$0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor
}
private let findRouteStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 4
$0.alignment = .center
$0.isUserInteractionEnabled = false
}
private let findRouteLabel = UILabel()
private let findRouteIconImageView = UIImageView().then {
$0.image = DSKitAsset.Assets.icMap1.image
$0.tintColor = UIColor(hexCode: "#111111")
$0.contentMode = .scaleAspectFit
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .white
[titleLabel, chevronButton, categoryInfoStackView, openingHoursLabel,
timeStackView, costStackView, findRouteButton].forEach {
addSubview($0)
}
// 카테고리 스택뷰
[categoryLabel, dotLabel, durationLabel, categoryChevronImageView].forEach {
categoryInfoStackView.addArrangedSubview($0)
}
// 시간 스택뷰
[timeIconImageView, timeLabel].forEach {
timeStackView.addArrangedSubview($0)
}
// 비용 스택뷰
[costIconImageView, costLabel].forEach {
costStackView.addArrangedSubview($0)
}
// 길찾기 버튼 내부
findRouteButton.addSubview(findRouteStackView)
[findRouteLabel, findRouteIconImageView].forEach {
findRouteStackView.addArrangedSubview($0)
}
// 기본 텍스트 설정
dotLabel.setText(.bodySR, text: "", color: UIColor(hexCode: "#444444"))
findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111"))
}
private let findRouteButton = UIButton(type: .system).then {
$0.backgroundColor = UIColor(hexCode: "#FFFFFF")
$0.layer.cornerRadius = 8
$0.layer.borderWidth = 1
$0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor
}
private let findRouteStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 4
$0.alignment = .center
$0.isUserInteractionEnabled = false
}
private let findRouteLabel = UILabel()
private let findRouteIconImageView = UIImageView().then {
$0.image = DSKitAsset.Assets.icMap1.image
$0.tintColor = UIColor(hexCode: "#111111")
$0.contentMode = .scaleAspectFit
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .white
[titleLabel, chevronButton, categoryInfoStackView, openingHoursLabel,
timeStackView, costStackView, findRouteButton].forEach {
addSubview($0)
}
// 카테고리 스택뷰
[categoryLabel, dotLabel, durationLabel, categoryChevronImageView].forEach {
categoryInfoStackView.addArrangedSubview($0)
}
// 시간 스택뷰
[timeIconImageView, timeLabel].forEach {
timeStackView.addArrangedSubview($0)
}
// 비용 스택뷰
[costIconImageView, costLabel].forEach {
costStackView.addArrangedSubview($0)
}
// 길찾기 버튼 내부
findRouteButton.addSubview(findRouteStackView)
[findRouteLabel, findRouteIconImageView].forEach {
findRouteStackView.addArrangedSubview($0)
}
// 기본 텍스트 설정
dotLabel.setText(.bodySR, text: "", color: UIColor(hexCode: "#444444"))
findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111"))
// 접근성
findRouteButton.isAccessibilityElement = true
findRouteButton.accessibilityLabel = "길찾기"
findRouteButton.accessibilityTraits = .button
}
🤖 Prompt for AI Agents
In
`@Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift`
around lines 83 - 151, The findRouteButton currently holds its visible text
inside subviews (findRouteLabel) so VoiceOver may not announce it; set the
button's accessibilityLabel and traits and/or make the label a
accessibilityElement of the button: assign findRouteButton.accessibilityLabel =
"길찾기" (or use findRouteLabel.text), set findRouteButton.accessibilityTraits =
.button, and ensure findRouteLabel.isAccessibilityElement = false (or
alternatively set findRouteButton.accessibilityElements = [findRouteLabel,
findRouteIconImageView] with appropriate labels) inside setupUI (references:
findRouteButton, findRouteLabel, findRouteIconImageView, setupUI).

Comment on lines +30 to 31
private let tabTypes: [TabBarItemType] = [.travelTool, .home, .myTrip]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "TabBarViewController.swift" --type f

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 141


🏁 Script executed:

cat -n "Projects/Features/TabBarFeature/Sources/TabBarViewController.swift"

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 8971


🏁 Script executed:

fd "TabBarItemType" --type f

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 146


🏁 Script executed:

cat -n "Projects/Features/TabBarFeature/Sources/Components/TabBarItemType.swift"

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 1226


tabTypes을 사용하여 탭 항목 생성 로직 개선

setupTabItems()에서 TabBarItemType.allCases 대신 정의된 tabTypes을 직접 사용하고, rawValue 대신 열거형 인덱스를 태그로 설정해야 합니다. 현재 allCases 순서가 tabTypes와 일치하지만, 향후 enum 순서가 변경되면 인덱싱 오류가 발생할 수 있습니다.

수정 제안
-TabBarItemType.allCases.forEach { tabType in
-    let item = NDGLTabItem().then {
-        $0.setup(title: tabType.title, image: tabType.image)
-        $0.tag = tabType.rawValue
-        $0.addTarget(self, action: `#selector`(tabTapped(_:)), for: .touchUpInside)
-    }
+tabTypes.enumerated().forEach { index, tabType in
+    let item = NDGLTabItem().then {
+        $0.setup(title: tabType.title, image: tabType.image)
+        $0.tag = index
+        $0.addTarget(self, action: `#selector`(tabTapped(_:)), for: .touchUpInside)
+    }
     tabItems.append(item)
     tabStackView.addArrangedSubview(item)
 }
 
-DispatchQueue.main.async {
-    self.updateSelection(at: 1, animated: false)
-}
+let defaultIndex = min(1, tabTypes.count - 1)
+DispatchQueue.main.async {
-    self.updateSelection(at: defaultIndex, animated: false)
-}
🤖 Prompt for AI Agents
In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift` around
lines 30 - 31, setupTabItems() currently iterates TabBarItemType.allCases and
uses each case's rawValue for the UITabBarItem.tag which risks mismatches if
enum order changes; update setupTabItems() to iterate the declared tabTypes
array instead and set each created UITabBarItem.tag to the loop index (the
enum's position) rather than rawValue, referencing tabTypes and setupTabItems()
and replacing uses of TabBarItemType.allCases and rawValue with the index-based
tag assignment.

Comment on lines 55 to 73
public func setViewControllers(_ viewControllers: [ViewControllable]) {
guard let homeVC = viewControllers.first?.uiviewController else { return }
guard viewControllers.count >= 2 else {
return
}

let homeVC = viewControllers[0].uiviewController
let travelVC = viewControllers[1].uiviewController

let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow }
let myTripDummy = UIViewController().then { $0.view.backgroundColor = .green }

let finalControllers = [infoDummy, homeVC, myTripDummy]

super.setViewControllers(finalControllers, animated: false)


let infoNav = UINavigationController(rootViewController: infoDummy)
let homeNav = UINavigationController(rootViewController: homeVC)
let travelNav = UINavigationController(rootViewController: travelVC)

[infoNav, homeNav, travelNav].forEach { $0.delegate = self }

super.setViewControllers([infoNav, homeNav, travelNav], animated: false)
setupTabItems()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "setViewControllers\(" --type=swift -A 5 -B 5

Repository: YAPP-Github/27th-App-Team-1-iOS

Length of output: 4237


setViewControllers에 정확히 2개 항목만 전달되는지 보장 필요
현재 코드는 입력 배열의 인덱스 [0], [1]만 사용하고, 인포 탭은 내부에서 더미로 고정 생성합니다. TabBarRouter에서 정확히 2개(홈, 여행)를 전달하는 것은 확인되었으나, 입력 개수 검증이 >= 2로만 되어있어 3개 이상 전달되면 초과분이 무시됩니다. 의도를 더 명확히 하려면 정확히 2개 입력을 강제하는 검증(예: count == 2 assert)을 추가하거나 고정된 파라미터 구조로 개선하는 것이 좋습니다.

🤖 Prompt for AI Agents
In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift` around
lines 55 - 73, The setViewControllers(_ viewControllers: [ViewControllable])
implementation assumes exactly two inputs but only checks for >= 2; change the
validation to require exactly two items (e.g., guard viewControllers.count == 2
else { assertionFailure("expected exactly 2 viewControllers: home and travel");
return }) so extra inputs aren't silently ignored, and keep using
viewControllers[0]/[1] (homeVC, travelVC) and the internally created
infoDummy/infoNav as before.

Comment on lines +75 to 90
func switchToTab(at index: Int) {
guard index < tabItems.count else { return }

viewControllers?.forEach { viewController in
if let navController = viewController as? UINavigationController {
navController.popToRootViewController(animated: false)
}
tabItems.append(item)
tabStackView.addArrangedSubview(item)
}

updateSelection(at: index)

DispatchQueue.main.async {
self.updateSelection(at: 1, animated: false)
self.customTabBarContainer.isHidden = false
self.customTabBarContainer.alpha = 1
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

switchToTab 음수 인덱스 방어 필요
guard index < tabItems.count는 음수 인덱스를 통과시켜 updateSelection에서 크래시가 날 수 있습니다. 0 이상과 viewControllers 범위를 함께 방어하세요.

🛡️ 수정 제안
 func switchToTab(at index: Int) {
-    guard index < tabItems.count else { return }
+    guard index >= 0, index < tabItems.count else { return }
+    guard let vcs = viewControllers, index < vcs.count else { return }

     viewControllers?.forEach { viewController in
         if let navController = viewController as? UINavigationController {
             navController.popToRootViewController(animated: false)
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func switchToTab(at index: Int) {
guard index < tabItems.count else { return }
viewControllers?.forEach { viewController in
if let navController = viewController as? UINavigationController {
navController.popToRootViewController(animated: false)
}
tabItems.append(item)
tabStackView.addArrangedSubview(item)
}
updateSelection(at: index)
DispatchQueue.main.async {
self.updateSelection(at: 1, animated: false)
self.customTabBarContainer.isHidden = false
self.customTabBarContainer.alpha = 1
}
}
func switchToTab(at index: Int) {
guard index >= 0, index < tabItems.count else { return }
guard let vcs = viewControllers, index < vcs.count else { return }
viewControllers?.forEach { viewController in
if let navController = viewController as? UINavigationController {
navController.popToRootViewController(animated: false)
}
}
updateSelection(at: index)
DispatchQueue.main.async {
self.customTabBarContainer.isHidden = false
self.customTabBarContainer.alpha = 1
}
}
🤖 Prompt for AI Agents
In `@Projects/Features/TabBarFeature/Sources/TabBarViewController.swift` around
lines 75 - 90, The switchToTab(at:) method currently only checks upper bound and
can accept negative indexes; update the guard to ensure index is within 0 ..<
tabItems.count (e.g. guard index >= 0 && index < tabItems.count else { return })
so updateSelection(at:) is never called with a negative index, and when you
access viewControllers ensure you only operate on valid indices (use the safe
bounds check above before calling updateSelection(at:) or accessing
tabItems/viewControllers).

@KimNahun KimNahun merged commit 95d2fd6 into develop Jan 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ design 디자인 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

design: 따라가기 컨텐츠 + 여행담기 화면 UI 구현

2 participants