Skip to content

[feat] Feed UX 개선 - 카테고리 필터, 이미지 피커, 툴팁 등#88

Open
DongChyeon wants to merge 39 commits intodevelopfrom
feature/#86-feed-improvement
Open

[feat] Feed UX 개선 - 카테고리 필터, 이미지 피커, 툴팁 등#88
DongChyeon wants to merge 39 commits intodevelopfrom
feature/#86-feed-improvement

Conversation

@DongChyeon
Copy link
Copy Markdown
Member

@DongChyeon DongChyeon commented Apr 15, 2026

🛠 Related issue

closed #86

어떤 변경사항이 있었나요?

  • ✨ Feature Feature
  • 🎨 Design Markup & styling

✅ CheckPoint

  • PR 컨벤션에 맞게 작성했습니다. (필수)
  • merge할 브랜치의 위치를 확인해 주세요(main❌/develop⭕) (필수)
  • Approve된 PR은 assigner가 머지하고, 수정 요청이 온 경우 수정 후 다시 push를 합니다. (필수)

✏️ Work Description

피드 목록 피드 없을 때 피드 업로드
image image image

홈 피드

  • Feed API v2 마이그레이션: 다중 이미지 및 동적 aspect ratio(1:1 / 4:5) 지원
  • 카테고리 필터 칩: 전체 칩 추가 및 API 기반 다중 선택 필터링 적용
  • 칩 선택 시 중앙 스크롤: FilterChipRow에서 선택된 칩이 LazyRow 중앙으로 애니메이션 스크롤
  • 고정 헤더: HomeTopBar/Tab을 스크롤과 무관하게 상단 고정
  • OptionSheet dim 처리: ViewModel State로 이관
  • OptionSheet 개선: 스크롤 가능한 경우에만 하단 여백 및 그라디언트 표시

FeedCard

  • 툴팁 dismiss: 상품 링크 툴팁이 카드 아무 영역이나 탭 시 dismiss (PointerEventPass.Initial 활용)
  • 빈 제목 처리: 제목이 비어있을 때 제목 영역 및 하단 여백 미노출

업로드

  • 이미지 피커 교체: GetMultipleContentsPickMultipleVisualMedia(maxItems = 3)로 OS 레벨 장수 제한
  • 중복 선택 방지: 이미 추가된 URI 필터링
  • 초과/중복 안내 스낵바: 최대 장수 초과 시 "최대 3장까지 추가할 수 있어요", 중복 시 "이미 추가된 사진은 제외됐어요"

내 피드 빈 화면

  • img_my_feed_empty 이미지 추가 및 BuyOrNotImgs.MyFeedEmpty 등록
  • 빈 피드 상태 UI 구현 ("첫번째 투표를 올려보세요!" + 투표 등록하기 버튼)
  • empty 상태일 때 FAB 숨김 처리

😅 Uncompleted Tasks

  • N/A

📢 To Reviewers

  • PointerEventPass.Initial로 툴팁 dismiss를 구현한 부분 (FeedCard.kt) 확인 부탁드립니다. 자식 클릭 이벤트를 consume하지 않고 관찰만 하는 방식입니다.

Summary by CodeRabbit

Release Notes

  • 새로운 기능
    • 피드에 다중 이미지 갤러리 추가 - 각 피드에서 여러 이미지를 슬라이드로 확인 가능
    • 상품 링크 클릭 기능 추가 - 피드의 상품 정보 페이지로 이동 가능
    • 카테고리별 필터링 - 관심 있는 카테고리의 피드만 표시
    • 투표 상태별 정렬 - 진행 중 또는 종료된 투표로 필터링
    • 업로드 개선 - 최대 3개 이미지, 상품명, 상품 링크 추가 지원

DongChyeon and others added 30 commits April 12, 2026 19:23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…로 이관

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DongChyeon DongChyeon changed the title [feat] #86 Feed UX 개선 - 카테고리 필터, 이미지 피커, 툴팁 등 [feat] Feed UX 개선 - 카테고리 필터, 이미지 피커, 툴팁 등 Apr 15, 2026
@DongChyeon DongChyeon self-assigned this Apr 15, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

Walkthrough

이 PR은 피드 시스템을 다중 이미지 지원, 제목/링크 메타데이터, 클라이언트 측 카테고리 필터링, 정렬 기능을 추가하여 고도화합니다. API v2 마이그레이션, 데이터 모델 재구성, FeedCard 및 HomeScreen 전반적인 UI 변경, 업로드 플로우 개선을 포함합니다.

Changes

