Skip to content

[NDGL-66] 일정 추가하기 화면 제작#26

Merged
mj010504 merged 9 commits intodevelopfrom
design/NDGL-66
Feb 18, 2026
Merged

[NDGL-66] 일정 추가하기 화면 제작#26
mj010504 merged 9 commits intodevelopfrom
design/NDGL-66

Conversation

@mj010504
Copy link
Copy Markdown
Contributor

@mj010504 mj010504 commented Feb 18, 2026

개요

  • 일정 추가하기 화면 제작

디자인

https://www.figma.com/design/qHn9o58ENLeHjiBWNuZFJx/Design_-YAPP-1%ED%8C%80-?node-id=2384-22520&t=eyU74Nuy00pF2YLK-0

영상

default.mp4

변경사항

  • 일정 추가하기 화면 UI/UX
    • AddItineraryBottomSheet
    • SearchedPlaceBottomSheet
    • 검색화면
    • 장소 관련 API 연동
  • 일정 추가하기 -> 장소 추가하기 화면 UI/UX 및 장소 관련 API 연동
  • Google Place AutoComplete API 연동
  • 디자인시스템에 NDGLBottomSheetDragHandle 추가
  • 디자인시스템에 NDGLNonModalBottomSheet 추가

참고 문서

추후 작업사항

  • 장소 북마크 기능
  • 일정 추가하기 기능

테스트 체크 리스트

  • Google Place AutoComplete API 연동
  • 바텀시트 상태 관리

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 18, 2026

Walkthrough

새로운 여행 일정 추가(Add Itinerary) 기능을 구현하며, 장소 검색 및 선택을 위한 UI 컴포넌트, 데이터 계층, 뷰 모델을 추가합니다. Google Places API 통합과 non-modal bottom sheet 디자인 시스템을 도입하고, 네비게이션 및 상태 관리를 업데이트합니다.

Changes

Cohort / File(s) Summary
Design System - Bottom Sheet Components
core/ui/src/main/java/.../NDGLBottomSheet.kt, core/ui/src/main/java/.../NDGLNonModalBottomSheet.kt
기존 드래그 핸들을 커스텀 composable로 교체하고, AnchoredDraggableState 기반의 non-modal bottom sheet 상태 관리 및 composable 추가. Nested scroll 통합 및 velocity 기반 fling 처리 포함.
Nested Scroll Connection Utility
core/ui/src/main/java/.../NestedScrollConnection.kt
Bottom sheet 범위 내 swipe 상호작용을 위한 NestedScrollConnection 유틸리티 함수 추가. Pre/post-scroll 및 fling 콜백 처리.
Places API Integration
data/travel/build.gradle.kts, data/travel/src/main/java/.../TravelNetworkModule.kt, gradle/libs.versions.toml
Google Places 라이브러리 의존성 추가 및 Hilt 기반 PlacesClient 제공자 구성. BuildConfig에 API 키 주입.
Place Search Data Models
data/travel/src/main/java/.../SearchKeywordResponse.kt, data/travel/src/main/java/.../PlaceRepository.kt
자동완성 검색 결과를 위한 SearchKeywordResponse/SearchResult 데이터 클래스 추가. PlaceRepository에 검색 및 세션 토큰 관리 메서드 추가.
Add Itinerary Feature - Contract & State
feature/travel/src/main/java/.../AddItineraryContract.kt
AddItineraryState, AddItineraryIntent, AddItinerarySideEffect 및 관련 데이터 모델 (SelectablePlace, PlaceInfo 등) 정의. 상태 파생 속성으로 조건부 UI 렌더링 제어.
Add Itinerary Feature - Screen & ViewModel
feature/travel/src/main/java/.../AddItineraryScreen.kt, feature/travel/src/main/java/.../AddItineraryViewModel.kt
AddItineraryRoute 및 화면 구현. 맵 토글, 검색 결과 리스트, 두 개의 bottom sheet 렌더링. ViewModel에서 장소 상세 로딩, 사진 로드, 북마크 상태 관리.
Add Itinerary Feature - Components
feature/travel/src/main/java/.../component/AddItineraryBottomSheet.kt, feature/travel/src/main/java/.../component/AddItineraryTopBar.kt, feature/travel/src/main/java/.../component/SearchComponents.kt, feature/travel/src/main/java/.../component/SearchPlaceMap.kt, feature/travel/src/main/java/.../component/SearchedPlaceBottomSheet.kt, feature/travel/src/main/java/.../component/AddItineraryButton.kt
Bottom sheet (draggable anchors, chip row, place list), 검색창(focus 관리, IME 액션), 검색 결과 UI, 지도 렌더링, 선택된 장소 상세 bottom sheet 컴포넌트.
Add Place Feature - Contract & Screen & ViewModel
feature/travel/src/main/java/.../addplace/AddPlaceContract.kt, feature/travel/src/main/java/.../addplace/AddPlaceScreen.kt, feature/travel/src/main/java/.../addplace/AddPlaceViewModel.kt
AddPlaceState, AddPlaceIntent, AddPlaceSideEffect 정의. 화면 구현으로 tab row, info/photo 탭 렌더링. ViewModel에서 장소 상세 및 사진 로드.
Add Place Feature - Components
feature/travel/src/main/java/.../addplace/component/AddPlaceInfoTab.kt, feature/travel/src/main/java/.../addplace/component/AddPlacePhotoTab.kt, feature/travel/src/main/java/.../addplace/component/AddPlaceTabRow.kt
장소 정보(주소, 전화, 웹사이트) 및 사진 갤러리 탭, tab row 선택 UI.
Navigation & Routing Updates
feature/travel/src/main/java/.../navigation/TravelEntry.kt, navigation/src/main/java/.../Route.kt
AddItinerary 및 AddPlace 라우트 추가. TravelDetail 및 PlaceDetail 라우트 시그니처 업데이트 (travelId Int→Long, alternativePlaces nullable).
Travel Detail State & Navigation
feature/travel/src/main/java/.../traveldetail/TravelDetailContract.kt, feature/travel/src/main/java/.../traveldetail/TravelDetailScreen.kt, feature/travel/src/main/java/.../traveldetail/TravelDetailViewModel.kt
TravelDetailState에 country 및 representativeLatLng 속성 추가. NavigateToAddItinerary side effect 추가. navigateToAddItinerary 콜백 처리. travelId 타입 Int→Long 변경.
Existing ViewModel Updates
feature/travel/src/main/java/.../placedetail/PlaceDetailViewModel.kt
phoneNumber 폴백 로직 업데이트 (nationalPhoneNumber ?: internationalPhoneNumber). estimatedDuration 제거.
Resources - Assets & Strings
core/ui/src/main/res/drawable/ic_140_serach.xml, core/ui/src/main/res/drawable/ic_24_arrow_up_right.xml, core/ui/src/main/res/drawable/ic_28_close.xml, core/ui/src/main/res/values/strings.xml
새로운 아이콘 drawable 자산 (검색, 화살표, 닫기). 일정 추가 검색 UI용 9개의 문자열 리소스 추가 (placeholder, empty hint, chip 레이블, no result 메시지).

Sequence Diagram(s)

sequenceDiagram
    participant User as 사용자
    participant AddItineraryScreen as AddItinerary<br/>Screen
    participant AddItineraryVM as AddItinerary<br/>ViewModel
    participant PlaceRepository as PlaceRepository
    participant PlacesAPI as Google Places<br/>API
    participant BottomSheet as Bottom Sheet<br/>Components

    User->>AddItineraryScreen: 검색창 포커스
    AddItineraryScreen->>AddItineraryVM: focusSearch()
    AddItineraryVM->>AddItineraryVM: 상태 업데이트<br/>(isSearchFocused)

    User->>AddItineraryScreen: 키워드 입력
    AddItineraryScreen->>AddItineraryVM: updateKeyword(keyword)
    AddItineraryVM->>AddItineraryVM: 디바운스 처리

    User->>AddItineraryScreen: 검색 실행
    AddItineraryScreen->>AddItineraryVM: searchKeyword()
    AddItineraryVM->>PlaceRepository: searchKeyword(keyword, country)
    PlaceRepository->>PlaceRepository: SessionToken 초기화
    PlaceRepository->>PlacesAPI: FindAutocompletePredictionsRequest
    PlacesAPI-->>PlaceRepository: 예측 결과
    PlaceRepository-->>AddItineraryVM: SearchKeywordResponse
    AddItineraryVM->>AddItineraryVM: 상태 업데이트<br/>(searchResults)

    AddItineraryVM-->>AddItineraryScreen: 상태 변경
    AddItineraryScreen->>BottomSheet: SearchResultItem<br/>렌더링

    User->>AddItineraryScreen: 검색 결과 선택
    AddItineraryScreen->>AddItineraryVM: selectSearchResult(result)
    AddItineraryVM->>PlaceRepository: getPlace(placeId)
    PlaceRepository->>PlacesAPI: FetchPlaceRequest
    PlacesAPI-->>PlaceRepository: Place 상세정보
    AddItineraryVM->>AddItineraryVM: PlaceInfo 생성 및<br/>사진 로드
    AddItineraryVM-->>AddItineraryScreen: 상태 업데이트

    AddItineraryScreen->>BottomSheet: SearchedPlaceBottomSheet<br/>렌더링 (expanded)
    User->>BottomSheet: AddItinerary 버튼 클릭
    BottomSheet->>AddItineraryVM: clickAddItinerary()
    AddItineraryVM-->>AddItineraryScreen: NavigateToAddPlace<br/>Side Effect
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 일정 추가하기 화면 제작이라는 주요 변경사항을 명확하게 요약하며, 구체적이고 의도를 잘 드러냅니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch design/NDGL-66

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: 8

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (3)
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailViewModel.kt (1)

27-27: ⚠️ Potential issue | 🟠 Major