Cohort / File(s) Summary
Navigation & WebView
app/src/main/.../BuyOrNotNavHost.kt, feature/home/navigation/HomeNavigation.kt, feature/notification/navigation/NotificationNavigation.kt, core/ui/webview/WebViewNavigation.kt
onLinkClick 콜백 파라미터 추가 및 WebView 네비게이션 처리; WebViewNavigation 헬퍼를 공개(public) API로 변경
Domain Models
domain/model/Feed.kt, domain/repository/FeedRepository.kt
FeedImage 도메인 모델 신규 추가; Feed에서 단일 이미지(s3ObjectKey, viewUrl, imageWidth, imageHeight) → 다중 이미지(images: List<FeedImage>) 변경; title, productLink 필드 추가; getFeedListcategory 필터링 파라미터 추가
Network & DTOs
core/network/api/FeedApiService.kt, core/network/dto/request/FeedRequest.kt, core/network/dto/response/FeedListResponse.kt
API 엔드포인트 v1 → v2 업그레이드; FeedImageDto 신규 추가; FeedItemDto에서 단일 이미지 → images: List<FeedImageDto> 변경; title, link 필드 추가; FeedRequestFeedImageRequest 리스트 및 선택적 메타데이터 추가
Design System Components
core/designsystem/components/FeedCard.kt, core/designsystem/components/OptionSheet.kt, core/designsystem/shape/TopArrowBubbleShape.kt, core/designsystem/icon/BuyOrNotIcons.kt, core/designsystem/icon/BuyOrNotImgs.kt, core/designsystem/res/drawable/ic_link.xml, core/designsystem/res/drawable/ic_sort.xml
FeedCard API를 다중 이미지 캐러셀(HorizontalPager), 제목, 링크 버튼, 페이지 인디케이터 지원으로 재구성; OptionSheet에 스크롤 오버플로우 감지 및 조건부 padding/그래디언트 적용; 새로운 아이콘(ic_sort, ic_link) 및 이미지 리소스(MyFeedEmpty) 추가; TopArrowBubbleShape 신규 추가(텍스트 풍선 모양)
Home Feature
feature/home/ui/HomeContract.kt, feature/home/ui/HomeScreen.kt, feature/home/ui/HomeViewModel.kt
FeedItem에서 단일 이미지 → 다중 이미지 변경; title, productLink 필드 추가; HomeUiStateallFeeds, selectedCategories, showSortSheet 상태 추가; 카테고리 토글 및 정렬 인텐트 신규 추가; UI를 filter chip row(정렬 버튼 + 카테고리 다중선택), 다중 카테고리 필터링 로직, 빈 피드 상태 UI로 개편; ViewModel에 클라이언트 측 카테고리 필터링(applyCategories) 및 상태 동기화 로직 추가
Notification Feature
feature/notification/ui/NotificationDetailScreen.kt
onLinkClick 콜백 추가; FeedCard 다중 이미지 및 메타데이터 매핑 업데이트; 링크 텍스트풍선(tooltip) 상태 관리
Upload Feature
feature/upload/ui/UploadContract.kt, feature/upload/ui/UploadScreen.kt, feature/upload/ui/UploadViewModel.kt, feature/upload/util/LinkValidator.kt, feature/upload/util/LinkValidatorTest.kt, feature/upload/navigation/UploadNavigation.kt, feature/upload/build.gradle.kts
SelectedImageUriselectedImageUris: List<Uri>(최대 3개) 변경; title, link 필드 입력 UI 추가; AddImages, RemoveImage 인텐트 신규 추가; 다중 이미지 피커(PickMultipleVisualMedia) 구현; 링크 검증 유틸(LinkValidator) 신규 추가 및 단위 테스트; 제출 시 모든 이미지 업로드 및 FeedImage 리스트 구성
Documentation
docs/superpowers/plans/2026-04-13-filter-sort.md, docs/superpowers/specs/2026-04-13-filter-sort-design.md
카테고리 필터링 및 정렬 기능의 설계 및 구현 계획 문서화

Sequence Diagram

sequenceDiagram
    actor User
    participant HomeUI as HomeScreen<br/>(UI)
    participant HomeVM as HomeViewModel
    participant FeedRepo as FeedRepository
    participant FeedAPI as FeedApiService
    participant WebNav as WebView<br/>Navigation

    User->>HomeUI: 피드 화면 로드
    HomeUI->>HomeVM: 초기 로드 요청
    HomeVM->>FeedRepo: getFeedList() 호출
    FeedRepo->>FeedAPI: GET /api/v2/feeds 요청
    FeedAPI-->>FeedRepo: FeedList 응답 (다중 이미지 포함)
    FeedRepo-->>HomeVM: 응답 맵핑 (allFeeds 설정)
    HomeVM->>HomeVM: applyCategories() 필터링<br/>(selectedCategories 적용)
    HomeVM-->>HomeUI: uiState 업데이트<br/>(feeds 렌더링)
    HomeUI->>User: 피드 목록 표시<br/>(이미지 캐러셀, 제목, 링크 버튼)

    User->>HomeUI: 카테고리 칩 선택
    HomeUI->>HomeVM: OnCategoryToggled(category) 인텐트
    HomeVM->>HomeVM: selectedCategories 토글<br/>applyCategories() 재적용
    HomeVM-->>HomeUI: 필터된 feeds 업데이트
    HomeUI->>User: 필터된 피드 목록 갱신

    User->>HomeUI: 제목/이미지 내 링크 클릭
    HomeUI->>HomeUI: onLinkClick(url) 콜백 호출
    HomeUI->>WebNav: navigateToWebView(title, url)
    WebNav-->>User: WebView 화면 네비게이션

    User->>HomeUI: 정렬 버튼 클릭
    HomeUI->>HomeUI: OptionSheet 표시 (showSortSheet=true)
    User->>HomeUI: 정렬 옵션 선택 (FilterChip)
    HomeUI->>HomeVM: OnFilterSelected(filterChip) 인텐트
    HomeVM->>HomeVM: selectedFilter 업데이트<br/>applyCategories() 재적용
    HomeVM-->>HomeUI: 정렬된 feeds 업데이트
    HomeUI->>User: 정렬된 피드 목록 갱신
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • Imagine-Choi
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경사항인 카테고리 필터, 이미지 피커, 툴팁 등 피드 UX 개선을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed PR이 연결된 이슈 #86의 모든 요구사항을 충족합니다: 최대 3장 사진 첨부, 피드 제목, 피드 링크 기능 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 이슈 #86의 피드 고도화 목표와 연관되어 있으며, 범위를 벗어난 변경사항은 없습니다.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/#86-feed-improvement

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.