생성자와 Factory 인터페이스의 alternativePlaces 타입 불일치 수정 필요

Line 27의 생성자 파라미터는 List<RouteAlternativePlace> (non-nullable)이지만, Line 152의 Factory 인터페이스 메서드는 List<RouteAlternativePlace>? (nullable)로 선언되어 있습니다. Kotlin에서 TT?는 별개의 타입이며, Line 68의 alternativePlaces.map { ... } 호출은 null이 아님을 가정하고 있습니다. 같은 패턴의 FollowPlaceDetailViewModel에서는 생성자와 Factory 메서드 모두 non-nullable로 올바르게 구현되어 있으므로, 이에 맞춰 수정해야 합니다.

Factory 파라미터를 non-nullable로 변경하세요:

수정안
-            alternativePlaces: List<RouteAlternativePlace>?,
+            alternativePlaces: List<RouteAlternativePlace>,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailViewModel.kt`
at line 27, The constructor parameter alternativePlaces in PlaceDetailViewModel
is non-nullable but the Factory interface method declares it as nullable; update
the Factory method signature (the Factory interface that creates
PlaceDetailViewModel) to accept alternativePlaces: List<RouteAlternativePlace>
(non-nullable) so it matches the `@Assisted` constructor parameter, and then
remove or avoid any null-handling around alternativePlaces (e.g., the
alternativePlaces.map { ... } call can remain as-is). Ensure the Factory method
name and parameter list (Factory.create or similar) uses the non-nullable type
to keep constructor and factory consistent.
navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt (1)

29-40: ⚠️ Potential issue | 🟡 Minor

PlaceDetailFollowPlaceDetailalternativePlaces null 처리 방식이 불일치합니다.

FollowPlaceDetail은 TravelEntry.kt에서 alternativePlaces?.map { ... } ?: emptyList()로 항상 non-null 값을 전달하고, 라우트 정의도 List<RouteAlternativePlace> = emptyList()입니다. 반면 PlaceDetail은 TravelEntry.kt에서 null을 직접 허용하고, 라우트 정의도 List<RouteAlternativePlace>? = null입니다. 두 라우트가 유사한 용도로 사용되지만 null 처리 패턴이 다르므로, PlaceDetailFollowPlaceDetail과 동일하게 ?: emptyList() 패턴을 적용하여 일관성을 맞추는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt` around lines 29 -
40, PlaceDetail's alternativePlaces is nullable while FollowPlaceDetail uses a
non-null List with default emptyList(), causing inconsistent null handling;
change PlaceDetail's declaration (data class PlaceDetail) to make
alternativePlaces: List<RouteAlternativePlace> = emptyList() to match
FollowPlaceDetail and ensure callers (e.g., mapping in TravelEntry.kt) rely on a
non-null list rather than nullable handling.
data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt (1)

47-61: ⚠️ Potential issue | 🟡 Minor

장소 선택 후 세션 토큰이 자동으로 초기화되지 않습니다

Google Places API에서 세션 토큰은 사용자 검색의 쿼리 및 선택 단계를 단일 세션으로 묶는 역할을 하며, 세션은 사용자가 쿼리 입력을 시작할 때 시작되고 장소를 선택할 때 종료됩니다. 현재 getPlace() 메서드는 resetSessionToken()을 호출하지 않으므로, 호출자가 이를 직접 관리하지 않으면 이후의 자동완성 요청들이 계속 동일한 세션 토큰을 재사용하게 됩니다. getPlace() 내부 또는 그 이후에 토큰을 자동으로 초기화하거나, KDoc으로 호출 계약을 명시해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt`
around lines 47 - 61, getPlace() currently never clears the Google Places
session token, so calls reuse sessionToken; modify getPlace(googlePlaceId:
String) to call resetSessionToken() after a successful place retrieval (both
when placeApi.getPlaceDetail(googlePlaceId).getData() succeeds and when you
recover via placeApi.savePlace(SavePlaceRequest(googlePlaceId)).getData()), e.g.
ensure resetSessionToken() is invoked in the success path or a
finally-equivalent flow so the token is cleared after selection; alternatively
add KDoc on getPlace/resetSessionToken to require callers to manage the token if
you prefer manual control.
🟡 Minor comments (17)
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt-88-124 (1)

88-124: ⚠️ Potential issue | 🟡 Minor

SearchResultItem: 수직 패딩 누락 및 trailing 아이콘 크기 미지정

두 가지 레이아웃 이슈가 있습니다:

  1. 수직 패딩 없음 (Line 91): padding(horizontal = 24.dp)만 적용되어 있어, 리스트에서 아이템 간 간격이 지나치게 좁아질 수 있습니다.
  2. trailing 아이콘 크기 미지정 (Lines 119-123): 화살표 아이콘에 Modifier.size()가 없어 기본 크기로 렌더링됩니다. 디자인 명세에 맞는 명시적 크기 지정이 필요합니다.
🐛 수정 제안
     Row(
         modifier = Modifier
             .fillMaxWidth()
-            .padding(horizontal = 24.dp)
+            .padding(horizontal = 24.dp, vertical = 12.dp)
             .noRippleClickable {
                 onClick()
             },
         verticalAlignment = Alignment.CenterVertically,
     ) {
         Icon(
             imageVector = ImageVector.vectorResource(R.drawable.ic_24_arrow_up_right),
             contentDescription = null,
             tint = NDGLTheme.colors.black400,
+            modifier = Modifier.size(24.dp),
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt`
around lines 88 - 124, The Row for the search result should include vertical
padding and the trailing arrow Icon needs an explicit size: update the Row's
Modifier.padding to include a vertical value (e.g., padding(horizontal = 24.dp,
vertical = <appropriate dp>)) to provide item spacing, and add a
Modifier.size(<appropriate dp>) to the trailing Icon (the
ImageVector.vectorResource(R.drawable.ic_24_arrow_up_right) Icon) so the arrow
renders at the design-specified size; adjust values to match design tokens used
elsewhere (e.g., 20.dp or 24.dp).
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt-36-80 (1)