@DongChyeon DongChyeon added ✨ FEAT 기능 개발 (애매하면 기능 개발로 두도록 하자) 💪 동현동현동현 labels Apr 15, 2026
@DongChyeon DongChyeon marked this pull request as ready for review April 15, 2026 11:16
@DongChyeon DongChyeon requested a review from Imagine-Choi April 15, 2026 11:21
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: 6

Caution

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

⚠️ Outside diff range comments (1)
core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt (1)

17-23: ⚠️ Potential issue | 🟡 Minor

title 파라미터도 URL 인코딩이 필요합니다.

라인 22에서 url만 인코딩하고 title은 raw 문자열로 전달되어, title&, ?, = 같은 예약 문자가 포함되면 라우트 파싱이 깨질 수 있습니다. Jetpack Navigation Compose는 query parameter 값의 reserved character를 인코딩해야 하며, 디코딩도 함께 처리해야 합니다.

수정 제안
 fun NavController.navigateToWebView(
     title: String,
     url: String,
 ) {
+    val encodedTitle = URLEncoder.encode(title, "UTF-8")
     val encodedUrl = URLEncoder.encode(url, "UTF-8")
-    this.navigate("$WEBVIEW_ROUTE?title=$title&url=$encodedUrl")
+    this.navigate("$WEBVIEW_ROUTE?title=$encodedTitle&url=$encodedUrl")
 }
-        val title = backStackEntry.arguments?.getString("title") ?: ""
+        val title = URLDecoder.decode(backStackEntry.arguments?.getString("title") ?: "", "UTF-8")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt`
around lines 17 - 23, The navigateToWebView function currently only URL-encodes
url, leaving title unencoded which can break route parsing if title contains
reserved characters; update navigateToWebView to also URLEncoder.encode the
title (e.g., encodedTitle = URLEncoder.encode(title, "UTF-8")) and use that
encodedTitle when building the route string (alongside encodedUrl), and ensure
receiving destination decodes both query parameters as needed.
🧹 Nitpick comments (6)
core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt (1)

649-650: 툴팁 문구는 문자열 리소스로 빼는 편이 좋습니다.

새 user-facing 문구가 디자인시스템 코드에 하드코딩되어 있으면 추후 문구 수정과 로컬라이징 대응이 번거로워집니다.

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

In
`@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt`
around lines 649 - 650, The Text composable in FeedCard.kt is hardcoding a
user-facing string; extract "상품 링크를 확인해보세요!" into the Android string resources
and use stringResource(...) in the Text call instead. Add a new entry (e.g.,
name="feed_card_check_product_link") with the Korean text to your strings.xml
(and localized variants as needed), update the Text invocation in FeedCard.kt to
reference stringResource(R.string.feed_card_check_product_link), and ensure you
import androidx.compose.ui.res.stringResource; keep the original semantics and
formatting.
core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt (1)

27-32: KDoc에 category 파라미터 설명도 같이 업데이트해 주세요.

Line 38에서 시그니처는 확장됐지만, 상단 주석에는 category 설명이 없어 유지보수 시 혼동 여지가 있습니다.

Also applies to: 38-38

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

In
`@core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt`
around lines 27 - 32, Update the KDoc for the FeedApiService method that was
extended to accept a category parameter (the method signature in
FeedApiService.kt now includes category) by adding an `@param` category entry
describing its purpose, allowed values/format (e.g., category id or name), and
behavior when omitted (e.g., returns all categories); ensure the new `@param` line
is consistent with existing `@param` style and placed alongside
cursor/size/feedStatus entries in the method's KDoc.
docs/superpowers/specs/2026-04-13-filter-sort-design.md (1)

51-53: 펜스 코드 블록에 언어 지정 누락

마크다운 린터가 펜스 코드 블록에 언어가 지정되지 않았음을 경고하고 있습니다. ASCII 레이아웃 다이어그램이라면 text 또는 plaintext를 사용하세요.

📝 제안된 수정
-```
+```text
 [ 정렬아이콘(ic_sort) ] [ 패션∙잡화 ] [ 명품∙프리미엄 ] [ 화장품∙뷰티 ] ...
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/superpowers/specs/2026-04-13-filter-sort-design.md around lines 51 -
53, The fenced code block containing the ASCII layout ("[ 정렬아이콘(ic_sort) ] [
패션∙잡화 ] ...") is missing a language tag; update the fence to include a plain
text language (e.g., use "text" or "plaintext") so the markdown linter stops
warning. Locate the code fence in the spec file (the block shown in the diff)
and prepend the opening backticks with the language token (for example text) and keep the closing unchanged.


</details>

</blockquote></details>
<details>
<summary>feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt (1)</summary><blockquote>

`559-561`: **displayName 문자열 비교 대신 enum 직접 비교 권장**

`displayName` 문자열 비교는 향후 표시명이 변경되거나 중복될 경우 필터링이 실패할 수 있습니다. `FeedItem`에 `FeedCategory` enum 자체를 저장하고 enum identity로 비교하는 것이 더 안전합니다.

현재 구조상 `FeedItem.category`가 `String`이므로, 역방향 조회가 필요하다면 `FeedCategory.entries.find { it.displayName == feed.category }`로 먼저 변환 후 비교하거나, `FeedItem`에 `FeedCategory` 타입 필드를 추가하는 것을 고려해주세요.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In
`@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt`
around lines 559 - 561, The filter currently compares FeedItem.category (a
String) against FeedCategory.displayName strings which is fragile; change to
compare enum identities by either converting the string to the enum first (use
FeedCategory.entries.find { it.displayName == feed.category } and then compare
to the selected categories) or, better, update FeedItem to hold a FeedCategory
field and filter directly (e.g., feeds.filter { feed -> categories.any { it ==
feed.category } }). Ensure references to FeedItem.category and
FeedCategory.entries/find are updated accordingly.
```

</details>

</blockquote></details>
<details>
<summary>feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt (2)</summary><blockquote>

`349-353`: **툴팁 상태가 피드 변경 시 리셋되지 않음**

`showLinkTooltip`이 `true`로 초기화된 후 한 번 dismiss되면 `filteredFeeds`가 변경되어도 다시 표시되지 않습니다. 새로고침이나 탭 전환 후에도 툴팁이 다시 표시되어야 한다면, `remember` 키에 관련 상태를 추가하거나 별도 로직이 필요할 수 있습니다.

현재 동작이 의도된 것이라면 무시해도 됩니다.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In
`@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt`
around lines 349 - 353, The tooltip visibility (showLinkTooltip) is only
remembered once and won't reset when filteredFeeds changes; update the state so
it resets on feed changes by including filteredFeeds in the remember key or by
adding a small effect that sets showLinkTooltip = true when filteredFeeds
changes (mirror the existing remember(filteredFeeds) usage used for
tooltipTargetIndex) so the tooltip reappears after refresh/tab change; adjust
the declaration/initialization of showLinkTooltip in HomeScreen.kt accordingly
and reference showLinkTooltip, filteredFeeds and tooltipTargetIndex when making
the change.
```

</details>

---

`546-559`: **scrollToCenter에서 아이템이 아직 레이아웃되지 않은 경우 처리**

`scrollToCenter` 함수는 `visibleItemsInfo`에서 해당 인덱스를 찾지 못하면 `animateScrollToItem(index)`로 폴백합니다. 이 접근은 합리적이지만, 첫 번째 스크롤 후 아이템이 중앙에 오지 않을 수 있습니다. 필요시 스크롤 완료 후 다시 중앙 정렬을 시도하는 로직을 추가할 수 있습니다.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In
`@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt`
around lines 546 - 559, scrollToCenter currently falls back to
listState.animateScrollToItem(index) when the target isn't in visibleItemsInfo,
but that can leave the item off-center after layout; modify scrollToCenter so
that after calling listState.animateScrollToItem(index) you wait for the new
layout (e.g., suspend briefly or read listState.layoutInfo again) and then
compute the item's center using listState.layoutInfo.visibleItemsInfo and call
listState.animateScrollBy(...) to center it; implement one retry (or small
delay) if the item still isn't found to ensure the item is properly centered
after layout changes.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In
@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt:

  • Around line 315-329: The bug is that currentAspectRatio (from
    imageAspectRatios.getOrElse(pagerState.currentPage)) is reused for every page,
    causing non-active pages to render at the wrong aspect; change the
    HorizontalPager item to use a page-specific ratio by calling
    imageAspectRatios.getOrElse(page) inside the pager lambda for the inner Box, and
    if you want the overall card container to animate by the current page height
    keep a separate computed containerAspectRatio (based on pagerState.currentPage)
    applied to the outer Box (with animateContentSize) while each page's inner Box
    uses its own aspectRatio retrieved by page; update references to
    currentAspectRatio, imageAspectRatios, pagerState, and the Box inside the
    HorizontalPager accordingly.
  • Around line 368-379: The current guard only checks productLink != null which
    still allows empty or whitespace strings to render LinkButton and open an
    invalid WebView; change the condition to filter out blank links (use
    productLink.isNullOrBlank() or !productLink.isNullOrBlank()) so the LinkButton
    block (LinkButton, onLinkClick usage) only runs when productLink contains a
    non-blank URL (and pass the non-blank string into onLinkClick).

In
@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/shape/TopArrowBubbleShape.kt:

  • Around line 41-44: The computed arrowCenterX (currently size.width -
    arrowOffsetPx used before moveTo/lineTo) can fall outside the tooltip bounds
    when arrowOffsetFromRight or tooltip width varies; clamp arrowCenterX to the
    valid range [arrowWidthPx/2, size.width - arrowWidthPx/2] before calling
    moveTo/lineTo to ensure the arrow isn't drawn outside the shape. Update the
    calculation in TopArrowBubbleShape (where arrowCenterX is defined) to clamp the
    value and then use that clamped arrowCenterX in the existing moveTo and lineTo
    calls.

In
@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt:

  • Around line 174-185: handleCategoryToggled currently calls loadFeeds causing
    an unnecessary API call on every toggle; change it to only update
    selectedCategories and re-filter locally from the cached feed list instead of
    invoking loadFeeds. Specifically, in handleCategoryToggled (and the updateState
    block that updates selectedCategories) remove the loadFeeds() call and set the
    ViewModel state’s feeds field by filtering state.allFeeds (or the cached
    allFeeds collection) according to the new selectedCategories so the UI updates
    locally without network requests.

In
@feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt:

  • Around line 87-90: The code validates currentState.link but later reads
    mutable currentState.title/currentState.link again, so snapshot the inputs at
    submission time: in UploadViewModel's submit/upload handler (the method
    containing LinkValidator.isValid and the sendSideEffect call) capture val
    titleSnapshot = currentState.title and val linkSnapshot = currentState.link
    before validating; use linkSnapshot for LinkValidator.isValid and thereafter
    pass titleSnapshot and linkSnapshot into the upload request and any side-effects
    instead of reading currentState again; apply the same snapshot approach to the
    similar block referenced at the area corresponding to lines 120-126.
  • Around line 49-60: The AddImages handler (UploadIntent.AddImages) fails to
    dedupe duplicates inside the incoming batch: compute a batch-unique list first
    (e.g., make intent.uris distinct/preserve order) before comparing against
    currentState.selectedImageUris and MAX_IMAGE_COUNT; use that batch-unique list
    instead of deduped so same-chooser duplicates are removed, then compute toAdd =
    batchUnique.filter { it !in existing }. Update hasDuplicates to reflect if
    batchUnique.size < intent.uris.size (or if any incoming duplicates were dropped)
    and keep overflow logic based on toAdd vs batchUnique, then
    updateState(selectedImageUris = ... + toAdd) and send the appropriate
    UploadSideEffect.ShowSnackbar messages.

Outside diff comments:
In
@core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt:

  • Around line 17-23: The navigateToWebView function currently only URL-encodes
    url, leaving title unencoded which can break route parsing if title contains
    reserved characters; update navigateToWebView to also URLEncoder.encode the
    title (e.g., encodedTitle = URLEncoder.encode(title, "UTF-8")) and use that
    encodedTitle when building the route string (alongside encodedUrl), and ensure
    receiving destination decodes both query parameters as needed.

Nitpick comments:
In
@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt:

  • Around line 649-650: The Text composable in FeedCard.kt is hardcoding a
    user-facing string; extract "상품 링크를 확인해보세요!" into the Android string resources
    and use stringResource(...) in the Text call instead. Add a new entry (e.g.,
    name="feed_card_check_product_link") with the Korean text to your strings.xml
    (and localized variants as needed), update the Text invocation in FeedCard.kt to
    reference stringResource(R.string.feed_card_check_product_link), and ensure you
    import androidx.compose.ui.res.stringResource; keep the original semantics and
    formatting.

In
@core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt:

  • Around line 27-32: Update the KDoc for the FeedApiService method that was
    extended to accept a category parameter (the method signature in
    FeedApiService.kt now includes category) by adding an @param category entry
    describing its purpose, allowed values/format (e.g., category id or name), and
    behavior when omitted (e.g., returns all categories); ensure the new @param line
    is consistent with existing @param style and placed alongside
    cursor/size/feedStatus entries in the method's KDoc.

In @docs/superpowers/specs/2026-04-13-filter-sort-design.md:

  • Around line 51-53: The fenced code block containing the ASCII layout ("[
    정렬아이콘(ic_sort) ] [ 패션∙잡화 ] ...") is missing a language tag; update the fence to
    include a plain text language (e.g., use "text" or "plaintext") so the markdown
    linter stops warning. Locate the code fence in the spec file (the block shown in
    the diff) and prepend the opening backticks with the language token (for example
    text) and keep the closing unchanged.

In
@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt:

  • Around line 349-353: The tooltip visibility (showLinkTooltip) is only
    remembered once and won't reset when filteredFeeds changes; update the state so
    it resets on feed changes by including filteredFeeds in the remember key or by
    adding a small effect that sets showLinkTooltip = true when filteredFeeds
    changes (mirror the existing remember(filteredFeeds) usage used for
    tooltipTargetIndex) so the tooltip reappears after refresh/tab change; adjust
    the declaration/initialization of showLinkTooltip in HomeScreen.kt accordingly
    and reference showLinkTooltip, filteredFeeds and tooltipTargetIndex when making
    the change.
  • Around line 546-559: scrollToCenter currently falls back to
    listState.animateScrollToItem(index) when the target isn't in visibleItemsInfo,
    but that can leave the item off-center after layout; modify scrollToCenter so
    that after calling listState.animateScrollToItem(index) you wait for the new
    layout (e.g., suspend briefly or read listState.layoutInfo again) and then
    compute the item's center using listState.layoutInfo.visibleItemsInfo and call
    listState.animateScrollBy(...) to center it; implement one retry (or small
    delay) if the item still isn't found to ensure the item is properly centered
    after layout changes.

In
@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt:

  • Around line 559-561: The filter currently compares FeedItem.category (a
    String) against FeedCategory.displayName strings which is fragile; change to
    compare enum identities by either converting the string to the enum first (use
    FeedCategory.entries.find { it.displayName == feed.category } and then compare
    to the selected categories) or, better, update FeedItem to hold a FeedCategory
    field and filter directly (e.g., feeds.filter { feed -> categories.any { it ==
    feed.category } }). Ensure references to FeedItem.category and
    FeedCategory.entries/find are updated accordingly.

</details>

<details>
<summary>🪄 Autofix (Beta)</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: Path: .coderabbit.yml

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `e5101cd7-ee28-4d97-95ea-7e8225a64ed2`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 0001563a6d321ff2ea7f6e3f795472455d191f22 and dc1b54804fd30aa9b8fdbcbc473ac199659ac5e4.

</details>

<details>
<summary>⛔ Files ignored due to path filters (3)</summary>

* `core/designsystem/src/main/res/drawable-xhdpi/img_my_feed_empty.png` is excluded by `!**/*.png`
* `core/designsystem/src/main/res/drawable-xxhdpi/img_my_feed_empty.png` is excluded by `!**/*.png`
* `core/designsystem/src/main/res/drawable-xxxhdpi/img_my_feed_empty.png` is excluded by `!**/*.png`

</details>

<details>
<summary>📒 Files selected for processing (30)</summary>

* `app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt`
* `core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt`
* `core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt`
* `core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/OptionSheet.kt`
* `core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotIcons.kt`
* `core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotImgs.kt`
* `core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/shape/TopArrowBubbleShape.kt`
* `core/designsystem/src/main/res/drawable/ic_link.xml`
* `core/designsystem/src/main/res/drawable/ic_sort.xml`
* `core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt`
* `core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/request/FeedRequest.kt`
* `core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/FeedListResponse.kt`
* `core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt`
* `docs/superpowers/plans/2026-04-13-filter-sort.md`
* `docs/superpowers/specs/2026-04-13-filter-sort-design.md`
* `domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt`
* `domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/FeedRepository.kt`
* `feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt`
* `feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt`
* `feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt`
* `feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt`
* `feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt`
* `feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt`
* `feature/upload/build.gradle.kts`
* `feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/navigation/UploadNavigation.kt`
* `feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt`
* `feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt`
* `feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt`
* `feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidator.kt`
* `feature/upload/src/test/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidatorTest.kt`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment on lines +315 to +329
val currentAspectRatio = imageAspectRatios.getOrElse(pagerState.currentPage) { ImageAspectRatio.SQUARE }

// 상품 이미지 박스
Box(modifier = modifier) {
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 20.dp),
pageSpacing = 10.dp,
modifier = Modifier.animateContentSize(),
) { page ->
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(imageAspectRatio.ratio)
.clip(RoundedCornerShape(16.dp)),
.aspectRatio(currentAspectRatio.ratio)
.clip(RoundedCornerShape(16.dp))
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

페이지별 비율이 아니라 현재 페이지 비율이 모든 이미지에 적용됩니다.

지금은 pagerState.currentPage로 구한 비율을 모든 page에 재사용하고 있어서, 한 피드 안에 1:1과 4:5가 섞여 있으면 비선택 페이지가 잘못된 높이로 렌더링됩니다. 스와이프 중에도 비율이 튀는 문제가 생깁니다.

🔧 제안 수정
-    val currentAspectRatio = imageAspectRatios.getOrElse(pagerState.currentPage) { ImageAspectRatio.SQUARE }
-
     Box(modifier = modifier) {
         HorizontalPager(
             state = pagerState,
             contentPadding = PaddingValues(horizontal = 20.dp),
             pageSpacing = 10.dp,
             modifier = Modifier.animateContentSize(),
         ) { page ->
+            val pageAspectRatio = imageAspectRatios.getOrElse(page) { ImageAspectRatio.SQUARE }
             Box(
                 modifier =
                     Modifier
                         .fillMaxWidth()
-                        .aspectRatio(currentAspectRatio.ratio)
+                        .aspectRatio(pageAspectRatio.ratio)
                         .clip(RoundedCornerShape(16.dp))
                         .clickable { onFullscreenClick(page) },
             ) {

현재 페이지 높이만 기준으로 카드 컨테이너를 애니메이션하고 싶다면, 컨테이너 높이 계산만 별도로 분리하는 편이 안전합니다.

📝 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
val currentAspectRatio = imageAspectRatios.getOrElse(pagerState.currentPage) { ImageAspectRatio.SQUARE }
// 상품 이미지 박스
Box(modifier = modifier) {
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 20.dp),
pageSpacing = 10.dp,
modifier = Modifier.animateContentSize(),
) { page ->
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(imageAspectRatio.ratio)
.clip(RoundedCornerShape(16.dp)),
.aspectRatio(currentAspectRatio.ratio)
.clip(RoundedCornerShape(16.dp))
Box(modifier = modifier) {
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 20.dp),
pageSpacing = 10.dp,
modifier = Modifier.animateContentSize(),
) { page ->
val pageAspectRatio = imageAspectRatios.getOrElse(page) { ImageAspectRatio.SQUARE }
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(pageAspectRatio.ratio)
.clip(RoundedCornerShape(16.dp))
.clickable { onFullscreenClick(page) },
) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt`
around lines 315 - 329, The bug is that currentAspectRatio (from
imageAspectRatios.getOrElse(pagerState.currentPage)) is reused for every page,
causing non-active pages to render at the wrong aspect; change the
HorizontalPager item to use a page-specific ratio by calling
imageAspectRatios.getOrElse(page) inside the pager lambda for the inner Box, and
if you want the overall card container to animate by the current page height
keep a separate computed containerAspectRatio (based on pagerState.currentPage)
applied to the outer Box (with animateContentSize) while each page's inner Box
uses its own aspectRatio retrieved by page; update references to
currentAspectRatio, imageAspectRatios, pagerState, and the Box inside the
HorizontalPager accordingly.

Comment on lines +368 to +379
if (page == 0 && productLink != null) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = 16.dp, end = 6.dp),
contentAlignment = Alignment.TopEnd,
) {
LinkButton(
modifier = Modifier.padding(end = 10.dp),
onClick = { onLinkClick(productLink) },
)
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

빈 링크도 유효한 상품 링크처럼 노출됩니다.

productLink != null만 검사하면 ""나 공백 문자열도 버튼/툴팁이 노출되고, 클릭 시 빈 URL로 WebView를 열 수 있습니다. productLink가 optional 필드인 만큼 blank도 같이 걸러두는 게 안전합니다.

🔧 제안 수정
-                if (page == 0 && productLink != null) {
+                val normalizedProductLink = productLink?.takeIf { it.isNotBlank() }
+                if (page == 0 && normalizedProductLink != null) {
                     Box(
                         modifier =
                             Modifier
                                 .align(Alignment.TopEnd)
                                 .padding(top = 16.dp, end = 6.dp),
                         contentAlignment = Alignment.TopEnd,
                     ) {
                         LinkButton(
                             modifier = Modifier.padding(end = 10.dp),
-                            onClick = { onLinkClick(productLink) },
+                            onClick = { onLinkClick(normalizedProductLink) },
                         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt`
around lines 368 - 379, The current guard only checks productLink != null which
still allows empty or whitespace strings to render LinkButton and open an
invalid WebView; change the condition to filter out blank links (use
productLink.isNullOrBlank() or !productLink.isNullOrBlank()) so the LinkButton
block (LinkButton, onLinkClick usage) only runs when productLink contains a
non-blank URL (and pass the non-blank string into onLinkClick).

Comment on lines +41 to +44
val arrowCenterX = size.width - arrowOffsetPx
moveTo(arrowCenterX - arrowWidthPx / 2, arrowHeightPx)
lineTo(arrowCenterX, 0f)
lineTo(arrowCenterX + arrowWidthPx / 2, arrowHeightPx)
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

화살표 중심 좌표를 경계 내로 보정해 주세요.

Line 41 계산값은 arrowOffsetFromRight나 툴팁 폭이 예상과 달라질 때 경계를 벗어나 화살표가 잘리거나 모양이 깨질 수 있습니다. 최소/최대 범위로 clamp 해두는 편이 안전합니다.

수정 예시 diff
-                val arrowCenterX = size.width - arrowOffsetPx
+                val halfArrowWidth = arrowWidthPx / 2f
+                val minCenterX = halfArrowWidth
+                val maxCenterX = size.width - halfArrowWidth
+                val arrowCenterX =
+                    if (minCenterX <= maxCenterX) {
+                        (size.width - arrowOffsetPx).coerceIn(minCenterX, maxCenterX)
+                    } else {
+                        size.width / 2f
+                    }
                 moveTo(arrowCenterX - arrowWidthPx / 2, arrowHeightPx)
                 lineTo(arrowCenterX, 0f)
                 lineTo(arrowCenterX + arrowWidthPx / 2, arrowHeightPx)
📝 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
val arrowCenterX = size.width - arrowOffsetPx
moveTo(arrowCenterX - arrowWidthPx / 2, arrowHeightPx)
lineTo(arrowCenterX, 0f)
lineTo(arrowCenterX + arrowWidthPx / 2, arrowHeightPx)
val halfArrowWidth = arrowWidthPx / 2f
val minCenterX = halfArrowWidth
val maxCenterX = size.width - halfArrowWidth
val arrowCenterX =
if (minCenterX <= maxCenterX) {
(size.width - arrowOffsetPx).coerceIn(minCenterX, maxCenterX)
} else {
size.width / 2f
}
moveTo(arrowCenterX - arrowWidthPx / 2, arrowHeightPx)
lineTo(arrowCenterX, 0f)
lineTo(arrowCenterX + arrowWidthPx / 2, arrowHeightPx)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/shape/TopArrowBubbleShape.kt`
around lines 41 - 44, The computed arrowCenterX (currently size.width -
arrowOffsetPx used before moveTo/lineTo) can fall outside the tooltip bounds
when arrowOffsetFromRight or tooltip width varies; clamp arrowCenterX to the
valid range [arrowWidthPx/2, size.width - arrowWidthPx/2] before calling
moveTo/lineTo to ensure the arrow isn't drawn outside the shape. Update the
calculation in TopArrowBubbleShape (where arrowCenterX is defined) to clamp the
value and then use that clamped arrowCenterX in the existing moveTo and lineTo
calls.

Comment on lines +174 to +185
private fun handleCategoryToggled(category: FeedCategory) {
updateState { state ->
val updated =
if (category in state.selectedCategories) {
state.selectedCategories - category
} else {
state.selectedCategories + category
}
state.copy(selectedCategories = updated)
}
loadFeeds()
}
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

카테고리 토글 시 불필요한 API 호출

handleCategoryToggled에서 loadFeeds()를 호출하고 있어 카테고리 선택/해제마다 API 요청이 발생합니다. 그러나 스펙 문서(docs/superpowers/specs/2026-04-13-filter-sort-design.md 40-41행)에 따르면 카테고리 필터 변경 시 "API 재호출 없이 allFeeds 재필터링"으로 처리해야 합니다.

현재 구현은 매번 서버 호출이 발생하여 불필요한 네트워크 요청과 latency가 생깁니다.

🔧 스펙에 맞는 로컬 필터링 구현 제안
 private fun handleCategoryToggled(category: FeedCategory) {
     updateState { state ->
         val updated =
             if (category in state.selectedCategories) {
                 state.selectedCategories - category
             } else {
                 state.selectedCategories + category
             }
-        state.copy(selectedCategories = updated)
+        state.copy(
+            selectedCategories = updated,
+            feeds = applyCategories(state.allFeeds, updated),
+        )
     }
-    loadFeeds()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt`
around lines 174 - 185, handleCategoryToggled currently calls loadFeeds causing
an unnecessary API call on every toggle; change it to only update
selectedCategories and re-filter locally from the cached feed list instead of
invoking loadFeeds. Specifically, in handleCategoryToggled (and the updateState
block that updates selectedCategories) remove the loadFeeds() call and set the
ViewModel state’s feeds field by filtering state.allFeeds (or the cached
allFeeds collection) according to the new selectedCategories so the UI updates
locally without network requests.

Comment on lines +49 to +60
is UploadIntent.AddImages -> {
val existing = currentState.selectedImageUris.toSet()
val remaining = MAX_IMAGE_COUNT - currentState.selectedImageUris.size
val deduped = intent.uris.filter { it !in existing }
val toAdd = deduped.take(remaining)
val hasDuplicates = deduped.size < intent.uris.size
val hasOverflow = toAdd.size < deduped.size
updateState { it.copy(selectedImageUris = it.selectedImageUris + toAdd) }
when {
hasOverflow -> sendSideEffect(UploadSideEffect.ShowSnackbar("최대 ${MAX_IMAGE_COUNT}장까지 추가할 수 있어요"))
hasDuplicates -> sendSideEffect(UploadSideEffect.ShowSnackbar("이미 추가된 사진은 제외됐어요"))
}
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

같은 선택 배치 내부의 중복 URI는 아직 걸러지지 않습니다.

지금 로직은 기존 목록(existing)에 없는지만 보므로, 한 번의 picker 결과에 같은 URI가 두 번 들어오면 둘 다 추가됩니다. 이 경우 중복 제거 snackbar도 뜨지 않아 PR 설명의 동작과 어긋납니다.

🔧 수정 예시
             is UploadIntent.AddImages -> {
                 val existing = currentState.selectedImageUris.toSet()
                 val remaining = MAX_IMAGE_COUNT - currentState.selectedImageUris.size
-                val deduped = intent.uris.filter { it !in existing }
+                val incoming = intent.uris.distinct()
+                val deduped = incoming.filter { it !in existing }
                 val toAdd = deduped.take(remaining)
-                val hasDuplicates = deduped.size < intent.uris.size
+                val hasDuplicates = incoming.size < intent.uris.size || deduped.size < incoming.size
                 val hasOverflow = toAdd.size < deduped.size
                 updateState { it.copy(selectedImageUris = it.selectedImageUris + toAdd) }
                 when {
                     hasOverflow -> sendSideEffect(UploadSideEffect.ShowSnackbar("최대 ${MAX_IMAGE_COUNT}장까지 추가할 수 있어요"))
                     hasDuplicates -> sendSideEffect(UploadSideEffect.ShowSnackbar("이미 추가된 사진은 제외됐어요"))
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt`
around lines 49 - 60, The AddImages handler (UploadIntent.AddImages) fails to
dedupe duplicates inside the incoming batch: compute a batch-unique list first
(e.g., make intent.uris distinct/preserve order) before comparing against
currentState.selectedImageUris and MAX_IMAGE_COUNT; use that batch-unique list
instead of deduped so same-chooser duplicates are removed, then compute toAdd =
batchUnique.filter { it !in existing }. Update hasDuplicates to reflect if
batchUnique.size < intent.uris.size (or if any incoming duplicates were dropped)
and keep overflow logic based on toAdd vs batchUnique, then
updateState(selectedImageUris = ... + toAdd) and send the appropriate
UploadSideEffect.ShowSnackbar messages.

Comment on lines +87 to +90
if (!LinkValidator.isValid(currentState.link)) {
sendSideEffect(UploadSideEffect.ShowSnackbar("링크 주소를 다시 확인해 주세요."))
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

제출 시점의 title/link를 스냅샷으로 고정해 주세요.

여기서는 링크를 먼저 검증해 두고도, 실제 요청에는 업로드가 끝난 뒤의 currentState.title / currentState.link를 다시 읽습니다. 업로드 중 입력값이 바뀌면 검증한 값과 저장되는 값이 달라질 수 있습니다.

🔧 수정 예시
     private fun submitFeed(context: Context) {
         if (currentState.isLoading) return

-        if (!LinkValidator.isValid(currentState.link)) {
+        val title = currentState.title.takeIf { it.isNotBlank() }
+        val link = currentState.link.takeIf { it.isNotBlank() }
+
+        if (!LinkValidator.isValid(link.orEmpty())) {
             sendSideEffect(UploadSideEffect.ShowSnackbar("링크 주소를 다시 확인해 주세요."))
             return
         }

         val uris = currentState.selectedImageUris
@@
                 feedRepository.createFeed(
                     category = category,
                     price = price,
                     content = content,
                     images = feedImages,
-                    title = currentState.title.takeIf { it.isNotBlank() },
-                    link = currentState.link.takeIf { it.isNotBlank() },
+                    title = title,
+                    link = link,
                 )
             }.onSuccess {

Also applies to: 120-126

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

In
`@feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt`
around lines 87 - 90, The code validates currentState.link but later reads
mutable currentState.title/currentState.link again, so snapshot the inputs at
submission time: in UploadViewModel's submit/upload handler (the method
containing LinkValidator.isValid and the sendSideEffect call) capture val
titleSnapshot = currentState.title and val linkSnapshot = currentState.link
before validating; use linkSnapshot for LinkValidator.isValid and thereafter
pass titleSnapshot and linkSnapshot into the upload request and any side-effects
instead of reading currentState again; apply the same snapshot approach to the
similar block referenced at the area corresponding to lines 120-126.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ FEAT 기능 개발 (애매하면 기능 개발로 두도록 하자) 💪 동현동현동현

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ Feature - 피드 고도화

1 participant