36-80: ⚠️ Potential issue | 🟡 Minor

두 빈 상태 컴포넌트 모두 수직 중앙 정렬이 누락되어 있습니다

SearchEmptyContentNoSearchResultContent 모두 fillMaxSize()를 사용하지만, 수직 방향으로 콘텐츠를 중앙에 배치하는 설정이 없습니다. 결과적으로 아이콘과 텍스트가 화면 상단에 붙어 렌더링됩니다.

  • SearchEmptyContent: Arrangement.spacedBy(16.dp)Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
  • NoSearchResultContent: verticalArrangement = Arrangement.Center 추가
🐛 수정 제안
 `@Composable`
 internal fun SearchEmptyContent() {
     Column(
         modifier = Modifier.fillMaxSize(),
         horizontalAlignment = Alignment.CenterHorizontally,
-        verticalArrangement = Arrangement.spacedBy(16.dp),
+        verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
     ) {
 `@Composable`
 internal fun NoSearchResultContent() {
     Column(
         modifier = Modifier.fillMaxSize(),
         horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
     ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt`
around lines 36 - 80, SearchEmptyContent과 NoSearchResultContent에서 Column이 수직 중앙
정렬되지 않아 화면 상단에 붙어 표시됩니다; SearchEmptyContent의 Column에서
Arrangement.spacedBy(16.dp)를 Arrangement.spacedBy(16.dp,
Alignment.CenterVertically)로 바꾸고, NoSearchResultContent의 Column에
verticalArrangement = Arrangement.Center를 추가하여 두 컴포넌트 내 콘텐츠가 수직으로 중앙에 배치되도록
수정하세요 (참조: 함수 이름 SearchEmptyContent, NoSearchResultContent 및 해당 Column 선언).
core/ui/src/main/res/drawable/ic_140_serach.xml-44-53 (1)

44-53: ⚠️ Potential issue | 🟡 Minor

strokeAlpha 단독 사용 — strokeColor/strokeWidth 미정의로 인해 무효 속성

두 path(Lines 44-48, 49-53) 모두 android:strokeAlpha="0.5"를 선언하고 있으나 android:strokeColorandroid:strokeWidth가 없습니다. Android VectorDrawable은 스트로크가 정의되지 않은 상태에서 strokeAlpha를 무시하므로, 해당 속성은 현재 아무 효과도 없는 dead code입니다. 의도가 스트로크를 그리는 것이었다면 strokeColor/strokeWidth를 추가하고, 그렇지 않다면 strokeAlpha를 제거하세요.

🛠️ strokeAlpha 제거 제안 (스트로크 의도가 없을 경우)
     <path
         android:pathData="M70.44,50.35C61.86,53.95 56.53,65.45 60.38,74.6C64.22,83.76 75.79,87.1 84.37,83.5C92.94,79.9 98.78,68.22 94.94,59.07C91.09,49.91 79.02,46.75 70.44,50.35Z"
-        android:strokeAlpha="0.5"
         android:fillColor="#F6F6F6"
         android:fillAlpha="0.5"/>
     <path
         android:pathData="M77.11,85.15C74.89,85.15 ..."
-        android:strokeAlpha="0.5"
         android:fillColor="#F6F6F6"
         android:fillAlpha="0.5"/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/ui/src/main/res/drawable/ic_140_serach.xml` around lines 44 - 53, The
two <path> elements (identify by their android:pathData values
"M70.44,50.35C61.86,53.95 56.53,65.45 60.38,74.6C64.22,83.76 75.79,87.1
84.37,83.5C92.94,79.9 98.78,68.22 94.94,59.07C91.09,49.91 79.02,46.75
70.44,50.35Z" and "M77.11,85.15C74.89,85.15 72.65,84.79 70.52,84.05...") declare
android:strokeAlpha="0.5" without any strokeColor or strokeWidth, so strokeAlpha
is ignored; either remove the strokeAlpha attributes from those <path> elements
if no stroke was intended, or add explicit android:strokeColor and
android:strokeWidth to actually render a semi-transparent stroke (set
strokeColor with desired color and strokeWidth in dp), then remove or keep
strokeAlpha as appropriate.
core/ui/src/main/res/drawable/ic_140_serach.xml-1-5 (1)

1-5: ⚠️ Potential issue | 🟡 Minor

파일명 오타 수정 필요: serachsearch

파일명이 ic_140_serach.xml로 되어 있어 이미 코드베이스 2곳에서 R.drawable.ic_140_serach로 참조되고 있습니다 (feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt 42줄, 62줄). 머지 전에 파일명을 ic_140_search.xml로 수정하고 관련 참조도 함께 변경해야 합니다. 머지 후 수정 시 모든 참조 변경이 필요한 breaking change가 발생합니다.

추가로 46줄과 51줄의 strokeAlpha="0.5" 속성은 strokeColorstrokeWidth가 없어 Android에서 무시되므로 제거하는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/ui/src/main/res/drawable/ic_140_serach.xml` around lines 1 - 5, Rename
the drawable file ic_140_serach.xml to ic_140_search.xml and update all usages
of R.drawable.ic_140_serach to R.drawable.ic_140_search (e.g., the references in
SearchComponents.kt) to avoid broken resource lookups; also remove the redundant
strokeAlpha="0.5" attributes from the vector drawable (they have no effect
without strokeColor/strokeWidth) so the XML contains only valid stroke-related
attributes.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryViewModel.kt-195-203 (1)

195-203: ⚠️ Potential issue | 🟡 Minor

reduce 블록 내에서 state.value 대신 this(현재 상태)를 사용해야 합니다.

Line 199에서 state.value.searchResults를 사용하고 있지만, reduce 람다의 리시버(this)는 현재 업데이트 대상인 상태 객체입니다. state.value는 동시 업데이트가 있을 경우 이미 변경된 값을 읽을 수 있어 불일치가 발생할 수 있습니다.

🔧 수정 제안
     private fun focusSearch() {
         reduce {
             copy(
                 isSearchFocused = true,
-                isSearched = state.value.searchResults.isNotEmpty(),
+                isSearched = searchResults.isNotEmpty(),
                 selectedPlaceDetail = null,
             )
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryViewModel.kt`
around lines 195 - 203, In focusSearch(), the reduce lambda must read the
current state's fields via the lambda receiver instead of accessing state.value;
replace state.value.searchResults.isNotEmpty() with
this.searchResults.isNotEmpty() (or just searchResults.isNotEmpty()) so the
copy(...) sets isSearched based on the receiver state, keeping isSearchFocused
and selectedPlaceDetail updates the same.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryContract.kt-16-51 (1)

16-51: ⚠️ Potential issue | 🟡 Minor

검색 상태 파생 프로퍼티에 UI 갭이 있습니다.

showSearchEmptyAreaisSearchFocused && searchResults.isEmpty()로 정의되어 있으므로, 사용자가 검색어를 입력한 순간 true가 됩니다. 그러나 API 응답 전까지는:

  • showSearchEmptyContent = false (keyword.isNotEmpty()이므로)
  • showNoSearchResultContent = false (isSearched = false이므로)

이 때문에 AddItineraryScreen.kt의 138-143번 줄에서 Box 내부에 렌더링될 콘텐츠가 없어 빈 화면이 표시됩니다. 300ms 지연이 있지만 사용자 경험상 입력 중 빈 영역이 깜박일 수 있으므로, 로딩 상태 추가 또는 상태 조건 재정의를 고려해주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/AddItineraryContract.kt`
around lines 16 - 51, The derived UI shows an empty box during typing because
showSearchEmptyArea is true as soon as isSearchFocused even before API returns;
update AddItineraryState to either add a search-loading flag (e.g.,
isSearchLoading: Boolean) and exclude the empty area while loading, or simplest:
require isSearched for showSearchEmptyArea (change its getter to isSearchFocused
&& isSearched && searchResults.isEmpty()), and adjust
showSearchEmptyContent/showNoSearchResultContent logic accordingly so the Box
only renders content when results/state are settled; update callers that set
isSearched or set the new flag (e.g., where search starts/finishes) to reflect
loading.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryTopBar.kt-82-90 (1)

82-90: ⚠️ Potential issue | 🟡 Minor

인터랙티브 아이콘에 접근성 contentDescription이 누락되어 있습니다.

뒤로 가기 버튼(Line 84)과 검색 아이콘(Line 155) 모두 클릭 가능한 요소이지만 contentDescription = null로 설정되어 있어 TalkBack 사용자가 해당 버튼의 역할을 파악할 수 없습니다.

♿ 수정 제안
 Icon(
     imageVector = ImageVector.vectorResource(R.drawable.ic_28_chevron_left),
-    contentDescription = null,
+    contentDescription = stringResource(R.string.content_description_back),
     tint = NDGLTheme.colors.black600,
     ...
 )
 Icon(
     imageVector = ImageVector.vectorResource(R.drawable.ic_28_search),
-    contentDescription = null,
+    contentDescription = stringResource(R.string.content_description_search),
     tint = NDGLTheme.colors.black600,
     ...
 )

Also applies to: 153-163

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryTopBar.kt`
around lines 82 - 90, The interactive Icon composables in AddItineraryTopBar
(the back button that calls clickBackButton() and the search icon) are missing
accessible descriptions; replace contentDescription = null with meaningful
descriptions using string resources via stringResource(...) (e.g., R.string.back
or R.string.search) so TalkBack can announce their purpose, and add the
necessary import for androidx.compose.ui.res.stringResource; ensure you update
both the back Icon and the search Icon (the ones invoking clickBackButton() and
the search click handler) to use these stringResource values.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryTopBar.kt-142-150 (1)

142-150: ⚠️ Potential issue | 🟡 Minor

placeholder 노출 조건이 keyword 대신 textFieldValue.text를 기준으로 해야 합니다.

onValueChange에서 textFieldValue는 동기적으로 업데이트되지만, updateKeyword(newValue.text)는 ViewModel을 통해 비동기로 전파됩니다. 첫 글자를 입력하는 순간, textFieldValue.text = "a"임에도 keyword가 아직 ""인 중간 recompose 시점에 placeholder가 잠깐 표시될 수 있습니다.

🐛 수정 제안
 decorationBox = { innerTextField ->
-    if (keyword.isEmpty()) {
+    if (textFieldValue.text.isEmpty()) {
         Text(
             text = stringResource(R.string.add_itinerary_search_placeholder),
             style = NDGLTheme.typography.bodyLgRegular,
             color = NDGLTheme.colors.black400,
         )
     }
     innerTextField()
 },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryTopBar.kt`
around lines 142 - 150, The placeholder visibility in AddItineraryTopBar's
decorationBox should be based on the local TextField state instead of the
ViewModel keyword to avoid flicker; change the condition from using keyword to
using textFieldValue.text (the value updated synchronously in onValueChange) so
the placeholder is shown only when textFieldValue.text.isEmpty(); update the
check inside decorationBox (where innerTextField() is invoked) and keep
onValueChange calling updateKeyword(newValue.text) as-is.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryTopBar.kt-57-61 (1)

57-61: ⚠️ Potential issue | 🟡 Minor

keyword 동기화 시 커서 위치(selection)를 함께 리셋해야 합니다.

copy(text = keyword)는 기존 selection을 그대로 유지합니다. 예를 들어 사용자가 "hello"를 입력해 TextRange(5)가 된 상태에서 ViewModel이 keyword""로 초기화하면, TextFieldValue("", selection = TextRange(5))처럼 텍스트 범위를 벗어난 selection이 생성됩니다. Compose가 내부적으로 클램핑하더라도, 명시적으로 selection을 재설정하는 것이 안전합니다.

🐛 수정 제안
 LaunchedEffect(keyword) {
     if (textFieldValue.text != keyword) {
-        textFieldValue = textFieldValue.copy(text = keyword)
+        textFieldValue = textFieldValue.copy(
+            text = keyword,
+            selection = TextRange(keyword.length),
+        )
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryTopBar.kt`
around lines 57 - 61, When syncing keyword inside AddItineraryTopBar's
LaunchedEffect(keyword), avoid copying only text into textFieldValue because
copy(text = keyword) preserves the old selection; instead reset the selection to
a valid position (e.g., collapsed at keyword.length) when you update
textFieldValue so selection won't point outside the new text. Update the
LaunchedEffect block that compares textFieldValue.text and keyword to assign
textFieldValue with both text and a safe selection (use
TextRange(keyword.length) or equivalent) rather than only copy(text = keyword).
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/component/AddPlacePhotoTab.kt-40-42 (1)

40-42: ⚠️ Potential issue | 🟡 Minor

photo.aspectRatio가 0 또는 무한대일 경우 런타임 크래시 발생 가능성

Compose의 aspectRatio() modifier는 양수 값을 요구합니다. API 응답의 widthPx 또는 heightPx가 0이면 aspectRatio가 0 또는 무한대가 되어 IllegalArgumentException이 발생합니다.

PlacePhoto 생성 시(AddPlaceViewModel.kt:80, AddItineraryViewModel.kt:274 등) 유효성 검사를 추가하거나, aspectRatio 계산에서 기본값을 제공해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/component/AddPlacePhotoTab.kt`
around lines 40 - 42, The aspectRatio modifier in AddPlacePhotoTab.kt uses
photo.aspectRatio which can be 0 or infinite if widthPx/heightPx are 0, causing
a runtime crash; fix by ensuring a positive finite aspect ratio before passing
to Modifier.aspectRatio—compute a safeAspectRatio (e.g., if photo.widthPx>0 and
photo.heightPx>0 then widthPx.toFloat()/heightPx else a default like 1f) and use
that in Modifier.aspectRatio(safeAspectRatio); additionally add validation when
constructing PlacePhoto in AddPlaceViewModel and AddItineraryViewModel to
prevent widthPx/heightPx from being zero or to fallback to the default aspect
ratio.
core/ui/src/main/res/values/strings.xml-116-124 (1)

116-124: ⚠️ Potential issue | 🟡 Minor

사용되지 않는 문자열 리소스를 제거하세요.

다음 문자열이 코드에서 참조되지 않습니다:

  • add_itinerary_chip_day_format (Line 119)
  • add_itinerary_chip_recently_saved (Line 120)

실제로 사용되는 키는 add_itinerary_chip_recommendedadd_itinerary_chip_recent입니다. 사용되지 않는 리소스를 정리하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/ui/src/main/res/values/strings.xml` around lines 116 - 124, Remove the
two unused string resources add_itinerary_chip_day_format and
add_itinerary_chip_recently_saved from strings.xml; keep the actual used keys
add_itinerary_chip_recommended and add_itinerary_chip_recent, and verify no code
references remain (search for add_itinerary_chip_day_format and
add_itinerary_chip_recently_saved before committing).
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/component/AddPlaceTabRow.kt-32-38 (1)

32-38: ⚠️ Potential issue | 🟡 Minor

SecondaryTabRow는 이 프로젝트의 Compose BOM 버전(2026.01.00)에서 @OptIn(ExperimentalMaterial3Api::class) 어노테이션이 필요합니다.

함수 또는 파일 레벨에 @OptIn(ExperimentalMaterial3Api::class)를 추가하세요. 다른 TabRow 관련 컴포넌트들(SearchedPlaceBottomSheet, AddItineraryBottomSheet 등)에서도 동일한 패턴으로 사용하고 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/component/AddPlaceTabRow.kt`
around lines 32 - 38, Add the `@OptIn`(ExperimentalMaterial3Api::class) annotation
at the top of the composable or file that contains SecondaryTabRow usage so the
call to SecondaryTabRow(selectedTabIndex = selectedIndex, modifier =
Modifier.fillMaxWidth(), ...) compiles under the project Compose BOM; you can
annotate the composable function that renders this UI (or the file) just like
other TabRow components (e.g., SearchedPlaceBottomSheet,
AddItineraryBottomSheet) to keep the pattern consistent.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryBottomSheet.kt-131-131 (1)

131-131: ⚠️ Potential issue | 🟡 Minor

sheetState.anchoredDraggableState.offset을 Spacer 높이로 사용 시 NaN 크래시 가능성.

offsetNaN일 경우 toDp()NaN dp를 반환하며, Modifier.height(NaN.dp)는 예측 불가능한 동작을 유발합니다. 방어적으로 처리해주세요.

🛡️ NaN 방어 처리 제안
-            Spacer(modifier = Modifier.height(with(density) { sheetState.anchoredDraggableState.offset.toDp() }))
+            val offsetDp = with(density) {
+                val offset = sheetState.anchoredDraggableState.offset
+                if (offset.isNaN() || offset < 0f) 0.dp else offset.toDp()
+            }
+            Spacer(modifier = Modifier.height(offsetDp))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryBottomSheet.kt`
at line 131, In AddItineraryBottomSheet.kt, guard against NaN when using
sheetState.anchoredDraggableState.offset for the Spacer height: compute a finite
fallback (e.g., 0f) if offset.isNaN() or !isFinite() and then convert that
finite float to Dp with with(density) { ...toDp() }; replace direct use of
sheetState.anchoredDraggableState.offset in Spacer(modifier =
Modifier.height(...)) with the validated/coerced value so Modifier.height never
receives NaN.dp.
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchedPlaceBottomSheet.kt-256-256 (1)

256-256: ⚠️ Potential issue | 🟡 Minor

anchoredDraggableState.offset이 초기화 전에 NaN일 수 있습니다.

AnchoredDraggableStateoffset은 앵커가 아직 설정되기 전에 Float.NaN을 반환할 수 있습니다. 이 경우 NaN.toDp()를 사용하면 레이아웃에서 예기치 않은 동작이 발생할 수 있습니다.

🛡️ 제안하는 수정
-            Spacer(modifier = Modifier.height(with(density) { sheetState.anchoredDraggableState.offset.toDp() }))
+            val offsetDp = with(density) {
+                val offset = sheetState.anchoredDraggableState.offset
+                if (offset.isNaN()) 0.dp else offset.toDp()
+            }
+            Spacer(modifier = Modifier.height(offsetDp.coerceAtLeast(0.dp)))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchedPlaceBottomSheet.kt`
at line 256, The Spacer uses sheetState.anchoredDraggableState.offset directly
and that offset may be Float.NaN before anchors are set; guard against
NaN/Infinite before calling toDp by reading
sheetState.anchoredDraggableState.offset into a local, check offset.isFinite()
(or !offset.isNaN()) and only convert to Dp when finite, otherwise use a safe
fallback (e.g., 0.dp or a minimum value); update the Spacer to use that safe Dp
value so NaN.toDp() is never called (references: SearchedPlaceBottomSheet,
sheetState, anchoredDraggableState.offset, Spacer).
feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchedPlaceBottomSheet.kt-439-471 (1)

439-471: ⚠️ Potential issue | 🟡 Minor

클릭 핸들러가 두 번 호출될 수 있습니다.

SearchedPlaceInfoRow에서 onClick이 null이 아닌 경우, 행 전체에 noRippleClickable(라인 448)이 적용되고, 동시에 우측 chevron 아이콘에도 별도의 clickable(라인 464)이 적용됩니다. 사용자가 chevron 아이콘을 탭하면 이벤트 버블링으로 인해 onClick이 두 번 호출될 수 있습니다.

🐛 제안하는 수정

chevron 아이콘의 별도 클릭 핸들러를 제거하거나, 행 전체의 클릭을 제거하고 chevron만 클릭 가능하게 하세요:

         if (onClick != null) {
             Icon(
                 modifier = Modifier
-                    .size(24.dp)
-                    .clip(CircleShape)
-                    .clickable { onClick() },
+                    .size(24.dp),
                 imageVector = ImageVector.vectorResource(R.drawable.ic_24_chevron_right),
                 tint = NDGLTheme.colors.black600,
                 contentDescription = null,
             )
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchedPlaceBottomSheet.kt`
around lines 439 - 471, SearchedPlaceInfoRow applies onClick both to the whole
Row (via noRippleClickable) and again to the chevron Icon (clickable), which can
cause the handler to fire twice; fix by removing the chevron's separate
clickable when onClick is provided (or alternatively remove the Row-level
noRippleClickable and keep only the chevron clickable) so that onClick is only
invoked once—update the logic around noRippleClickable, the onClick parameter,
and the chevron Icon to ensure only a single click target invokes onClick.
data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt-27-29 (1)

27-29: ⚠️ Potential issue | 🟡 Minor

빈 API 키로 초기화 시 런타임에서야 실패합니다

BuildConfig.PLACE_API_KEY가 빈 문자열인 경우(예: CI/CD 환경 또는 local.properties 키 누락), Places.initializeWithNewPlacesApiEnabled는 초기화를 성공적으로 완료하지만 실제 API 호출 시점에 인증 오류가 발생합니다. 앱 시작 시 즉시 실패하도록 검증을 추가하면 디버깅이 훨씬 쉬워집니다.

🛡️ 빠른 실패(fail-fast) 검증 추가 제안
     if (!Places.isInitialized()) {
+        require(BuildConfig.PLACE_API_KEY.isNotEmpty()) {
+            "PLACE_API_KEY is not configured. Check local.properties."
+        }
         Places.initializeWithNewPlacesApiEnabled(context, BuildConfig.PLACE_API_KEY)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt`
around lines 27 - 29, Before calling Places.initializeWithNewPlacesApiEnabled,
validate BuildConfig.PLACE_API_KEY is non-blank and fail-fast if it is empty:
check BuildConfig.PLACE_API_KEY (used where Places.isInitialized() and
Places.initializeWithNewPlacesApiEnabled are called) and if blank, log an
explicit error (or throw an IllegalStateException) with a clear message about
the missing Places API key so initialization does not silently succeed and later
API calls fail; keep the existing Places.isInitialized() guard and only call
initializeWithNewPlacesApiEnabled when the key is valid.
data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt-23-28 (1)

23-28: ⚠️ Potential issue | 🟡 Minor

sessionToken 의 동시 접근이 스레드 안전하지 않습니다

sessionToken@Volatile 없이 선언된 var이며, suspend 함수에서 null 체크 후 할당하는 패턴이 원자적이지 않습니다. 두 코루틴이 동시에 searchKeyword()를 호출하면 둘 다 null을 보고 각자 다른 토큰을 생성하여 청구 세션이 분리될 수 있습니다. @Volatile을 추가하거나, lazy initialization에 synchronized를 사용하세요.

🔒 `@Volatile` 추가 제안
-    private var sessionToken: AutocompleteSessionToken? = null
+    `@Volatile`
+    private var sessionToken: AutocompleteSessionToken? = null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt`
around lines 23 - 28, The sessionToken field in PlaceRepository is not
thread-safe: multiple coroutines calling searchKeyword(...) can observe null and
create multiple AutocompleteSessionToken instances; make sessionToken volatile
(add `@Volatile` to the var sessionToken: AutocompleteSessionToken? declaration)
or perform a synchronized/lazy initialization inside searchKeyword (wrap the
null-check and assignment around AutocompleteSessionToken.newInstance() in a
synchronized block or use Kotlin's lazy/threadSafe construct) so only one token
is created and shared safely across coroutines.

Comment thread data/travel/build.gradle.kts
Comment thread data/travel/build.gradle.kts
@mj010504 mj010504 merged commit 0030134 into develop Feb 18, 2026
2 checks passed
@mj010504 mj010504 deleted the design/NDGL-66 branch February 18, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant