Skip to content

release: 1.6.0 버전 업데이트 (to develop)#823

Merged
m6z1 merged 72 commits intodevelopfrom
release/1.6.0
Mar 6, 2026
Merged

release: 1.6.0 버전 업데이트 (to develop)#823
m6z1 merged 72 commits intodevelopfrom
release/1.6.0

Conversation

@Sadturtleman
Copy link
Contributor

@Sadturtleman Sadturtleman commented Mar 6, 2026

📌𝘐𝘴𝘴𝘶𝘦𝘴

  • closed #

릴리즈 1.6.0 업데이트

📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯

📷𝘚𝘤𝘳𝘦𝘦𝘯𝘴𝘩𝘰𝘵

💬𝘛𝘰 𝘙𝘦𝘷𝘪𝘦𝘸𝘦𝘳𝘴

Summary by CodeRabbit

  • New Features

    • 작가가 매긴 소설 평점 표시 추가
    • 피드 작성/편집 흐름 개선 및 빠른 글쓰기 버튼 제공
    • 장르별 아이콘·배경 색상 테마 추가
    • 새 아이콘들(좋아요, 뒤로가기, 더보기) 추가
  • Refactor

    • 피드·라이브러리 로딩 및 캐싱 구조 개선으로 더 안정적인 목록 동기화
  • UI

    • 피드 목록/상세 레이아웃 및 빈 상태 안내 UI 개선

m6z1 and others added 30 commits January 24, 2026 15:04
기존의 복잡한 분기문으로 처리하던 피드 아이템의 장르 및 색상 결정 로직을 `firstOrNull`을 사용하여 간소화했습니다.
@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Walkthrough

로컬 core/database 모듈 제거와 피드 기능 대규모 재설계: UpdatedFeedRepository/UpdatedFeedDetailViewModel 등 신규 저장소·뷰모델 및 FeedApi 확장, feedWriterNovelRating 필드 전파, 라이브러리 서버 중심 페이징 도입, UI/레이아웃·바인딩 어댑터 변경을 포함한 대규모 리팩터링.

Changes

Cohort / File(s) Summary
Core database 제거
core/database/..., core/database/build.gradle.kts, core/database/src/main/...
core/database 모듈과 관련 파일(Entities, DAOs, DatabaseModule, Converters, RemoteMediator, .gitignore, proguard 등) 전면 삭제. settings.gradle.kts와 app/build.gradle.kts에서 모듈/의존성 제거.
피드 API·DTO 확장
core/network/.../FeedApi.kt, core/network/.../FeedDetailResponseDto.kt, data/feed/.../FeedDetailResponseDto.kt
FeedApi에 단일 피드 조회, 멀티파트 생성/수정, 댓글/좋아요 엔드포인트 추가 및 FeedDetailResponseDto 변경(novelThumbnail/genre/author/description 추가, 일부 필드 제거).
데이터/도메인/UI에 feedWriterNovelRating 전파
app/.../FeedResponseDto.kt, data/.../FeedEntity.kt, data/feed/.../FeedDetailEntity.kt, domain/model/Feed.kt, ui/mapper/FeedMapper.kt, ui/main/feed/model/FeedModel.kt
응답 DTO → 데이터 → 도메인 → UI 매핑에 feedWriterNovelRating: Float? 추가 및 관련 널/0 체크(isWriterRatingNoting) 및 UI 계산 속성 추가.
새 저장소: UpdatedFeedRepository, 기존 FeedRepository 리팩토링
data/feed/repository/UpdatedFeedRepository.kt, data/feed/repository/FeedRepository.kt
UpdatedFeedRepository 추가(이미지/멀티파트/캐시/플로우/동기화/댓글 등 광범위 기능), 기존 FeedRepository는 축소·Deprecated 처리 및 일부 시그니처 변경(saveRemovedFeed 등).
뷰모델·유스케이스 재구성
feature/feed/UpdatedFeedViewModel.kt, ui/feedDetail/UpdatedFeedDetailViewModel.kt, domain/feed/UpdatedGetFeedsUseCase.kt, domain/feed/UpdatedGetMyFeedsUseCase.kt
UpdatedFeedViewModel/UpdatedFeedDetailViewModel 및 UpdatedGetFeedsUseCase/UpdatedGetMyFeedsUseCase 추가: 상태 플로우 구독, 페이징/갱신/댓글/좋아요/신고/동기화 로직 제공. 기존 FeedViewModel/FeedDetailViewModel는 Deprecated 처리/축소.
Compose UI 및 라우트 마이그레이션
feature/feed/UpdateFeedRoute.kt, feature/feed/FeedScreen.kt, feature/feed/component/*.kt, feature/feed/FeedRoute.kt, feature/feed/FeedViewModel.kt
FeedRoute/FeedScreen/컴포넌트 파라미터 재정의(onWriteFeedClick 추가, onMoreClick 제거), 빈 상태·로딩 처리 변경, 탭/필터/필터시트 연동 방식 변경, FeedRoute/FeedViewModel 일부 Deprecated.
Create/Edit 피드 UI·ViewModel 변경
ui/createFeed/CreateFeedViewModel.kt, ui/createFeed/CreateFeedActivity.kt, ui/createFeed/CreatingFeedDialogFragment.kt
CreateFeedViewModel이 UpdatedFeedRepository 사용으로 대폭 확장(이미지 다운로드·압축·업로드, 편집 로직 등). CreateFeedActivity 편집 호출 시 시그니처 변경 반영. 다이얼로그 레이아웃 및 바인딩 변경.
라이브러리 저장소·페이징 재구성
data/library/.../MyLibraryRepository.kt, UserLibraryRepository.kt, LibraryPagingSource.kt, data/library/LibraryRepository.kt, feature/library/LibraryViewModel.kt
LibraryRemoteMediator/LibraryLocalDataSource 제거, 서버 중심 LibraryPagingSource 기반 재구성, novelTotalCount 흐름 추가, getLibraryFlow() 도입 및 ViewModel 쪽 상태/수집 로직 변경.
바인딩 어댑터·레이아웃·리소스 추가/변경
app/src/main/java/.../BindingAdapters.kt, app/src/main/res/layout/*.xml, app/src/main/res/values/colors.xml, core/resource/src/main/res/drawable/ic_*.xml
ImageView tint / genre background BindingAdapter 추가, dialog_editing_cancel_feed 레이아웃 추가, activity_feed_detail 레이아웃 구조 변경(RecyclerView로 대체), 다수 색상 리소스·아이콘 추가.
여타 리팩터링 및 안전조정
data/repository/NovelRepository.kt, data/repository/UserNovelRepository.kt, ui/accountInfo/AccountInfoViewModel.kt, ui/withdraw/.../WithdrawSecondViewModel.kt
LibraryLocalDataSource 의존성 제거, saveNovelRating/deleteUserNovel 등 시그니처/내부 로직 단순화, 일부 호출(deleteAllNovels) 제거.
버전·메타 변경
gradle/libs.versions.toml
앱 버전 코드/네임 상승: 10043→10045, 1.5.0→1.6.0.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Client(UI / Compose)
    participant VM as UpdatedFeedDetailViewModel
    participant Repo as UpdatedFeedRepository
    participant API as FeedApi
    participant UserRepo as UserRepository
    participant NotiRepo as NotificationRepository

    rect rgba(50,115,220,0.5)
    UI->>VM: updateFeedDetail(feedId, from)
    end
    VM->>Repo: fetchFeed(feedId)   %% fetchSingleFeed
    Repo->>API: GET /feeds/{feedId}
    API-->>Repo: FeedDetailResponseDto
    Repo->>Repo: to FeedDetailEntity (map novel + feedWriterNovelRating)
    Repo-->>VM: FeedDetailEntity
    VM->>UserRepo: fetchMyProfile()
    UserRepo-->>VM: MyProfileEntity
    VM->>VM: combine feed + profile + comments, update feedDetailUiState
    VM->>NotiRepo: updateNotificationRead(notificationId) (opt)
    VM-->>UI: feedDetailUiState updated
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

🏹 궁사 명지, 🚀 [RELEASE]

Suggested reviewers

  • s9hn
  • devfeijoa

Poem

🐰 데이터베이스는 안녕이라 하고,
새 저장소들 춤추며 길을 닦네.
평점 한 줄 고이 퍼뜨리고,
컬러 옷을 입은 장르들 웃네.
버전은 올라가고, 배포는 냠냠.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch release/1.6.0

Copy link

@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.

Note

Due to the large number of review comments, Critical 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 (4)
app/build.gradle.kts (1)

145-145: 🛠️ Refactor suggestion | 🟠 Major

버전 카탈로그에 이미 정의된 paging 라이브러리를 사용하도록 변경하세요.

버전 카탈로그(gradle/libs.versions.toml)에는 paging 라이브러리가 이미 정의되어 있지만, 현재 코드에서는 하드코딩된 버전 3.3.2를 사용하고 있습니다. 더 큰 문제는 카탈로그의 버전은 3.3.6으로, 버전 불일치가 발생하고 있다는 점입니다. 다른 모든 의존성은 libs.xxx 패턴을 따르고 있으므로 이에 맞춰 변경해야 합니다.

♻️ 수정 방법

build.gradle.kts에서 다음과 같이 변경하세요:

-    implementation("androidx.paging:paging-runtime:3.3.2")
+    implementation(libs.paging.runtime)

버전은 카탈로그의 paging = "3.3.6"에서 관리됩니다.

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

In `@app/build.gradle.kts` at line 145, The dependency is hardcoded as
implementation("androidx.paging:paging-runtime:3.3.2") which conflicts with the
version catalog (paging = "3.3.6"); replace that hardcoded dependency with the
version-catalog entry (the libs.paging alias used by other deps) so the module
uses libs.paging from gradle/libs.versions.toml instead of a literal 3.3.2
string, ensuring consistency with the catalog-managed version.
feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt (1)

88-115: ⚠️ Potential issue | 🟠 Major

비동기 응답이 현재 선택된 목록에 잘못 반영될 수 있습니다.

성공 블록에서 currentData를 응답 시점에 다시 읽고 있어서, 요청 후 탭/카테고리를 바꾸면 이전 요청 결과가 현재 탭 목록에 append되거나 삭제/신고가 원래 리스트에 반영되지 않을 수 있습니다. 요청 시작 시점의 selectedTab/sosoCategory를 캡처해서 해당 source만 갱신하도록 고정해 주세요.

Also applies to: 156-179, 198-201, 221-246

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

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt`
around lines 88 - 115, The success handler in FeedViewModel's
viewModelScope.launch reads currentData at response time, which lets stale async
responses update the wrong tab/category; capture the request's context (e.g.,
val requestedTab = state.selectedTab and val requestedCategory =
state.sosoCategory) immediately before calling getMyFeedsUseCase/getFeedsUseCase
and, in onSuccess, only update the source when the current UI state still
matches those captured values (or locate the target source by an id/tag and
update that specific source via updateCurrentSource), ensuring FeedViewModel's
updateCurrentSource call only mutates the list belonging to the originally
requested tab/category.
app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt (2)

327-327: ⚠️ Potential issue | 🟡 Minor

메서드 이름에 오타가 있습니다.

setupBackgorundViewsetupBackgroundView로 수정이 필요합니다.

🔧 수정 제안
-    private fun setupBackgorundView() {
+    private fun setupBackgroundView() {

Line 328의 호출부도 함께 수정하세요.

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

In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt` at line
327, Rename the misspelled method setupBackgorundView to setupBackgroundView in
the NovelFeedFragment class and update all call sites (including the invocation
currently calling setupBackgorundView) to the corrected name; ensure the method
signature and any references (e.g., setupBackgorundView in NovelFeedFragment)
are consistently renamed to setupBackgroundView to avoid compilation errors.

254-256: ⚠️ Potential issue | 🟡 Minor

예외에 메시지를 추가하세요.

디버깅을 위해 의미 있는 메시지를 포함시키세요.

🔧 수정 제안
-            } ?: throw IllegalArgumentException()
+            } ?: throw IllegalArgumentException("Feed with id $feedId not found for editing")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt` around
lines 254 - 256, The IllegalArgumentException thrown at the end of the
expression (currently `?: throw IllegalArgumentException()`) has no message;
replace it with a descriptive message that includes context (e.g., mention the
operation and relevant identifiers such as `feed`, `feed.id`, or current
position) so the throw in NovelFeedFragment (where `imageUrls = feed.imageUrls`
is used) becomes `?: throw IllegalArgumentException("...")` with a clear,
actionable message for debugging.
🟠 Major comments (24)
feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt-73-73 (1)

73-73: ⚠️ Potential issue | 🟠 Major

onWriteFeedClick를 빈 람다로 넘기면 작성 진입이 끊깁니다.

Line 73에서 새로 추가된 작성 액션이 아무 동작도 하지 않아서, FeedScreen 내부의 작성 CTA를 눌러도 이동이 발생하지 않습니다. 기존 동선을 유지하려는 의도라면 onWriteClick을 그대로 전달하는 쪽이 맞습니다.

수정 예시
-        onWriteFeedClick = { },
+        onWriteFeedClick = onWriteClick,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt` at
line 73, The new FeedRoute is passing an empty lambda to the FeedScreen's
onWriteFeedClick, which prevents navigation; replace the empty lambda with the
existing onWriteClick handler so FeedScreen's CTA triggers the original write
flow (update the use of onWriteFeedClick to forward onWriteClick where it is
passed into FeedScreen).
feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt-35-40 (1)

35-40: ⚠️ Potential issue | 🟠 Major

클릭 가능한 로그인 이미지에 접근성 이름을 넣어주세요.

contentDescription = null인 상태로 로그인 액션을 Image에 연결하면 TalkBack 사용자는 이 버튼의 의미를 알 수 없습니다. 최소한 로컬라이즈된 contentDescription을 제공하고, 가능하면 버튼 role도 함께 지정해 주세요.

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

In
`@feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt`
around lines 35 - 40, The Image currently sets contentDescription = null making
the clickable login icon inaccessible; update the Image in SignInButtons.kt to
provide a localized contentDescription (e.g., use
stringResource(R.string.signin_kakao) or a passed-in localized label) and add
button semantics (e.g., attach Role.Button via Modifier.semantics or using a
clickable with role) while keeping the existing modifier.debouncedClickable {
onClick(AuthPlatform.KAKAO) } so TalkBack will announce it as a tappable "Kakao
login" control.
app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt-24-24 (1)

24-24: 🛠️ Refactor suggestion | 🟠 Major

사용되지 않는 의존성 libraryRepository가 주입되어 있습니다.

libraryRepository가 생성자에 주입되어 있지만 클래스 내에서 사용되지 않습니다. AI 요약에 따르면 deleteAllNovels() 호출이 제거되었으므로, 이 의존성도 함께 제거해야 합니다.

♻️ 제안된 수정
 `@HiltViewModel`
 class WithdrawSecondViewModel
 `@Inject`
 constructor(
     private val accountRepository: AccountRepository,
     private val pushMessageRepository: PushMessageRepository,
     private val userRepository: UserRepository,
-    private val libraryRepository: MyLibraryRepository,
     private val filterRepository: MyLibraryFilterRepository,
 ) : ViewModel() {

또한 사용되지 않는 import도 제거하세요:

-import com.into.websoso.data.library.repository.MyLibraryRepository
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt`
at line 24, Remove the unused constructor dependency libraryRepository:
MyLibraryRepository from the WithdrawSecondViewModel class and delete its
related import—locate the primary constructor of WithdrawSecondViewModel and
remove the libraryRepository parameter and any unused import or references
(e.g., MyLibraryRepository) left after deleting deleteAllNovels(); ensure the
class compiles and no other methods reference libraryRepository before
committing.
app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt-63-67 (1)

63-67: ⚠️ Potential issue | 🟠 Major

onResume()에서 매번 refresh() 호출로 인한 불필요한 데이터 재로딩

매 resume마다 (탭 전환, 상세 화면 복귀 등) libraryViewModel.refresh()를 호출하면 loadLibrary()가 실행되어 paging job을 취소하고 새로운 데이터 흐름을 시작합니다. 이는 매번 새로운 네트워크 요청을 트리거하므로 불필요한 재로딩과 UX 중단으로 이어질 수 있습니다.

resetScrollPosition()처럼 명시적인 신호가 필요한 경우에만 갱신하도록 분리하거나, 데이터 변경이 감지되었을 때만 자동 갱신하는 구조로 변경하는 것을 권장합니다.

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

In `@app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt` around
lines 63 - 67, The onResume() currently calls libraryViewModel.refresh()
unconditionally causing loadLibrary() to cancel and restart paging on every
resume; change this to only trigger refresh when explicitly required (e.g., on
resetScrollPosition() or when a ViewModel flag indicates data is stale) by
removing the unconditional call in onResume() and instead expose a
method/LiveData in LibraryViewModel (e.g., needsRefresh or refreshIfStale) that
onResume() can check or observe, or call libraryViewModel.refresh() only from
the explicit UI actions that require reload; ensure loadLibrary() remains
lifecycle-safe and only restarts when that flag/action demands it.
app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt-29-48 (1)

29-48: ⚠️ Potential issue | 🟠 Major

피드 캐시가 카테고리/새로고침 경계를 모르고 계속 누적됩니다.

_cachedFeeds가 단일 리스트라서 카테고리 전환이나 첫 페이지 재조회에서도 이전 결과가 남습니다. 지금처럼 호출부가 항상 clearCachedFeeds()를 기억해야 맞는 구조면 누락 시 바로 데이터가 오염되니, repository 내부에서 category 변경이나 lastFeedId == 0을 기준으로 직접 초기화하는 편이 안전합니다.

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

In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt` around
lines 29 - 48, The cache _cachedFeeds is shared across categories so fetchFeeds
can return mixed results; update fetchFeeds to clear _cachedFeeds when the
requested category differs from the previously-used category or when lastFeedId
== 0 (indicating a refresh/first page); track the last requested category in a
private var (e.g. private var lastCategory: String? within the repository), call
_cachedFeeds.clear() and set lastCategory = category when category !=
lastCategory or lastFeedId == 0 before adding new feeds, and keep existing
clearCachedFeeds() for external use.
data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt-65-74 (1)

65-74: ⚠️ Potential issue | 🟠 Major

삭제 실패를 삼키면 FeedViewModel이 실패를 감지할 수 없습니다.

이 메서드가 예외를 먹고 정상 복귀하면 FeedViewModel.updateRemovedFeed()는 서버 삭제 실패여도 리스트에서 항목을 제거하게 됩니다. 캐시 정리는 API 성공 뒤에만 하고, 실패는 그대로 호출부까지 전달해 주세요.

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 65 - 74, The saveRemovedFeed function currently swallows API
failures via runCatching, causing FeedViewModel.updateRemovedFeed to act as if
deletion succeeded; change saveRemovedFeed so it only performs cache removals
(cachedFeeds.removeIf and cachedRecommendedFeeds.removeIf) after a successful
feedApi.deleteFeed call and allow failures to propagate to the caller (i.e.,
remove the runCatching swallow or rethrow the caught exception after logging) so
callers can detect deletion errors; reference saveRemovedFeed,
feedApi.deleteFeed, cachedFeeds, cachedRecommendedFeeds and
FeedViewModel.updateRemovedFeed when making this change.
app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt-20-30 (1)

20-30: ⚠️ Potential issue | 🟠 Major

검색/필터 캐시가 요청 조건과 연결되어 있지 않습니다.

지금 구조는 searchWord나 필터 조합이 바뀌어도 같은 캐시에 계속 addAll해서, 호출부가 clearCached…()를 놓치면 이전 결과와 isLoadable 상태가 그대로 섞입니다. 최소한 page == 0/1 또는 조건 변경 시점에는 repository 내부에서 캐시를 초기화해 주세요.

Also applies to: 50-67, 79-105

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

In `@app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt` around
lines 20 - 30, The current cache fields (cachedNormalExploreIsLoadable,
_cachedNormalExploreResult, cachedDetailExploreResult and
cachedDetailExploreIsLoadable) are not tied to request parameters, causing stale
mixes when searchWord/filters change; update the repository to reset the
corresponding cache and isLoadable flag automatically whenever the request's
identity changes (e.g., when page == 0 or 1, or when searchWord/filters differ
from the last request) by storing the last request key and clearing
_cachedNormalExploreResult/_cachedDetailExploreResult and resetting
cachedNormalExploreIsLoadable/cachedDetailExploreIsLoadable inside the fetch
methods (the explore/fetch functions that currently call addAll) before adding
results; ensure this logic is applied symmetrically for both "normal" and
"detail" flows so callers don't have to call clearCached…() manually.
app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt-20-27 (1)

20-27: ⚠️ Potential issue | 🟠 Major

수정 경로에서 novelId!!로 바로 크래시할 수 있습니다.

isAlreadyRated == true인데 novelRatingEntity.novelId가 비어 있으면 API 호출 전에 NPE가 납니다. requireNotNull로 계약을 명시하거나, 수정 경로에 쓰는 모델 자체를 non-null로 올려 주세요.

수정 예시
     suspend fun saveNovelRating(
         novelRatingEntity: NovelRatingEntity,
         isAlreadyRated: Boolean,
     ) {
         if (isAlreadyRated) {
-            userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData())
+            val novelId = requireNotNull(novelRatingEntity.novelId) {
+                "isAlreadyRated=true 일 때 novelId는 필수입니다."
+            }
+            userNovelApi.putNovelRating(novelId, novelRatingEntity.toData())
         } else {
             userNovelApi.postNovelRating(novelRatingEntity.toData())
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt`
around lines 20 - 27, In saveNovelRating, calling
userNovelApi.putNovelRating(novelRatingEntity.novelId!!, ...) can NPE if novelId
is null; change to explicitly requireNotNull(novelRatingEntity.novelId) (or
validate and handle the null case) before calling userNovelApi.putNovelRating so
the contract is clear and you avoid a silent crash; update the code around
saveNovelRating, novelRatingEntity.novelId, and the userNovelApi.putNovelRating
invocation to either throw a descriptive error via requireNotNull or route the
null case to an appropriate fallback/validation path.
app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt-107-113 (1)

107-113: ⚠️ Potential issue | 🟠 Major

삭제 실패를 삼키면 상위가 성공으로 오인합니다.

여기서 예외를 소비하면 호출부의 runCatching { saveRemovedFeed(...) }는 항상 성공으로 끝납니다. 서버 삭제가 실패해도 화면에서는 항목이 제거될 수 있으니, 예외를 다시 던지거나 Result를 반환해서 상위가 실패를 분기할 수 있게 해 주세요.

수정 예시
     suspend fun saveRemovedFeed(feedId: Long) {
-        runCatching {
-            feedApi.deleteFeed(feedId)
-        }.onSuccess {
-            _cachedFeeds.removeIf { it.id == feedId }
-        }
+        feedApi.deleteFeed(feedId)
+        _cachedFeeds.removeIf { it.id == feedId }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt` around
lines 107 - 113, The current saveRemovedFeed function swallows exceptions by
using runCatching and never propagating failures, so callers cannot detect
deletion failures; update saveRemovedFeed (and its callers) to either propagate
the exception (remove runCatching or rethrow in onFailure) or change its
signature to return a Result<Unit> and return the runCatching result; in either
case ensure _cachedFeeds.removeIf { it.id == feedId } only runs on success
(i.e., after a successful feedApi.deleteFeed(feedId)) and that callers check the
propagated exception or Result to handle failure.
feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt-234-248 (1)

234-248: ⚠️ Potential issue | 🟠 Major

탭 접근성 시맨틱 누락

Box + debouncedClickable만으로는 Role.Tab 및 선택 상태 시맨틱이 노출되지 않아 TalkBack이 현재 탭과 탭 전환 컨텍스트를 제대로 읽지 못합니다. selectable(role = Role.Tab, selected = selected)로 접근성 시맨틱을 추가하고, TalkBack/Accessibility Scanner로 선택 상태가 읽히는지 확인해 주세요.

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

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt`
around lines 234 - 248, The tab items built in FeedTab.entries currently use Box
with debouncedClickable which lacks accessibility semantics; update the Box
modifier chain where FeedTab.entries is iterated (the block using selectedTab,
onTabClick and debouncedClickable) to include selectable(role = Role.Tab,
selected = selected) (keeping debouncedClickable for click handling) so the
TalkBack/Accessibility Scanner can read the tab role and selection state; after
adding selectable, verify accessibility reads correctly.
app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt-218-248 (1)

218-248: ⚠️ Potential issue | 🟠 Major

검색 응답 순서가 뒤집히면 최신 결과가 덮어써집니다.

이 두 메서드는 이전 요청을 취소하거나 요청 식별자를 검증하지 않습니다. 사용자가 빠르게 입력하거나 페이지 로딩 중 검색어를 바꾸면, 늦게 도착한 이전 응답이 _searchNovelUiState를 다시 써서 다른 검색어 결과가 노출될 수 있습니다.

Also applies to: 255-285

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

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 218 - 248, The updateSearchedNovels flow can write stale responses
into _searchNovelUiState because concurrent launches are not cancelled or
validated; change updateSearchedNovels to either cancel any previous search
coroutine (keep a Job/MutableStateFlow for the active search launched via
viewModelScope.launch and cancel it before starting a new one) or attach a
unique requestId/typingText token captured before calling
getSearchedNovelsUseCase and verify that the token still matches
searchedText/typingText before updating _searchNovelUiState in
onSuccess/onFailure; reference updateSearchedNovels, _searchNovelUiState,
searchedText, viewModelScope.launch and getSearchedNovelsUseCase when applying
the fix.
feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt-144-157 (1)

144-157: ⚠️ Potential issue | 🟠 Major

삭제/신고 실패가 예외로 새고, 삭제는 로딩 상태에 갇힙니다.

여기서는 saveRemovedFeed/saveSpoilerFeed/saveImpertinenceFeed를 예외 처리 없이 호출합니다. 특히 updateRemovedFeed()는 실패 시 loading을 내리지 못해서 화면이 계속 로딩으로 남고, 나머지 두 메서드도 저장 실패가 그대로 코루틴 예외로 빠집니다.

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

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt`
around lines 144 - 157,
updateRemovedFeed/updateReportedSpoilerFeed/updateReportedImpertinenceFeed call
repository methods without error handling; wrap the repository calls in a
try/catch (and a finally for updateRemovedFeed) so exceptions don't crash the
coroutine and so loading is always set back to false: in updateRemovedFeed set
loading=true before launch, then in the coroutine use try {
feedRepository.saveRemovedFeed(feedId) } catch (e: Exception) { /* log/update
_uiState with error */ } finally { _uiState.update { it.copy(loading = false) }
}; apply similar try/catch around feedRepository.saveSpoilerFeed and
feedRepository.saveImpertinenceFeed to log errors and update UI state instead of
letting exceptions escape.
app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt-330-358 (1)

330-358: ⚠️ Potential issue | 🟠 Major

비동기 압축 중 중복 추가하면 최대 이미지 수를 넘길 수 있습니다.

개수 검사는 압축 시작 전에 한 번만 수행되는데, 압축이 끝나기 전에 addImages()가 다시 호출되면 두 요청이 모두 통과합니다. 이후 성공 콜백에서 현재 개수를 다시 확인하지 않고 append해서 MAX_IMAGE_COUNT를 초과한 첨부 상태가 만들어질 수 있습니다.

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

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 330 - 358, The current pre-check in addImages plus async
compression in addCompressedImages can race and exceed MAX_IMAGE_COUNT; after
compression completes, re-check and update _attachedImages atomically instead of
blindly appending. In addCompressedImages (onSuccess), use an atomic/state
update (e.g., _attachedImages.update { current -> ... }) to verify current.size
+ compressedImages.size <= MAX_IMAGE_COUNT and only then return current +
compressedImages; otherwise emit _exceedingImageCountEvent.tryEmit(Unit) and do
not append. Keep retry behavior same but ensure the atomic check happens inside
addCompressedImages before modifying _attachedImages.
app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt-149-163 (1)

149-163: ⚠️ Potential issue | 🟠 Major

수정 대상 피드를 myFeedData에서만 찾으면 SOSO 탭의 내 글 편집이 깨집니다.

FeedItemfeed.isMyFeed를 넘기므로 SOSO/추천 탭의 내 글에서도 편집 플로우로 들어올 수 있습니다. 그런데 여기서는 myFeedData.feeds만 조회해서, MY_FEED가 아직 로드되지 않았거나 현재 필터에서 빠진 경우 IllegalArgumentException으로 바로 종료됩니다.

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

In `@app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt` around lines
149 - 163, navigateToFeedEdit currently searches only
updatedFeedViewModel.uiState.value.myFeedData.feeds and throws
IllegalArgumentException if not found; change the lookup to search both
myFeedData.feeds and the general feed list (e.g.,
updatedFeedViewModel.uiState.value.feedData.feeds or whatever holds
SOSO/recommend feeds) before failing, then build the EditFeedModel from the
found Feed; only throw IllegalArgumentException if the feed cannot be found in
any source.
app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt-178-190 (1)

178-190: ⚠️ Potential issue | 🟠 Major

신고 완료 콜백이 오래된 feedUiState로 최신 UI를 되돌릴 수 있습니다.

두 메서드 모두 코루틴 시작 전에 캡처한 feedUiState를 성공/실패 시 다시 대입합니다. 요청 중에 좋아요 변경이나 목록 갱신이 일어나면 그 변경이 통째로 덮어써져서, 사용자가 방금 본 화면 상태가 롤백될 수 있습니다.

Also applies to: 196-208

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

In
`@app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt`
around lines 178 - 190, Summary: updateReportedSpoilerFeed captures a stale
feedUiState before launching the coroutine and then reassigns that old snapshot
on success/failure, which can overwrite concurrent UI updates; the same pattern
exists in the other method around lines 196-208. Fix: inside
updateReportedSpoilerFeed (and the analogous method), do not reuse the
pre-launch feedUiState variable—after the network call completes read the latest
_uiState.value (or use _uiState.update { ... }) and apply only the field changes
(set isLoading=false and isError as needed) so you merge with any intermediate
updates instead of overwriting them; reference functions/fields:
updateReportedSpoilerFeed, _uiState, viewModelScope,
feedRepository.saveSpoilerFeed (and the similar method at 196-208) to locate and
update the code.
app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt-317-324 (1)

317-324: ⚠️ Potential issue | 🟠 Major

소설 선택 해제 시 novelId도 같이 초기화해야 합니다.

지금은 UI 선택 상태와 제목만 비우고 실제 저장값인 novelId는 유지합니다. 이 상태로 저장하면 방금 해제한 소설이 그대로 생성/수정 요청에 실립니다.

수정 예시
 fun updateSelectedNovelClear() {
     searchNovelUiState.value?.let { searchNovelUiState ->
         val novels = searchNovelUiState.novels.map { novel ->
             novel.copy(isSelected = false)
         }
         _searchNovelUiState.value = searchNovelUiState.copy(novels = novels)
-        _selectedNovelTitle.value = ""
     }
+    novelId = null
+    _selectedNovelTitle.value = ""
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 317 - 324, updateSelectedNovelClear currently clears only the UI
selection flags and _selectedNovelTitle but leaves the stored novel identifier
unchanged; modify updateSelectedNovelClear() to also reset the stored selected
novel id (e.g., set _selectedNovelId or selectedNovelId to null/empty depending
on its type) after clearing novels and title so the cleared novel won't be
included in subsequent create/update requests; locate this in the same function
alongside _searchNovelUiState and _selectedNovelTitle updates and ensure the
reset matches the existing selected novel id property name and type.
feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt-88-126 (1)

88-126: ⚠️ Potential issue | 🟠 Major

탭/카테고리 전환 중 응답이 돌아오면 잘못된 소스의 페이징 상태를 갱신합니다.

fetchNextPage()는 요청 시점의 탭/카테고리로 데이터를 가져오지만, 성공 시에는 currentState.currentDatalastIdisLoadable을 기록합니다. 요청 중에 탭이나 소소 카테고리를 바꾸면 이전 응답이 새로 선택된 소스의 페이징 상태를 덮어써서 다음 페이지 로딩이 꼬일 수 있습니다.

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

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt`
around lines 88 - 126, fetchNextPage currently updates uiState.currentData for
whatever is the current UI when the network returns, so a slow response from a
previous tab/category overwrites the newly selected source; to fix, capture the
request's identity before launching (e.g., val requestedTab = state.selectedTab
and val requestedCategory = state.sosoCategory and any filter/sort key used) and
after onSuccess compare the live currentState to those captured values (check
currentState.selectedTab and currentState.sosoCategory / filter/sort match
requestedTab/requestedCategory) and only then call
currentState.updateCurrentSource(updatedSource). If they don't match, discard
the result or merge into the correct source by locating/updating only the
matching data source instead of the global currentData.
app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt-271-285 (1)

271-285: ⚠️ Potential issue | 🟠 Major

키워드 편집 중 ‘전체 해제’가 저장 전 원본까지 지웁니다.

saveSelectedKeywords()가 커밋 지점인데, clearEditingKeyword()에서 이미 novelRatingModel.userKeywords를 비우고 있습니다. 그래서 사용자가 해제 후 취소하면 cancelEditingKeyword()가 복원할 원본이 없어집니다.

수정 예시
 fun clearEditingKeyword() {
     uiState.value?.let { uiState ->
         _uiState.value = uiState.copy(
-            novelRatingModel = uiState.novelRatingModel.copy(userKeywords = setOf()),
             keywordsModel = uiState.keywordsModel.copy(
                 categories = uiState.keywordsModel.categories.map { category ->
                     category.copy(
                         keywords = category.keywords.map { keyword ->
                             keyword.copy(isSelected = false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt`
around lines 271 - 285, clearEditingKeyword() currently clears
novelRatingModel.userKeywords immediately which destroys the pre-edit original
before saveSelectedKeywords() can commit; change clearEditingKeyword() to only
reset keywordsModel (set each keyword.isSelected = false and
keywordsModel.currentSelectedKeywords = setOf()) and avoid mutating
novelRatingModel.userKeywords so cancelEditingKeyword() can still restore the
original value; ensure saveSelectedKeywords() remains the single place that
writes to novelRatingModel.userKeywords and that cancelEditingKeyword() reads
the preserved original to revert UI state.
app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt-224-235 (1)

224-235: ⚠️ Potential issue | 🟠 Major

TODO 주석과 하드코딩된 값, 그리고 에러 핸들링 문제가 있습니다.

  1. isLogin 값을 받아오지만 무시하고 항상 true로 설정합니다.
  2. onFailure에서 예외를 직접 throw하면 앱이 크래시될 수 있습니다.
🐛 수정 제안
         }.onSuccess { isLogin ->
-            // TODO: _isLogin.value = isLogin
-            _isLogin.value = true
+            _isLogin.value = isLogin
         }.onFailure {
-            throw it
+            _isLogin.value = false
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt` around
lines 224 - 235, In updateLoginStatus(): stop ignoring the fetched value—assign
the actual isLogin from runCatching.onSuccess to _isLogin (replace the hardcoded
true) and remove the throw in onFailure; instead log the exception (or post it
to an error state) and set a safe fallback like _isLogin.value = false so the
app won't crash; reference userRepository.fetchIsLogin(), the runCatching block,
onSuccess/onFailure handlers, and the _isLogin mutable state when making the
change.
data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt-278-294 (1)

278-294: ⚠️ Potential issue | 🟠 Major

동기화 실패 시 데이터 손실 가능성이 있습니다.

syncDirtyFeeds()에서 API 호출 전에 dirtyFeedStatesoriginalFeedStates를 clear하고 있습니다. API 호출이 실패하면 변경사항이 손실됩니다.

🔧 수정 제안
     fun syncDirtyFeeds() {
         if (dirtyFeedStates.isEmpty()) return

         val syncMap = dirtyFeedStates.toMap()
-        dirtyFeedStates.clear()
-        originalFeedStates.clear()

         scope.launch {
             syncMap.forEach { (id, isLiked) ->
                 runCatching {
                     if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id)
+                }.onSuccess {
+                    dirtyFeedStates.remove(id)
+                    originalFeedStates.remove(id)
                 }.onFailure {
                     Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it)
+                    // 실패한 항목은 dirtyFeedStates에 유지됨
                 }
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 278 - 294, The current syncDirtyFeeds() clears dirtyFeedStates and
originalFeedStates before performing network calls which can cause data loss if
feedApi.postLikes/deleteLikes fail; change the logic to first copy
dirtyFeedStates into syncMap (keep the originals intact), perform the API calls
(using scope.launch and runCatching over syncMap entries), and only after each
call succeeds remove the corresponding id from dirtyFeedStates and
originalFeedStates (or remove them all after all calls succeed); ensure failures
do not clear state so retries or rollback are possible—use the existing symbols
syncDirtyFeeds, dirtyFeedStates, originalFeedStates, syncMap, scope.launch,
feedApi.postLikes and feedApi.deleteLikes to locate and implement this change.
app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt-226-241 (1)

226-241: ⚠️ Potential issue | 🟠 Major

오래된 상태 스냅샷을 기준으로 댓글 결과를 반영해서 다른 변경을 롤백할 수 있습니다.

Line 227에서 잡아 둔 feedDetailUiState 를 네트워크 완료 후 Lines 234-240에서 그대로 사용하고 있습니다. 댓글 갱신 대기 중에 updateLike() 같은 다른 액션이 먼저 성공하면, 여기서 예전 스냅샷을 다시 써서 최신 값이 되돌아갈 수 있습니다. 완료 시점의 최신 상태를 읽어서 merge 하도록 바꾸는 편이 안전합니다.

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

In `@app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt`
around lines 226 - 241, updateComments에서 네트워크 호출 시작 시 캡처한 feedDetailUiState 스냅샷을
그대로 사용해 결과를 병합하면(updateComments, feedDetailUiState) 다른 동시 작업(updateLike 등)으로 인한
최신 상태를 덮어쓸 수 있으니, 네트워크 성공 시점에 최신 _feedDetailUiState.value를 읽어 그 값과 comments 결과를
병합하도록 변경하세요; 구체적으로 onSuccess 블록에서 현재 _feedDetailUiState.value 또는
feedDetailUiState.value?.feedDetail을 다시 조회한 뒤 commentCount와 comments만 교체해 copy하여
_feedDetailUiState.value에 할당하도록 수정하면 됩니다.
app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt-45-49 (1)

45-49: ⚠️ Potential issue | 🟠 Major

isLiked = false 기본값 때문에 이미 좋아요한 피드가 잘못 언라이크될 수 있습니다.

Line 48의 기본값으로 호출부가 세 번째 인자를 넘기지 않으면, 서버 응답이 feedDetail.isLiked == true 인 경우에도 Lines 72-76 분기가 실행되어 likeCount 를 1 줄인 UI가 만들어집니다. 값이 없을 때는 서버 상태를 그대로 쓰도록 분리하는 편이 안전합니다.

🩹 제안된 수정
 fun updateFeedDetail(
     feedId: Long,
     from: ResultFrom,
-    isLiked: Boolean = false,
+    isLiked: Boolean? = null,
 ) {
     this.feedId = feedId
     feedDetailUiState.value?.let { feedDetailUiState ->
         viewModelScope.launch {
             delay(300)
@@
-                    val updatedFeed = if (feedDetail.isLiked == isLiked) {
-                        uiFeed
-                    } else if (!isLiked && feedDetail.isLiked) {
-                        uiFeed?.copy(
-                            isLiked = false,
-                            likeCount = feedDetail.likeCount - 1,
-                        )
-                    } else {
-                        uiFeed?.copy(
-                            isLiked = true,
-                            likeCount = feedDetail.likeCount + 1,
-                        )
-                    }
+                    val updatedFeed = when {
+                        isLiked == null || feedDetail.isLiked == isLiked -> uiFeed
+                        isLiked == false && feedDetail.isLiked -> uiFeed?.copy(
+                            isLiked = false,
+                            likeCount = (feedDetail.likeCount - 1).coerceAtLeast(0),
+                        )
+                        else -> uiFeed?.copy(
+                            isLiked = true,
+                            likeCount = feedDetail.likeCount + 1,
+                        )
+                    }

Also applies to: 69-81

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

In `@app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt`
around lines 45 - 49, The updateFeedDetail function's isLiked default of false
causes existing liked feeds to be erroneously unliked when callers omit the
third argument; change the parameter to a nullable Boolean (isLiked: Boolean? =
null) on updateFeedDetail and adjust the logic that computes
feedDetail.likeCount / feedDetail.isLiked (the branch around the current
conditional at/near the handling of likeCount and isLiked between the existing
feedDetail retrieval and the UI state emission) to only modify likeCount and
overwrite isLiked when isLiked != null, otherwise preserve the server-provided
feedDetail values; apply the same nullable-check pattern to the same branch
referenced in lines ~69-81.
app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt-53-64 (1)

53-64: ⚠️ Potential issue | 🟠 Major

초기 로드 전체를 취소하지 않아 이전 요청이 현재 상세 상태를 덮어쓸 수 있습니다.

지금은 feedFlowJob만 취소되고 fetchSingleFeed/fetchMyProfile/fetchComments는 계속 살아 있습니다. 같은 ViewModel에서 updateFeedDetail()가 다시 호출되면, 이전 요청 결과가 늦게 도착해 현재 feedId의 상태를 덮어쓸 수 있습니다. 초기 로드를 별도 Job으로 묶어 같이 취소하거나, 결과 반영 전에 feedId를 다시 검증해 주세요.

🛠️ 수정 예시
     private var feedId: Long = -1
     var commentId: Long = -1
         private set
     private var feedFlowJob: Job? = null
+    private var initialLoadJob: Job? = null
...
     fun updateFeedDetail(
         feedId: Long,
         from: ResultFrom,
     ) {
         this.feedId = feedId
+        initialLoadJob?.cancel()
         feedFlowJob?.cancel()
...
-        viewModelScope.launch {
+        initialLoadJob = viewModelScope.launch {
             launch { fetchSingleFeed(feedId) }
             launch { fetchMyProfile() }
             launch { fetchComments(feedId) }
             observeFeedStream(feedId)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt`
around lines 53 - 64, The three initial-load coroutines (fetchSingleFeed,
fetchMyProfile, fetchComments) can outlive feedFlowJob and overwrite state for a
new feedId; wrap them in a single parent Job (e.g., initialLoadJob) or a
coroutineScope/child Job started from viewModelScope, cancel that Job at the
start of updateFeedDetail (alongside feedFlowJob), then launch the three fetches
as children of that Job; alternatively, add a guard in the result-handling paths
of fetchSingleFeed/fetchMyProfile/fetchComments to verify the current feedId
before applying updates. Ensure you reference and update the existing symbols
feedFlowJob, updateFeedDetail, fetchSingleFeed, fetchMyProfile, fetchComments,
and observeFeedStream when implementing the change.
app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt-102-103 (1)

102-103: ⚠️ Potential issue | 🟠 Major

runCatching으로 코루틴 취소를 실패로 잘못 처리하고 있습니다.

runCatchingsuspend 호출을 포함한 모든 Throwable을 잡으므로, CancellationException도 함께 캡처되어 실패로 처리됩니다. Kotlin 공식 문서는 CancellationException을 다시 던지도록 명확히 권장하고 있습니다. 현재 코드는 취소된 작업을 일반 오류로 취급하여 isServerError, error 등의 실패 상태를 잘못 설정하거나(예: 라인 187, 201, 243), 일부는 취소를 무시하고 있습니다(라인 259, 268, 277, 286, 296).

🛠️ 수정 패턴
+import kotlinx.coroutines.CancellationException
...
-        runCatching { feedRepository.fetchFeed(targetFeedId) }
-            .onSuccess { result ->
-                // 성공 처리
-            }
-            .onFailure {
-                // 실패 처리
-            }
+        try {
+            val result = feedRepository.fetchFeed(targetFeedId)
+            // 성공 처리
+        } catch (e: CancellationException) {
+            throw e
+        } catch (_: Throwable) {
+            // 실패 처리
+        }

이 패턴이 파일 전반에 반복됩니다(라인 102–103, 154, 169, 187, 201, 216, 243, 259, 268, 277, 286, 296).

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

In
`@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt`
around lines 102 - 103, runCatching around suspend calls (e.g., the call to
feedRepository.fetchFeed(targetFeedId) in UpdatedFeedDetailViewModel) is
incorrectly treating CancellationException as a failure; change each runCatching
block to rethrow CancellationException instead of handling it as an error: after
catching/inspecting the Throwable from runCatching (or replace runCatching with
a try/catch), if the exception is a
kotlin.coroutines.cancellation.CancellationException rethrow it, otherwise
proceed to set isServerError/error and other failure state as before; update
every occurrence in UpdatedFeedDetailViewModel that wraps suspend calls (the
blocks around feedRepository.fetchFeed, and the other runCatching usages noted)
to follow this pattern so cancellations are not converted into normal failures.
🟡 Minor comments (9)
feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt-57-57 (1)

57-57: ⚠️ Potential issue | 🟡 Minor

Column과 Text의 너비 불일치 문제

Column의 너비가 180.dp로 변경되었지만, 내부 Text의 너비는 여전히 192.dp로 설정되어 있습니다. 부모보다 큰 자식 너비는 Compose의 제약 시스템에 의해 잘리지만, 코드의 의도가 불명확하고 불일치합니다.

Text의 너비를 Column과 동일하게 맞추거나, fillMaxWidth()를 사용하는 것이 적절합니다.

🔧 수정 제안
                     modifier = Modifier
-                        .width(width = 192.dp)
+                        .width(width = 180.dp)
                         .debouncedClickable {

또는 고정 너비 대신 fillMaxWidth() 사용:

                     modifier = Modifier
-                        .width(width = 192.dp)
+                        .fillMaxWidth()
                         .debouncedClickable {

Also applies to: 66-66

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

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt`
at line 57, Column의 너비가 .width(width = 180.dp)로 설정되어 있는데 내부 Text가
.width(192.dp)로 더 크게 설정되어 있어 불일치하므로 FeedMoreMenu.kt에서 Column과 해당 Text의 Modifier
크기를 일치시키세요; 수정 방법은 내부 Text의 Modifier.width(...)를 Column과 같은 180.dp로 변경하거나, 고정 너비
대신 Column의 자식에 Modifier.fillMaxWidth()를 사용하여 부모 너비에 맞추는 방식 중 하나를 선택하면 됩니다(참조:
Column 및 해당 Text의 Modifier 설정).
app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt-44-48 (1)

44-48: ⚠️ Potential issue | 🟡 Minor

사용자 정보 조회 실패를 그대로 삼키고 있습니다.

Line 44-48에서는 실패 시 아무 상태도 남기지 않아 화면에 빈 이메일만 표시됩니다. 최소한 에러 상태나 재시도 트리거는 내려주는 편이 안전합니다.

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

In `@app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt`
around lines 44 - 48, The current runCatching around
userRepository.fetchUserInfoDetail() in AccountInfoViewModel swallows failures
and only updates _userEmail on success; add an onFailure handler to propagate an
error state or retry trigger instead of leaving the UI blank. Modify the
runCatching chain (around fetchUserInfoDetail()) to call onFailure and update a
dedicated observable (e.g., _userError, _uiState, or _shouldRetry) with the
exception or a retry flag so the UI can show an error message and allow retry;
keep the existing _userEmail.update in onSuccess. Ensure the new observable is
exposed as a public LiveData/StateFlow so views can react.
feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt-39-43 (1)

39-43: ⚠️ Potential issue | 🟡 Minor

닫기 버튼 터치 영역이 padding을 포함하지 않습니다.

현재 순서에서는 vertical padding이 클릭 영역이 아니라 바깥 여백으로 적용돼서 실제 hit area가 아이콘 크기 수준으로 유지됩니다. 닫기 버튼은 자주 쓰는 액션이라 debouncedClickablepadding보다 앞에 두거나 최소 터치 영역을 명시하는 편이 안전합니다.

제안 수정안
         Box(
             modifier = Modifier
-                .padding(vertical = 20.dp)
-                .debouncedClickable {
+                .debouncedClickable {
                     onDismissRequest()
-                },
+                }
+                .padding(vertical = 20.dp),
         ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt`
around lines 39 - 43, The close button's touch area currently excludes the
vertical padding because .debouncedClickable is applied after .padding, so only
the icon bounds are clickable; update the Modifier in
LibraryFilterBottomSheetHeader (the composable using modifier = Modifier
.padding(vertical = 20.dp) .debouncedClickable { onDismissRequest() }) to ensure
the padding is inside the clickable area by moving .debouncedClickable before
.padding or by adding an explicit minimum hit target (e.g., sizeIn or
requiredSize with a sensible min touch target) so the debouncedClickable covers
the padded area while still calling onDismissRequest().
feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt-93-107 (1)

93-107: ⚠️ Potential issue | 🟡 Minor

LazyColumn의 itemsIndexed에 key가 누락되어 메뉴 상태가 잘못된 항목으로 이동할 수 있습니다.

FeedItem에서 라인 125의 remember { mutableStateOf(false) }isMenuExpanded 상태를 관리하고 있는데, LazyColumn의 itemsIndexed(라인 94)에 key 파라미터가 없습니다. 피드 항목이 삭제되거나 삽입될 때 Compose가 인덱스 기반으로 상태를 재사용하여, 열려있던 메뉴가 다른 피드 항목에 붙는 문제가 발생할 수 있습니다.

수정 예시
-                LazyColumn(modifier = Modifier.padding(horizontal = 20.dp)) {
-                    itemsIndexed(items = feeds) { index, feed ->
+                LazyColumn(modifier = Modifier.padding(horizontal = 20.dp)) {
+                    itemsIndexed(
+                        items = feeds,
+                        key = { _, feed -> feed.id },
+                    ) { index, feed ->
                         FeedItem(
                             feed = feed,
                             currentTab = currentTab,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt`
around lines 93 - 107, The LazyColumn's itemsIndexed call lacks a stable key,
causing Compose to reuse index-based state (so FeedItem's remember {
mutableStateOf(false) } for isMenuExpanded at line ~125 can attach to the wrong
item); fix by passing a key param to itemsIndexed (use a stable unique
identifier from each feed, e.g., feed.id or feed.uniqueId) so itemsIndexed(items
= feeds, key = { _, feed -> feed.id }) ensures menu state in FeedItem binds to
the correct feed when items are inserted or removed.
app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt-128-132 (1)

128-132: ⚠️ Potential issue | 🟡 Minor

예외에 메시지를 추가하세요.

IllegalArgumentException을 던질 때 디버깅을 위한 메시지가 없습니다. Static analysis 도구에서도 경고하고 있습니다.

🔧 수정 제안
-            } ?: throw IllegalArgumentException()
+            } ?: throw IllegalArgumentException("Feed with id $selectedFeedId not found")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt` around
lines 128 - 132, The throw of IllegalArgumentException in NovelFeedViewModel
when no feed matches selectedFeedId lacks a message; update the code that
computes selectedFeed (searching feedUiState.feeds for feedModel.id ==
selectedFeedId) to throw an IllegalArgumentException with a descriptive message
including the missing selectedFeedId and/or a brief context (e.g., "No feed
found for selectedFeedId=... in NovelFeedViewModel") so static analysis warnings
are resolved and debugging is easier.
app/src/main/java/com/into/websoso/ui/BindingAdapters.kt-36-39 (1)

36-39: ⚠️ Potential issue | 🟡 Minor

ETC 보더 두께가 저밀도 기기에서 0px가 될 수 있습니다.

Line 37의 (1 * density).toInt() 는 density 가 1 미만이면 0으로 잘립니다. 그 경우 기타 보더가 아예 그려지지 않으니 최소 1px은 보장하는 쪽이 안전합니다.

🩹 제안된 수정
+import kotlin.math.roundToInt
+
 ...
-        val strokeWidth = (1 * context.resources.displayMetrics.density).toInt()
+        val strokeWidth =
+            context.resources.displayMetrics.density.roundToInt().coerceAtLeast(1)
         val strokeColor = ContextCompat.getColor(context, R.color.gray_70_DFDFE3)
         drawable.setStroke(strokeWidth, strokeColor)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/BindingAdapters.kt` around lines 36 -
39, The ETC border width calculation in BindingAdapters.kt uses (1 *
context.resources.displayMetrics.density).toInt() which can truncate to 0 on
low-density devices; update the isEtc branch to compute strokeWidth with a
minimum of 1 pixel (e.g., take the max between 1 and the density-scaled value or
use ceil) before calling drawable.setStroke so the "기타" border is never
zero-width.
app/src/main/java/com/into/websoso/ui/novelDetail/model/Category.kt-19-19 (1)

19-19: ⚠️ Potential issue | 🟡 Minor

bl_021A6FF 색상 리소스 명명 규칙 재검토 권장.

색상 리소스가 올바르게 정의되어 있으나 (#21A6FF), 리소스 이름이 다른 색상들과 명명 규칙이 다릅니다. 대부분의 색상은 prefix_HEXvalue 형식(예: romance_E586CA#E586CA, fantasy_6457FC#6457FC)을 따르지만, bl_021A6FF는 이름의 021A6FF와 실제 hex 값 #21A6FF가 일치하지 않습니다. 명확성과 일관성을 위해 bl_21A6FF로 변경하거나, 이 명명 방식의 의도를 코드에 문서화하는 것을 권장합니다.

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

In `@app/src/main/java/com/into/websoso/ui/novelDetail/model/Category.kt` at line
19, The color resource name for BOYS_LOVE in Category.kt is inconsistent
(R.color.bl_021A6FF refers to hex `#21A6FF`); rename the color resource to match
the pattern (e.g., bl_21A6FF) and update the reference in the enum constant
BOYS_LOVE (and any other usages) accordingly, or alternatively add a brief
comment in Category.kt documenting why the current atypical name is intentional
if you decide not to rename; ensure the drawable/resource file name and R.color
reference stay in sync.
data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt-49-52 (1)

49-52: ⚠️ Potential issue | 🟡 Minor

실패 응답에서 전체 개수를 0으로 덮어쓰지 마세요.

여기서는 페이지 로드가 실패해도 novelTotalCount를 바로 0으로 바꿔서, 이미 화면에 남아 있는 목록과 상단 개수가 쉽게 어긋납니다. 성공 응답에서만 갱신하고, 필터 변경 시 초기화가 필요하면 그 시점에 별도로 리셋하는 편이 안전합니다.

🛠️ 수정 예시
                             getUserNovels(lastUserNovelId, currentFilter).also { result ->
-                                _novelTotalCount.update {
-                                    result.getOrNull()?.userNovelCount ?: 0
-                                }
+                                result.getOrNull()?.userNovelCount?.let { totalCount ->
+                                    _novelTotalCount.update { totalCount }
+                                }
                             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt`
around lines 49 - 52, The code currently overwrites _novelTotalCount to 0 on
failed page loads because you use result.getOrNull()?.userNovelCount ?: 0;
change the logic so you only update _novelTotalCount when the call to
getUserNovels(lastUserNovelId, currentFilter) succeeds (e.g., check
result.isSuccess or result.getOrNull() != null) and update with
result.getOrNull()!!.userNovelCount; do not write 0 on failure—if you need to
reset the total when the filter changes, do that explicitly at the filter-change
handler rather than here; refer to getUserNovels, _novelTotalCount and
currentFilter to locate the code to change.
data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt-55-58 (1)

55-58: ⚠️ Potential issue | 🟡 Minor

실패 응답에서 전체 개수를 0으로 덮어쓰지 마세요.

여기도 페이지 로드 실패 시 novelTotalCount가 즉시 0으로 바뀌어서, 이미 표시 중인 목록과 카운트가 불일치할 수 있습니다. 성공 응답에서만 갱신하고, 필터 전환 시 초기화가 필요하면 그 시점에 명시적으로 처리하는 편이 안전합니다.

🛠️ 수정 예시
                                 getUserNovels(lastUserNovelId, currentFilter).also { result ->
-                                    _novelTotalCount.update {
-                                        result.getOrNull()?.userNovelCount ?: 0
-                                    }
+                                    result.getOrNull()?.userNovelCount?.let { totalCount ->
+                                        _novelTotalCount.update { totalCount }
+                                    }
                                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`
around lines 55 - 58, The code unconditionally writes 0 into _novelTotalCount
when getUserNovels fails; change the update so that _novelTotalCount is only
modified on a successful response from getUserNovels (e.g., check
result.isSuccess or result.getOrNull() != null and then update with
result.getOrNull()!!.userNovelCount), and do not set it to 0 on failure—handle
filter-reset initialization of novelTotalCount explicitly elsewhere (on filter
change) rather than in this failure path; locate this logic around the
getUserNovels(...) call and the _novelTotalCount.update usage to apply the
conditional update.

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit

ktlint

🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (4) (should be 8)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (5) (should be 9)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (5) (should be 9)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (4) (should be 8)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)

// 모든 캐시에서 해당 피드 제거 (안전하게 두 곳 모두 확인 가능)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)

cachedRecommendedFeeds.removeIf { it.id == feedId }


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (8) (should be 12)


🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)

Log.d("FeedRepository", "saveRemovedFeed 함수 failed : ${it.message}")


🚫 [ktlint] standard:annotation reported by reviewdog 🐶
Expected newline before annotation

class UpdatedGetFeedsUseCase @Inject constructor(


🚫 [ktlint] standard:annotation reported by reviewdog 🐶
Expected newline after last annotation

class UpdatedGetFeedsUseCase @Inject constructor(


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Unexpected newline before '.'


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Unexpected newline before '.'

Copy link

@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 (6)
feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt (1)

234-259: ⚠️ Potential issue | 🟠 Major

탭과 쓰기 액션에 접근성 정보를 추가해야 합니다.

Box + debouncedClickable으로 구현된 탭은 debouncedClickable이 기본 Modifier.clickable()을 사용하기 때문에 Role.Tab과 선택 상태 의미론(semantics)이 전달되지 않습니다. 선택 상태는 텍스트 색상(Black/Gray100)으로만 표시되어 스크린리더에는 전달되지 않으며, 쓰기 액션 아이콘의 contentDescription = null로 인해 스크린리더가 제공할 레이블이 없습니다.

Material3의 Tab composable을 사용하거나, 현재 구조를 유지하려면 다음 중 하나를 적용하세요:

  • Modifier.selectable(selected = ..., role = Role.Tab)를 탭에 추가
  • Modifier.semantics { contentDescription = "..." } 또는 Modifier.semantics { selected = ... } 명시
  • 쓰기 액션 아이콘에 의미 있는 contentDescription 값 제공 (예: "글 작성")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt`
around lines 234 - 259, Tabs rendered in the FeedTab.entries loop use Box +
debouncedClickable and only indicate selection via color, and the write Icon has
contentDescription = null; update accessibility by adding semantics/role: for
each tab Box (inside the FeedTab.entries loop where selected is computed from
selectedTab and onTabClick is invoked via debouncedClickable) add
Modifier.selectable(selected = selected, role = Role.Tab) or Modifier.semantics
{ selected = selected } so screen readers get the selection state and role; and
for the write action (where debouncedClickable calls onWriteClick and Icon
currently has contentDescription = null) provide a meaningful contentDescription
like "글 작성" (or use Modifier.semantics { contentDescription = "글 작성" }) so the
write button is announced.
data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt (1)

60-80: ⚠️ Potential issue | 🟠 Major

runCatching으로 취소를 삼키면 Paging invalidation이 에러로 바뀝니다.

필터 변경이나 새로고침 시 flatMapLatest가 기존 PagingSource를 무효화하고 coroutine을 취소하는데, 여기서 CancellationException까지 runCatching으로 잡으면 LibraryPagingSource의 onFailure에서 모든 예외가 LoadResult.Error로 변환됩니다(8-31줄). 결과적으로 취소된 요청도 로드 에러 상태로 노출됩니다. 취소는 재던지고, 나머지 예외만 Result.failure로 감싸세요.

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

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt`
around lines 60 - 80, The current getUserNovels uses runCatching which swallows
CancellationException causing canceled PagingSource loads to become
LoadResult.Error; change the error handling so CancellationException is rethrown
and only non-cancellation exceptions are wrapped as failures: replace or augment
the runCatching around libraryRemoteDataSource.getUserNovels in getUserNovels to
catch Throwable, if it is a CancellationException rethrow it, otherwise return
Result.failure(exception); keep the successful return path unchanged so
LibraryPagingSource.onFailure only sees genuine errors (non-cancellation).
data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt (1)

66-86: ⚠️ Potential issue | 🟠 Major

runCatchingCancellationException을 감싸면 페이징 취소가 정상적으로 작동하지 않습니다.

runCatchingCancellationException을 포함한 모든 Throwable을 잡으므로, 취소된 요청이 Result.failure로 변환되고 LibraryPagingSource에서 LoadResult.Error로 처리됩니다. 필터 변경이나 새로고침 시 이전 요청의 취소를 방해합니다.

CancellationException은 다시 던지고, 나머지 예외만 Result.failure로 변환하세요:

suspend inline fun <T> runCatchingCancellable(crossinline block: suspend () -> T): Result<T> =
  try {
    Result.success(block())
  } catch (e: CancellationException) {
    throw e
  } catch (t: Throwable) {
    Result.failure(t)
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`
around lines 66 - 86, The getUserNovels helper currently wraps
libraryRemoteDataSource.getUserNovels in runCatching which swallows
CancellationException and breaks paging cancellation; replace runCatching with a
cancellable wrapper (e.g., implement runCatchingCancellable as described) and
call that from getUserNovels (referencing getUserNovels and runCatching usage)
so CancellationException is rethrown while other Throwables are converted to
Result.failure; ensure the new helper is suspend inline and catches
CancellationException separately before returning Result.success/failure.
app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt (3)

234-241: ⚠️ Potential issue | 🟡 Minor

선택 상태 토글 로직이 의도와 다를 수 있습니다.

novel.id == novelId인 경우 !it.isSelected로 토글하면, 검색 결과에서 기존 선택된 소설이 선택 해제될 수 있습니다. 편집 모드에서 기존 선택된 소설은 항상 선택 상태로 표시되어야 합니다.

🐛 제안된 수정
                            novels = result.novels.map { novel ->
                                if (novel.id == novelId) {
                                    novel
                                        .toUi()
-                                       .let { it.copy(isSelected = !it.isSelected) }
+                                       .copy(isSelected = true)
                                } else {
                                    novel.toUi()
                                }
                            },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 234 - 241, The toggle logic currently flips isSelected when
novel.id == novelId, which can accidentally deselect the pre-selected novel; in
CreateFeedViewModel change the branch that handles novel.id == novelId to set
isSelected = true (use novel.toUi().copy(isSelected = true)) instead of toggling
with !it.isSelected so the existing selected novel remains selected in edit mode
while other novels continue to use novel.toUi().

269-277: ⚠️ Potential issue | 🟡 Minor

페이지네이션 메서드에서도 동일한 토글 로직 문제가 있습니다.

위와 동일하게 isSelected = true로 수정해야 합니다.

🐛 제안된 수정
                        novels = result.novels.map { novel ->
                            if (novel.id == novelId) {
                                novel
                                    .toUi()
-                                   .let { it.copy(isSelected = !it.isSelected) }
+                                   .copy(isSelected = true)
                            } else {
                                novel.toUi()
                            }
                        },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 269 - 277, 페이지네이션 처리 로직에서 현재 novel.id == novelId 인 항목을 토글하는 대신 항상
선택 상태로 설정해야 합니다; CreateFeedViewModel의 해당 매핑 블록(uses result.novels.map { novel ->
... }, 호출되는 메서드명/변수 novelId, toUi(), isSelected)을 찾아 novel.id == novelId일 때
.toUi().let { it.copy(isSelected = true) }로 변경하고 나머지 항목은 그대로 novel.toUi()를 반환하도록
수정하세요.

167-172: ⚠️ Potential issue | 🟠 Major

피드 저장 실패 시 사용자 피드백이 없습니다.

onFailure 블록에서 _isUploading만 false로 설정하고 에러 이벤트를 발행하지 않습니다. 사용자가 저장 실패 여부를 알 수 없습니다.

🐛 에러 이벤트 추가 제안
+private val _updateFeedErrorEvent: MutableSharedFlow<Unit> = MutableSharedFlow()
+val updateFeedErrorEvent: SharedFlow<Unit> get() = _updateFeedErrorEvent.asSharedFlow()

// createFeed와 editFeed의 onFailure 블록에서:
 }.onFailure {
     _isUploading.value = false
+    _updateFeedErrorEvent.emit(Unit)
 }

Also applies to: 195-200

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

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 167 - 172, The onFailure branch currently only sets
_isUploading.value = false and provides no user feedback; add and emit an error
event (e.g., create or use _updateFeedErrorEvent) inside the onFailure handlers
where _isUploading is set to false so the UI can display a failure message or
retry option; update both occurrences (the block around lines with
onSuccess/onFailure and the similar block at 195-200) to emit the error (or a
mapped user-friendly message) along with stopping the uploading state so the
View can observe _updateFeedErrorEvent and show feedback.
🧹 Nitpick comments (3)
feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt (1)

53-53: novels를 읽기 전용으로 노출하세요.

지금 타입이면 화면 코드도 PagingData를 직접 덮어쓸 수 있어서 ViewModel 상태 경계가 깨집니다. _novelsprivate으로 두고 StateFlow<PagingData<NovelUiModel>>만 공개하는 편이 안전합니다.

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

In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`
at line 53, 현재 public으로 선언된 `novels: MutableStateFlow<PagingData<NovelUiModel>>`
때문에 외부에서 상태를 덮어쓸 수 있으므로 `_novels`를 private MutableStateFlow로 변경하고 외부에는 읽기 전용
`StateFlow<PagingData<NovelUiModel>>` 타입의 `novels`만 노출하도록 수정하세요; 즉 클래스 내부에
`private val _novels = MutableStateFlow(PagingData.empty<NovelUiModel>())`를 두고
기존 `novels`를 `val novels: StateFlow<PagingData<NovelUiModel>> = _novels`로 바꿔 상태
캡슐화를 강제하세요.
app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt (1)

346-361: 재시도 로직에 지연(delay)이 없습니다.

현재 구현은 실패 시 즉시 재시도하여 시스템에 부담을 줄 수 있습니다. 지수 백오프(exponential backoff) 또는 최소 지연을 추가하는 것을 권장합니다.

♻️ 백오프 지연 추가 제안
+import kotlinx.coroutines.delay

 private fun addCompressedImages(
     newImages: List<Uri>,
     retryCount: Int = 0,
 ) {
     if (retryCount > MAX_RETRY_COUNT) return

     viewModelScope.launch {
         runCatching {
             feedRepository.compressImages(newImages)
         }.onSuccess { compressedImages ->
             _attachedImages.value = attachedImages.value + compressedImages
         }.onFailure {
+            delay(1000L * (retryCount + 1)) // 점진적 지연
             addCompressedImages(newImages, retryCount + 1)
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`
around lines 346 - 361, The addCompressedImages retry path currently retries
immediately; introduce a suspend delay with exponential backoff before retrying
to avoid tight retry loops: inside the viewModelScope.launch that calls
feedRepository.compressImages, compute a backoff delay (e.g., BASE_DELAY_MS *
2.pow(retryCount) capped by MAX_DELAY_MS), call
kotlinx.coroutines.delay(backoff) before invoking addCompressedImages(newImages,
retryCount + 1) (or refactor the recursion to a loop that delays between
attempts), keeping the existing MAX_RETRY_COUNT check and ensuring the delay
runs inside the coroutine so cancellation works; update constants or add
BASE_DELAY_MS and MAX_DELAY_MS as needed and reference addCompressedImages,
retryCount, MAX_RETRY_COUNT, viewModelScope.launch,
feedRepository.compressImages, and _attachedImages in your change.
app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt (1)

253-287: 신고 API 호출 결과 처리가 없습니다.

모든 신고 메서드가 runCatching 결과를 무시하고 있어 사용자에게 성공/실패 피드백이 전달되지 않습니다. 최소한 에러 로깅 또는 UI 피드백을 고려해주세요.

♻️ 에러 처리 추가 제안
 fun updateReportedSpoilerFeed() {
     viewModelScope.launch {
         runCatching { feedRepository.saveSpoilerFeed(feedId) }
+            .onFailure {
+                _feedDetailUiState.value =
+                    _feedDetailUiState.value?.copy(isServerError = true)
+            }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt`
around lines 253 - 287, Each report method (updateReportedSpoilerFeed,
updateReportedImpertinenceFeed, updateReportedSpoilerComment,
updateReportedImpertinenceComment) currently calls runCatching and drops the
result; update each to handle success and failure by chaining .onSuccess { ... }
and .onFailure { ... } on the runCatching result: onSuccess emit a UI event or
update a StateFlow/LiveData indicating report succeeded (e.g., post a
toast/snackbar or set a _reportResult/_uiEvent), and onFailure log the throwable
and emit an error UI event so the user sees failure; use the existing
viewModelScope and feedRepository.save* methods and ensure logging includes the
exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt`:
- Around line 23-24: The code uses a forced non-null (!!) on
novelRatingEntity.novelId in the block guarded by isAlreadyRated when calling
userNovelApi.putNovelRating; remove the unsafe !! and instead ensure a safe
non-null value by handling the nullable: e.g., check novelRatingEntity.novelId
with an if/let or use the elvis operator to provide a clear fallback or throw a
descriptive IllegalStateException before calling userNovelApi.putNovelRating
(update the logic in the same method in UserNovelRepository.kt where
isAlreadyRated is evaluated and the call to userNovelApi.putNovelRating occurs).

In `@app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt`:
- Around line 149-164: navigateToFeedEdit currently looks up the feed only in
updatedFeedViewModel.uiState.value.myFeedData and throws
IllegalArgumentException if not found, causing a crash when an editable item
exists in the visible list but not in myFeedData; change the lookup to first try
the current visible feed collection (e.g., the fragment's displayed feed list or
uiState.value.feedList/currentTabFeeds) and only then fall back to myFeedData,
and if still not found avoid throwing—either return silently or trigger a
refresh/reload (e.g., call the existing refresh method) instead of throwing;
update navigateToFeedEdit to handle a nullable result safely and proceed to
EditFeedModel creation only when the feed is found.

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`:
- Around line 314-347: The three suspend functions saveRemovedFeed,
saveSpoilerFeed, and saveImpertinenceFeed currently swallow errors by using
runCatching without propagating failures; update them so callers can detect
failures by either rethrowing the caught exception or returning a Result: call
feedApi within runCatching, onSuccess keep the existing cache updates
(removeFromFlow/markAsSpoilerInFlow on
_sosoAllFeeds/_sosoRecommendedFeeds/_myFeeds) and in onFailure either rethrow
the Throwable (so the suspend function throws) or return the failure as
Result.failure(...) (and change the function signature to return Result<Unit>)
so the caller (e.g., UpdatedFeedViewModel) can observe errors and retry.
- Around line 96-127: saveEditedFeed currently updates local cache via
updateFeedInLocalCache then launches a background sync to feedApi.putFeed and
swallows failures with Log.e, leaving callers unaware and the UI inconsistent on
server rollback; change saveEditedFeed to surface failures (e.g., return a
Result/Boolean or suspend and throw) or accept a callback so callers can handle
errors, and on failure undo the optimistic local change by calling
updateFeedInLocalCache with the original data (capture pre-edit state before
calling updateFeedInLocalCache) or enqueue a retry mechanism; ensure
feedApi.putFeed failures from the scope.launch/runCatching block are propagated
to the caller or trigger a rollback/retry path and keep the Log.e for
diagnostics.
- Around line 289-304: syncDirtyFeeds currently copies dirtyFeedStates to
syncMap and immediately clears dirtyFeedStates and originalFeedStates, which
loses pending changes on network failure and can cause duplicate sends on
concurrent calls; fix by making the snapshot-and-clear atomic (use a mutex or
synchronized block) and only clear/remove entries after they have been
successfully synced (e.g., iterate over the snapshot and on success remove the
id from dirtyFeedStates and originalFeedStates, or collect failures and put them
back), ensure scope.launch runs outside the lock so network IO doesn't hold the
mutex, and update syncDirtyFeeds, dirtyFeedStates, originalFeedStates, and the
scope.launch usage accordingly to prevent lost or duplicated like-syncs.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt`:
- Around line 49-52: 현재 getUserNovels(...) 결과가 실패일 때도 _novelTotalCount.update {
... }로 0을 덮어써 이전 카운트를 잃고 있으므로, _novelTotalCount는 성공 응답에서만 갱신하도록 변경하세요:
getUserNovels(...) 호출 후 result를 검사하여 성공일 때만 result.getOrNull()?.userNovelCount로
_novelTotalCount를 업데이트하고, 실패인 경우에는 기존 값을 유지하도록 처리하도록 변경(예: result.isSuccess 확인
또는 fold/onSuccess 사용). 참조 심볼: getUserNovels, result, _novelTotalCount.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`:
- Around line 55-58: The current code unconditionally updates _novelTotalCount
with result.getOrNull()?.userNovelCount ?: 0 which overwrites the previous total
on any failure; change the update so it only sets _novelTotalCount when
getUserNovels(...) returns a successful value (e.g., check result.isSuccess or
result.getOrNull() != null or use result.onSuccess) and otherwise leave the
existing _novelTotalCount untouched; locate the getUserNovels(...) call and the
_novelTotalCount.update block and wrap the update so it runs only for successful
results (use result.getOrNull()?.userNovelCount to guard the update).

In `@domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt`:
- Around line 48-60: The returned Feeds object currently uses feeds =
emptyList() which breaks pagination; change the return to include the converted
feeds (use convertedFeeds from myFeedsEntity.feeds.map) so
UpdatedGetMyFeedsUseCase returns the actual list used by the consumer to compute
lastId; ensure you still call feedRepository.updateMyFeedsCache(feeds =
convertedFeeds, ...) and return Feeds(category = "내 활동", isLoadable =
myFeedsEntity.isLoadable, feeds = convertedFeeds) so the viewmodel can compute
next lastId correctly.

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt`:
- Line 69: FeedSection's write callback is being dropped because FeedRoute
passes onWriteFeedClick = { } while only UpdateFeedRoute forwards the real
onWriteFeedClick; update FeedRoute (or remove the deprecated route) so it
forwards the provided onWriteFeedClick into FeedSection/UpdateFeedRoute instead
of a no-op, ensuring the same handler is invoked for the write entry point
(reference FeedRoute, UpdateFeedRoute, FeedSection, onWriteFeedClick). Also fix
accessibility in FeedTabRow by giving the pencil icon a meaningful
contentDescription (use an appropriate string resource or passed label rather
than null) so screen readers announce the action (reference FeedTabRow and
contentDescription).

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt`:
- Around line 90-130: The fetchNextPage flow updates currentData
(lastId/isLoadable) based on the response but doesn't guard against the user
changing selectedTab/sosoCategory while the request is in-flight, causing
cross-source clobbering; modify fetchNextPage to capture a request-specific
source key (e.g., selectedTab and sosoCategory or a derived sourceId) before
launching the network call, pass/use that key inside the runCatching/onSuccess
block, and when applying the response only update the matching source via
updateCurrentSource (or skip applying if
currentState.selectedTab/currentState.sosoCategory no longer equals the captured
key) so only the source that initiated the request gets its lastId/isLoadable
changed.

In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`:
- Around line 69-80: loadLibrary currently recreates a new
cachedIn(viewModelScope) chain each time (canceling only the collector via
pagingJob), causing accumulated caches; instead create a single cached flow once
in the ViewModel (e.g. val cachedLibraryFlow =
libraryRepository.getLibraryFlow().map(...).cachedIn(viewModelScope)) and have
loadLibrary/collector subscribe to that cachedLibraryFlow, cancelling/
restarting only the collector (pagingJob) as needed; for refresh, do not
recreate cachedIn—either expose a pager.invalidate() /
repository.invalidatePagingSource() call or add a separate refresh trigger (e.g.
MutableSharedFlow<Unit> consumed via flatMapLatest/switchMapLatest upstream of
getLibraryFlow()) so upstream paging source is re-created without creating new
cachedIn chains (adjust references: loadLibrary, pagingJob,
libraryRepository.getLibraryFlow(), cachedIn(viewModelScope), novels.update).

---

Outside diff comments:
In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`:
- Around line 234-241: The toggle logic currently flips isSelected when novel.id
== novelId, which can accidentally deselect the pre-selected novel; in
CreateFeedViewModel change the branch that handles novel.id == novelId to set
isSelected = true (use novel.toUi().copy(isSelected = true)) instead of toggling
with !it.isSelected so the existing selected novel remains selected in edit mode
while other novels continue to use novel.toUi().
- Around line 269-277: 페이지네이션 처리 로직에서 현재 novel.id == novelId 인 항목을 토글하는 대신 항상 선택
상태로 설정해야 합니다; CreateFeedViewModel의 해당 매핑 블록(uses result.novels.map { novel ->
... }, 호출되는 메서드명/변수 novelId, toUi(), isSelected)을 찾아 novel.id == novelId일 때
.toUi().let { it.copy(isSelected = true) }로 변경하고 나머지 항목은 그대로 novel.toUi()를 반환하도록
수정하세요.
- Around line 167-172: The onFailure branch currently only sets
_isUploading.value = false and provides no user feedback; add and emit an error
event (e.g., create or use _updateFeedErrorEvent) inside the onFailure handlers
where _isUploading is set to false so the UI can display a failure message or
retry option; update both occurrences (the block around lines with
onSuccess/onFailure and the similar block at 195-200) to emit the error (or a
mapped user-friendly message) along with stopping the uploading state so the
View can observe _updateFeedErrorEvent and show feedback.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt`:
- Around line 60-80: The current getUserNovels uses runCatching which swallows
CancellationException causing canceled PagingSource loads to become
LoadResult.Error; change the error handling so CancellationException is rethrown
and only non-cancellation exceptions are wrapped as failures: replace or augment
the runCatching around libraryRemoteDataSource.getUserNovels in getUserNovels to
catch Throwable, if it is a CancellationException rethrow it, otherwise return
Result.failure(exception); keep the successful return path unchanged so
LibraryPagingSource.onFailure only sees genuine errors (non-cancellation).

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`:
- Around line 66-86: The getUserNovels helper currently wraps
libraryRemoteDataSource.getUserNovels in runCatching which swallows
CancellationException and breaks paging cancellation; replace runCatching with a
cancellable wrapper (e.g., implement runCatchingCancellable as described) and
call that from getUserNovels (referencing getUserNovels and runCatching usage)
so CancellationException is rethrown while other Throwables are converted to
Result.failure; ensure the new helper is suspend inline and catches
CancellationException separately before returning Result.success/failure.

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt`:
- Around line 234-259: Tabs rendered in the FeedTab.entries loop use Box +
debouncedClickable and only indicate selection via color, and the write Icon has
contentDescription = null; update accessibility by adding semantics/role: for
each tab Box (inside the FeedTab.entries loop where selected is computed from
selectedTab and onTabClick is invoked via debouncedClickable) add
Modifier.selectable(selected = selected, role = Role.Tab) or Modifier.semantics
{ selected = selected } so screen readers get the selection state and role; and
for the write action (where debouncedClickable calls onWriteClick and Icon
currently has contentDescription = null) provide a meaningful contentDescription
like "글 작성" (or use Modifier.semantics { contentDescription = "글 작성" }) so the
write button is announced.

---

Nitpick comments:
In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`:
- Around line 346-361: The addCompressedImages retry path currently retries
immediately; introduce a suspend delay with exponential backoff before retrying
to avoid tight retry loops: inside the viewModelScope.launch that calls
feedRepository.compressImages, compute a backoff delay (e.g., BASE_DELAY_MS *
2.pow(retryCount) capped by MAX_DELAY_MS), call
kotlinx.coroutines.delay(backoff) before invoking addCompressedImages(newImages,
retryCount + 1) (or refactor the recursion to a loop that delays between
attempts), keeping the existing MAX_RETRY_COUNT check and ensuring the delay
runs inside the coroutine so cancellation works; update constants or add
BASE_DELAY_MS and MAX_DELAY_MS as needed and reference addCompressedImages,
retryCount, MAX_RETRY_COUNT, viewModelScope.launch,
feedRepository.compressImages, and _attachedImages in your change.

In
`@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt`:
- Around line 253-287: Each report method (updateReportedSpoilerFeed,
updateReportedImpertinenceFeed, updateReportedSpoilerComment,
updateReportedImpertinenceComment) currently calls runCatching and drops the
result; update each to handle success and failure by chaining .onSuccess { ... }
and .onFailure { ... } on the runCatching result: onSuccess emit a UI event or
update a StateFlow/LiveData indicating report succeeded (e.g., post a
toast/snackbar or set a _reportResult/_uiEvent), and onFailure log the throwable
and emit an error UI event so the user sees failure; use the existing
viewModelScope and feedRepository.save* methods and ensure logging includes the
exception.

In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`:
- Line 53: 현재 public으로 선언된 `novels: MutableStateFlow<PagingData<NovelUiModel>>`
때문에 외부에서 상태를 덮어쓸 수 있으므로 `_novels`를 private MutableStateFlow로 변경하고 외부에는 읽기 전용
`StateFlow<PagingData<NovelUiModel>>` 타입의 `novels`만 노출하도록 수정하세요; 즉 클래스 내부에
`private val _novels = MutableStateFlow(PagingData.empty<NovelUiModel>())`를 두고
기존 `novels`를 `val novels: StateFlow<PagingData<NovelUiModel>> = _novels`로 바꿔 상태
캡슐화를 강제하세요.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 013e27da-83f9-4b22-ac19-3c4b029cb67a

📥 Commits

Reviewing files that changed from the base of the PR and between 0e646e2 and 3c07bff.

📒 Files selected for processing (29)
  • app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt
  • app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt
  • app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt
  • app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt
  • app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt
  • app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt
  • app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt
  • app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt
  • app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt
  • app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt
  • app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt
  • app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt
  • data/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.kt
  • data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt
  • data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt
  • data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt
  • data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt
  • data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt
  • domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt
💤 Files with no reviewable changes (3)
  • app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt
  • app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt
  • app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt
🚧 Files skipped from review as they are similar to previous changes (10)
  • feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt
  • data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt
  • app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt
  • app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt
  • app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt

Comment on lines +23 to +24
if (isAlreadyRated) {
userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData())
Copy link

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:

#!/bin/bash
set -euo pipefail

echo "== Repository structure =="
find . -type f -name "*.kt" | grep -i "repository\|entity" | head -20

echo
echo "== NovelRatingEntity definition =="
fd 'NovelRatingEntity\.kt$' --type f || rg -l 'class NovelRatingEntity' --type=kotlin || echo "Not found via fd, searching with rg..."
rg 'data class NovelRatingEntity|class NovelRatingEntity' -A 15 --type=kotlin

echo
echo "== UserNovelRepository.kt content (lines 1-50) =="
fd 'UserNovelRepository\.kt$' --type f -exec head -60 {} \;

echo
echo "== saveNovelRating function in UserNovelRepository =="
rg -n -B5 -A15 'fun saveNovelRating' --type=kotlin

Repository: Team-WSS/WSS-Android

Length of output: 1460


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Finding NovelRatingEntity =="
find . -name "*NovelRatingEntity*" -type f

echo
echo "== Finding UserNovelRepository =="
find . -name "UserNovelRepository*" -type f

Repository: Team-WSS/WSS-Android

Length of output: 275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NovelRatingEntity.kt =="
cat -n ./app/src/main/java/com/into/websoso/data/model/NovelRatingEntity.kt

echo
echo "== UserNovelRepository.kt (full file) =="
wc -l ./app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt

echo
echo "== UserNovelRepository.kt content =="
cat -n ./app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt

Repository: Team-WSS/WSS-Android

Length of output: 2049


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Call sites of saveNovelRating =="
rg -n -B3 -A3 'saveNovelRating' --type=kotlin app

echo
echo "== Check if novelId is always set before saveNovelRating call =="
rg -n -B10 'saveNovelRating' --type=kotlin app | head -80

Repository: Team-WSS/WSS-Android

Length of output: 4368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NovelRatingViewModel.kt around saveNovelRating (lines 290-330) =="
sed -n '290,330p' ./app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt

echo
echo "== All saveNovelRating calls with more context =="
rg -n -B15 -A5 'saveNovelRating' --type=kotlin app | grep -A20 'saveNovelRating'

Repository: Team-WSS/WSS-Android

Length of output: 5375


24번 줄의 !! 사용은 불필요한 크래시 경로를 남깁니다.

novelIdLong?로 선언된 nullable 타입이지만, !! 연산자로 강제 unbox되고 있습니다. 현재 호출 지점에서는 novel?.novel?.novelId ?: 0으로 기본값을 제공하지만, 타입 시스템이 isAlreadyRated == true일 때 novelId의 non-null을 강제하지 않으므로, 향후 다른 호출처나 변경 시 crash 가능성이 있습니다.

🔧 제안 수정
             if (isAlreadyRated) {
-                userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData())
+                val novelId = requireNotNull(novelRatingEntity.novelId) {
+                    "novelId is required when updating an existing rating"
+                }
+                userNovelApi.putNovelRating(novelId, novelRatingEntity.toData())
             } else {
                 userNovelApi.postNovelRating(novelRatingEntity.toData())
             }
📝 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
if (isAlreadyRated) {
userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData())
if (isAlreadyRated) {
val novelId = requireNotNull(novelRatingEntity.novelId) {
"novelId is required when updating an existing rating"
}
userNovelApi.putNovelRating(novelId, novelRatingEntity.toData())
} else {
userNovelApi.postNovelRating(novelRatingEntity.toData())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt`
around lines 23 - 24, The code uses a forced non-null (!!) on
novelRatingEntity.novelId in the block guarded by isAlreadyRated when calling
userNovelApi.putNovelRating; remove the unsafe !! and instead ensure a safe
non-null value by handling the nullable: e.g., check novelRatingEntity.novelId
with an if/let or use the elvis operator to provide a clear fallback or throw a
descriptive IllegalStateException before calling userNovelApi.putNovelRating
(update the logic in the same method in UserNovelRepository.kt where
isAlreadyRated is evaluated and the call to userNovelApi.putNovelRating occurs).

Comment on lines 149 to +164
private fun navigateToFeedEdit(feedId: Long) {
val feedContent =
feedViewModel.uiState.value.myFeedData.feeds.find { it.id == feedId }?.let { feed ->
EditFeedModel(
feedId = feed.id,
novelId = feed.novel?.id,
novelTitle = feed.novel?.title,
isSpoiler = feed.isSpoiler,
isPublic = feed.isPublic,
feedContent = feed.content,
feedCategory = feed.relevantCategories,
imageUrls = feed.imageUrls,
)
} ?: throw IllegalArgumentException()
updatedFeedViewModel.uiState.value.myFeedData.feeds
.find { it.id == feedId }
?.let { feed ->
EditFeedModel(
feedId = feed.id,
novelId = feed.novel?.id,
novelTitle = feed.novel?.title,
isSpoiler = feed.isSpoiler,
isPublic = feed.isPublic,
feedContent = feed.content,
feedCategory = feed.relevantCategoriesByKr,
imageUrls = feed.imageUrls,
)
} ?: throw IllegalArgumentException()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

편집 대상을 myFeedData에서만 찾으면 바로 크래시 날 수 있습니다.

onFirstItemClickisMyFeed만 보고 편집으로 보내는데, 해당 피드가 현재 탭에만 있고 myFeedData에는 아직 없을 수 있습니다. 그 경우 Line 164의 IllegalArgumentException()으로 앱이 종료됩니다. 현재 리스트에서 우선 찾거나, 못 찾으면 조용히 반환하거나 재조회로 보강하는 쪽이 안전합니다.

🛠️ 제안
     private fun navigateToFeedEdit(feedId: Long) {
-        val feedContent =
-            updatedFeedViewModel.uiState.value.myFeedData.feeds
-                .find { it.id == feedId }
-                ?.let { feed ->
-                    EditFeedModel(
-                        feedId = feed.id,
-                        novelId = feed.novel?.id,
-                        novelTitle = feed.novel?.title,
-                        isSpoiler = feed.isSpoiler,
-                        isPublic = feed.isPublic,
-                        feedContent = feed.content,
-                        feedCategory = feed.relevantCategoriesByKr,
-                        imageUrls = feed.imageUrls,
-                    )
-                } ?: throw IllegalArgumentException()
+        val state = updatedFeedViewModel.uiState.value
+        val feed =
+            (state.currentData.feeds + state.myFeedData.feeds)
+                .firstOrNull { it.id == feedId } ?: return
+
+        val feedContent =
+            EditFeedModel(
+                feedId = feed.id,
+                novelId = feed.novel?.id,
+                novelTitle = feed.novel?.title,
+                isSpoiler = feed.isSpoiler,
+                isPublic = feed.isPublic,
+                feedContent = feed.content,
+                feedCategory = feed.relevantCategoriesByKr,
+                imageUrls = feed.imageUrls,
+            )
 
         startActivity(CreateFeedActivity.getIntent(requireContext(), feedContent))
     }
🧰 Tools
🪛 detekt (1.23.8)

[warning] 164-164: A call to the default constructor of an exception was detected. Instead one of the constructor overloads should be called. This allows to provide more meaningful exceptions.

(detekt.exceptions.ThrowingExceptionsWithoutMessageOrCause)

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

In `@app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt` around lines
149 - 164, navigateToFeedEdit currently looks up the feed only in
updatedFeedViewModel.uiState.value.myFeedData and throws
IllegalArgumentException if not found, causing a crash when an editable item
exists in the visible list but not in myFeedData; change the lookup to first try
the current visible feed collection (e.g., the fragment's displayed feed list or
uiState.value.feedList/currentTabFeeds) and only then fall back to myFeedData,
and if still not found avoid throwing—either return silently or trigger a
refresh/reload (e.g., call the existing refresh method) instead of throwing;
update navigateToFeedEdit to handle a nullable result safely and proceed to
EditFeedModel creation only when the feed is found.

Comment on lines +96 to +127
fun saveEditedFeed(
feedId: Long,
relevantCategories: List<String>,
editedFeed: String,
novelId: Long?,
isSpoiler: Boolean,
isPublic: Boolean,
images: List<Uri>,
) {
updateFeedInLocalCache(feedId, editedFeed, relevantCategories, isSpoiler, isPublic)

scope.launch {
runCatching {
feedApi.putFeed(
feedId = feedId,
feedRequestDto = multiPartMapper.formatToMultipart<FeedRequestDto>(
target = FeedRequestDto(
relevantCategories = relevantCategories,
feedContent = editedFeed,
novelId = novelId,
isSpoiler = isSpoiler,
isPublic = isPublic,
),
partName = PART_NAME_FEED,
fileName = FILE_NAME_FEED_JSON,
),
images = images.map { multiPartMapper.formatToMultipart(it) },
)
}.onFailure {
Log.e("UpdatedFeedRepository", "Failed to sync edited feed", it)
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

편집 실패가 조용히 묻혀 로컬 상태만 남습니다.

여기는 캐시를 먼저 바꾼 뒤 서버 동기화를 백그라운드에서 수행하는데, 실패 시에는 Log.e만 남기고 끝납니다. 그러면 사용자는 수정이 저장된 것처럼 보이지만, 다음 재조회 시 서버 값으로 되돌아갈 수 있고 호출부도 롤백이나 재시도를 할 수 없습니다.

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 96 - 127, saveEditedFeed currently updates local cache via
updateFeedInLocalCache then launches a background sync to feedApi.putFeed and
swallows failures with Log.e, leaving callers unaware and the UI inconsistent on
server rollback; change saveEditedFeed to surface failures (e.g., return a
Result/Boolean or suspend and throw) or accept a callback so callers can handle
errors, and on failure undo the optimistic local change by calling
updateFeedInLocalCache with the original data (capture pre-edit state before
calling updateFeedInLocalCache) or enqueue a retry mechanism; ensure
feedApi.putFeed failures from the scope.launch/runCatching block are propagated
to the caller or trigger a rollback/retry path and keep the Log.e for
diagnostics.

Comment on lines +289 to +304
fun syncDirtyFeeds() {
if (dirtyFeedStates.isEmpty()) return

val syncMap = dirtyFeedStates.toMap()
dirtyFeedStates.clear()
originalFeedStates.clear()

scope.launch {
syncMap.forEach { (id, isLiked) ->
runCatching {
if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id)
}.onFailure {
Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it)
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

좋아요 동기화 내역을 너무 일찍 비워서 실패 복구가 불가능합니다.

synkMap을 만든 직후 dirtyFeedStatesoriginalFeedStatesclear()해서, 네트워크 실패가 나면 pending 변경이 그대로 유실됩니다. 게다가 snapshot 복사와 clear()가 원자적이지 않아서 이 메서드가 겹쳐 호출되면 같은 항목을 중복 전송할 수도 있습니다.

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 289 - 304, syncDirtyFeeds currently copies dirtyFeedStates to
syncMap and immediately clears dirtyFeedStates and originalFeedStates, which
loses pending changes on network failure and can cause duplicate sends on
concurrent calls; fix by making the snapshot-and-clear atomic (use a mutex or
synchronized block) and only clear/remove entries after they have been
successfully synced (e.g., iterate over the snapshot and on success remove the
id from dirtyFeedStates and originalFeedStates, or collect failures and put them
back), ensure scope.launch runs outside the lock so network IO doesn't hold the
mutex, and update syncDirtyFeeds, dirtyFeedStates, originalFeedStates, and the
scope.launch usage accordingly to prevent lost or duplicated like-syncs.

Comment on lines +314 to +347
suspend fun saveRemovedFeed(feedId: Long) {
runCatching {
feedApi.deleteFeed(feedId)
}.onSuccess {
removeFromFlow(_sosoAllFeeds, feedId)
removeFromFlow(_sosoRecommendedFeeds, feedId)
removeFromFlow(_myFeeds, feedId)
}
}

/**
* 피드를 스포일러로 신고합니다.
*/
suspend fun saveSpoilerFeed(feedId: Long) {
runCatching {
feedApi.postSpoilerFeed(feedId)
}.onSuccess {
markAsSpoilerInFlow(_sosoAllFeeds, feedId)
markAsSpoilerInFlow(_sosoRecommendedFeeds, feedId)
markAsSpoilerInFlow(_myFeeds, feedId)
}
}

/**
* 피드를 부적절한 게시물로 신고합니다.
*/
suspend fun saveImpertinenceFeed(feedId: Long) {
runCatching {
feedApi.postImpertinenceFeed(feedId)
}.onSuccess {
removeFromFlow(_sosoAllFeeds, feedId)
removeFromFlow(_sosoRecommendedFeeds, feedId)
removeFromFlow(_myFeeds, feedId)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

삭제/신고 실패를 호출자가 감지할 수 없습니다.

세 메서드 모두 예외를 내부에서 삼키고 그냥 Unit으로 돌아옵니다. 그래서 feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt의 Line 149-163 같은 호출부는 실패 시 에러 상태도 못 세우고 재시도 기회도 잃습니다. 성공 시에만 캐시를 바꾸고, 실패는 그대로 throw 하거나 Result로 반환하는 편이 안전합니다.

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 314 - 347, The three suspend functions saveRemovedFeed,
saveSpoilerFeed, and saveImpertinenceFeed currently swallow errors by using
runCatching without propagating failures; update them so callers can detect
failures by either rethrowing the caught exception or returning a Result: call
feedApi within runCatching, onSuccess keep the existing cache updates
(removeFromFlow/markAsSpoilerInFlow on
_sosoAllFeeds/_sosoRecommendedFeeds/_myFeeds) and in onFailure either rethrow
the Throwable (so the suspend function throws) or return the failure as
Result.failure(...) (and change the function signature to return Result<Unit>)
so the caller (e.g., UpdatedFeedViewModel) can observe errors and retry.

Comment on lines +55 to +58
getUserNovels(lastUserNovelId, currentFilter).also { result ->
_novelTotalCount.update {
result.getOrNull()?.userNovelCount ?: 0
}
Copy link

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으로 반영됩니다. 그러면 목록은 남아 있는데 total count만 갑자기 0으로 바뀌게 됩니다. 성공 응답일 때만 _novelTotalCount를 갱신하고, 실패 시에는 이전 값을 유지하는 쪽이 맞습니다.

수정 예시
-                                        _novelTotalCount.update {
-                                            result.getOrNull()?.userNovelCount ?: 0
-                                        }
+                                        result.getOrNull()?.userNovelCount?.let { totalCount ->
+                                            _novelTotalCount.value = totalCount
+                                        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`
around lines 55 - 58, The current code unconditionally updates _novelTotalCount
with result.getOrNull()?.userNovelCount ?: 0 which overwrites the previous total
on any failure; change the update so it only sets _novelTotalCount when
getUserNovels(...) returns a successful value (e.g., check result.isSuccess or
result.getOrNull() != null or use result.onSuccess) and otherwise leave the
existing _novelTotalCount untouched; locate the getUserNovels(...) call and the
_novelTotalCount.update block and wrap the update so it runs only for successful
results (use result.getOrNull()?.userNovelCount to guard the update).

Comment on lines +48 to +60
val convertedFeeds = myFeedsEntity.feeds.map { userFeed ->
userFeed.toFeedEntity(userProfile = profile, userId = myId)
}

feedRepository.updateMyFeedsCache(
feeds = convertedFeeds,
isRefreshed = isFeedRefreshed,
)

return Feeds(
category = "내 활동",
isLoadable = myFeedsEntity.isLoadable,
feeds = emptyList(),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

여기서 빈 feeds를 반환하면 내 피드 페이징이 앞으로 진행되지 않습니다.

feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt의 Line 123-125는 반환된 result.feeds로 다음 lastId를 계산합니다. 지금처럼 emptyList()를 돌려주면 isLoadable = true여도 lastId가 계속 0이라 다음 요청이 매번 첫 페이지 재조회로 고정됩니다.

🛠️ 제안
             return Feeds(
                 category = "내 활동",
                 isLoadable = myFeedsEntity.isLoadable,
-                feeds = emptyList(),
+                feeds = convertedFeeds.map { it.toDomain() },
             )
📝 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 convertedFeeds = myFeedsEntity.feeds.map { userFeed ->
userFeed.toFeedEntity(userProfile = profile, userId = myId)
}
feedRepository.updateMyFeedsCache(
feeds = convertedFeeds,
isRefreshed = isFeedRefreshed,
)
return Feeds(
category = "내 활동",
isLoadable = myFeedsEntity.isLoadable,
feeds = emptyList(),
val convertedFeeds = myFeedsEntity.feeds.map { userFeed ->
userFeed.toFeedEntity(userProfile = profile, userId = myId)
}
feedRepository.updateMyFeedsCache(
feeds = convertedFeeds,
isRefreshed = isFeedRefreshed,
)
return Feeds(
category = "내 활동",
isLoadable = myFeedsEntity.isLoadable,
feeds = convertedFeeds.map { it.toDomain() },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt`
around lines 48 - 60, The returned Feeds object currently uses feeds =
emptyList() which breaks pagination; change the return to include the converted
feeds (use convertedFeeds from myFeedsEntity.feeds.map) so
UpdatedGetMyFeedsUseCase returns the actual list used by the consumer to compute
lastId; ensure you still call feedRepository.updateMyFeedsCache(feeds =
convertedFeeds, ...) and return Feeds(category = "내 활동", isLoadable =
myFeedsEntity.isLoadable, feeds = convertedFeeds) so the viewmodel can compute
next lastId correctly.

onFirstItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit,
onSecondItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit,
onRefreshPull: () -> Unit,
onWriteFeedClick: () -> Unit,
Copy link

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:

# First, examine the FeedScreen.kt file to understand the callback structure
fd -e "FeedScreen.kt" --type f

Repository: Team-WSS/WSS-Android

Length of output: 46


🏁 Script executed:

# Get the structure and size of the file
wc -l feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 138


🏁 Script executed:

# Read FeedScreen.kt focusing on the relevant sections
sed -n '60,85p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 1126


🏁 Script executed:

# Check lines 170-185 for the other referenced area
sed -n '170,185p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 681


🏁 Script executed:

# Check FeedRoute.kt for callback wiring
fd -e "FeedRoute.kt" --type f

Repository: Team-WSS/WSS-Android

Length of output: 46


🏁 Script executed:

# Check UpdateFeedRoute.kt for callback wiring
fd -e "UpdateFeedRoute.kt" --type f

Repository: Team-WSS/WSS-Android

Length of output: 46


🏁 Script executed:

# Search for route files more broadly
find . -name "*FeedRoute.kt" -type f 2>/dev/null | head -20

Repository: Team-WSS/WSS-Android

Length of output: 213


🏁 Script executed:

# Read the full context around the parameter definition and usage
sed -n '65,82p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 724


🏁 Script executed:

# Get more context around line 177
sed -n '172,190p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 788


🏁 Script executed:

# Search for FeedRoute and UpdateFeedRoute in all files
rg "FeedRoute|UpdateFeedRoute" --type kt -l

Repository: Team-WSS/WSS-Android

Length of output: 90


🏁 Script executed:

# Search for routes without type filter
rg "FeedRoute|UpdateFeedRoute" --files-with-matches

Repository: Team-WSS/WSS-Android

Length of output: 273


🏁 Script executed:

# Read FeedRoute.kt to check callback wiring
cat -n ./feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt | head -100

Repository: Team-WSS/WSS-Android

Length of output: 3565


🏁 Script executed:

# Read UpdateFeedRoute.kt to check callback wiring
cat -n ./feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt | head -100

Repository: Team-WSS/WSS-Android

Length of output: 3593


🏁 Script executed:

# Search for the FeedScreen calls to see what callbacks are passed
rg "FeedScreen\(" --context 5 ./feature/feed/src/main/java/com/into/websoso/feature/feed/

Repository: Team-WSS/WSS-Android

Length of output: 4797


🏁 Script executed:

# Search for FeedTabRow to check for Tab replacement and icon accessibility
fd -name "FeedTabRow*" -type f

Repository: Team-WSS/WSS-Android

Length of output: 294


🏁 Script executed:

# If not found, search for FeedTabRow in all kotlin files
rg "fun FeedTabRow" --context 10

Repository: Team-WSS/WSS-Android

Length of output: 2096


🏁 Script executed:

# Also search for the write icon implementation
rg "Icon.*write\|write.*Icon" --context 5 ./feature/feed/src/main/java/com/into/websoso/feature/feed/

Repository: Team-WSS/WSS-Android

Length of output: 46


🏁 Script executed:

# Get the full FeedTabRow implementation
sed -n '200,270p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 2304


🏁 Script executed:

# Search for Icon and contentDescription in FeedScreen
rg "Icon\(" feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt -A 2 -B 2

Repository: Team-WSS/WSS-Android

Length of output: 887


🏁 Script executed:

# Get more lines from FeedTabRow - search for where it ends
sed -n '195,292p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt

Repository: Team-WSS/WSS-Android

Length of output: 3081


쓰기 콜백을 분리한 뒤 route별 배선이 이미 어긋나 있습니다.

FeedSectiononWriteFeedClick을 사용하지만, FeedRoute는 이를 빈 람다로 전달하고 (onWriteFeedClick = { }) UpdateFeedRoute만 실제 핸들러를 전달합니다 (onWriteFeedClick = onWriteFeedClick). FeedRoute@Deprecated 표시되었음에도 여전히 존재하므로, 이 경로가 라우팅되면 같은 화면의 쓰기 진입점 중 하나가 no-op이 됩니다.

추가로, FeedTabRow의 연필 아이콘이 contentDescription = null로 설정되어 있어 스크린 리더 접근성이 누락되었습니다.

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

In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt` at
line 69, FeedSection's write callback is being dropped because FeedRoute passes
onWriteFeedClick = { } while only UpdateFeedRoute forwards the real
onWriteFeedClick; update FeedRoute (or remove the deprecated route) so it
forwards the provided onWriteFeedClick into FeedSection/UpdateFeedRoute instead
of a no-op, ensuring the same handler is invoked for the write entry point
(reference FeedRoute, UpdateFeedRoute, FeedSection, onWriteFeedClick). Also fix
accessibility in FeedTabRow by giving the pencil icon a meaningful
contentDescription (use an appropriate string resource or passed label rather
than null) so screen readers announce the action (reference FeedTabRow and
contentDescription).

Comment on lines +90 to +130
fun fetchNextPage(feedId: Long? = null) {
val state = uiState.value
val current = state.currentData
val lastFeedId = feedId ?: current.lastId

if (state.loading || (!current.isLoadable && lastFeedId != 0L)) return

_uiState.update { it.copy(loading = true) }

viewModelScope.launch {
runCatching {
when (state.selectedTab) {
FeedTab.MY_FEED -> {
getMyFeedsUseCase(
lastFeedId = lastFeedId,
genres = state.currentFilter.selectedGenres.map { it.tag },
isVisible = state.currentFilter.isVisible,
sortCriteria = state.myFeedData.sort.name
.uppercase(),
isUnVisible = state.currentFilter.isUnVisible,
)
}

FeedTab.SOSO_FEED -> {
getFeedsUseCase(
feedsOption = state.sosoCategory.name.uppercase(),
lastFeedId = lastFeedId,
)
}
}
}.onSuccess { result ->
_uiState.update { currentState ->
val updatedSource = currentState.currentData.copy(
lastId = result.isLoadable.let {
if (it) result.feeds.lastOrNull()?.id ?: 0 else 0
},
isLoadable = result.isLoadable,
)
currentState
.updateCurrentSource(updatedSource)
.copy(loading = false, isRefreshing = false)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

응답이 돌아올 때 현재 탭이 바뀌어 있으면 다른 소스의 페이징 상태를 덮어씁니다.

요청은 state.selectedTab/state.sosoCategory 기준으로 시작하는데, 성공 처리에서는 currentState.currentDatalastIdisLoadable을 씁니다. 사용자가 로딩 중 탭이나 카테고리를 바꾸면 이전 요청의 응답으로 현재 소스의 페이징 상태가 덮여서 상태가 섞일 수 있으니, 요청 시점의 source key를 캡처해서 그 소스만 갱신해야 합니다.

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

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt`
around lines 90 - 130, The fetchNextPage flow updates currentData
(lastId/isLoadable) based on the response but doesn't guard against the user
changing selectedTab/sosoCategory while the request is in-flight, causing
cross-source clobbering; modify fetchNextPage to capture a request-specific
source key (e.g., selectedTab and sosoCategory or a derived sourceId) before
launching the network call, pass/use that key inside the runCatching/onSuccess
block, and when applying the response only update the matching source via
updateCurrentSource (or skip applying if
currentState.selectedTab/currentState.sosoCategory no longer equals the captured
key) so only the source that initiated the request gets its lastId/isLoadable
changed.

Comment on lines +69 to +80
private fun loadLibrary() {
pagingJob?.cancel()

pagingJob = viewModelScope.launch {
libraryRepository
.getLibraryFlow()
.map { pagingData -> pagingData.map(NovelEntity::toUiModel) }
.cachedIn(viewModelScope)
.collect { result ->
novels.update { result }
}
}
Copy link

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:

find . -type f -name "LibraryViewModel.kt" | head -20

Repository: Team-WSS/WSS-Android

Length of output: 148


🏁 Script executed:

find . -type f -name "LibraryFragment.kt" | head -20

Repository: Team-WSS/WSS-Android

Length of output: 135


🏁 Script executed:

cat -n ./feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt

Repository: Team-WSS/WSS-Android

Length of output: 7783


🏁 Script executed:

cat -n ./app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt | head -100

Repository: Team-WSS/WSS-Android

Length of output: 2978


cachedIn(viewModelScope)refresh() 경로에서 반복 생성하면 캐시가 누적됩니다.

onResume()마다 refresh()를 호출하고 있는데, 현재 구조는 그때마다 새 cachedIn(viewModelScope) 체인을 만듭니다. pagingJob?.cancel()은 수집 작업만 취소할 뿐, cachedIn() 연산자 자체와 그에 묶인 이전의 paging 소스 구독은 정리되지 않습니다. 뷰모델 스코프 내에 여러 캐시된 플로우가 쌓이면서 메모리 누수와 중복 데이터 소스 구독이 발생할 수 있습니다. cachedIn() 캐시는 한 번만 만들고, 새로고침은 별도의 trigger(예: pager 무효화, 별도 명령어 채널)로 upstream을 재생성하는 구조로 변경하세요.

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

In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`
around lines 69 - 80, loadLibrary currently recreates a new
cachedIn(viewModelScope) chain each time (canceling only the collector via
pagingJob), causing accumulated caches; instead create a single cached flow once
in the ViewModel (e.g. val cachedLibraryFlow =
libraryRepository.getLibraryFlow().map(...).cachedIn(viewModelScope)) and have
loadLibrary/collector subscribe to that cachedLibraryFlow, cancelling/
restarting only the collector (pagingJob) as needed; for refresh, do not
recreate cachedIn—either expose a pager.invalidate() /
repository.invalidatePagingSource() call or add a separate refresh trigger (e.g.
MutableSharedFlow<Unit> consumed via flatMapLatest/switchMapLatest upstream of
getLibraryFlow()) so upstream paging source is re-created without creating new
cachedIn chains (adjust references: loadLibrary, pagingJob,
libraryRepository.getLibraryFlow(), cachedIn(viewModelScope), novels.update).

Copy link

@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: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt`:
- Around line 44-50: The current saveRemovedFeed function swallows all API
errors by using runCatching and never propagating failures; change it so
feedApi.deleteFeed(feedId) is invoked directly (or rethrow the caught exception
in runCatching.onFailure) and only call _cachedFeeds.removeIf { it.id == feedId
} after a successful delete; reference saveRemovedFeed,
feedApi.deleteFeed(feedId) and _cachedFeeds.removeIf to locate and update the
logic so exceptions bubble to callers and the cache is updated only on success.

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`:
- Around line 54-56: The current logic only filters feeds against existingIds so
duplicate ids inside the incoming page (result.feeds) will still be added;
update the flow in FeedRepository so you first deduplicate result.feeds by id
(e.g., build a local Map/Set from result.feeds or iterate and keep only the
first occurrence), then compute newUniqueFeeds by filtering those deduplicated
feeds against existingIds and add them to targetCache, ensuring you also update
the id set (existingIds or a mutable Set) as you accept items so duplicates
within the same page are prevented.
- Around line 66-74: The runCatching block in saveRemovedFeed currently swallows
errors (calls feedApi.deleteFeed and only logs onFailure), so change it to
propagate failures: either rethrow the caught exception after logging inside
onFailure or change saveRemovedFeed to return a Result/throws and propagate the
failure to callers; locate the runCatching around feedApi.deleteFeed (and the
cachedFeeds/cachedRecommendedFeeds removals) and implement one of these two
approaches so callers can handle rollback or show errors.
- Around line 19-20: FeedRepository's singleton cachedFeeds and
cachedRecommendedFeeds are mutated concurrently in fetchFeeds() and
saveRemovedFeed(), causing race conditions; protect all cache reads/writes by
introducing a dedicated lock (e.g., private val cacheLock = Any()) and wrap
every mutation and compound read-modify operation on cachedFeeds and
cachedRecommendedFeeds in a synchronized(cacheLock) block (apply this to
fetchFeeds(), saveRemovedFeed() and any other methods touching those lists) so
clear/addAll/removeIf operate as an atomic critical section; alternatively swap
to a thread-safe collection and still synchronize compound updates to preserve
ordering and atomicity.

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt`:
- Line 58: The container is set to .width(width = 180.dp) but the internal Text
items use .width(width = 192.dp), causing potential overflow; update the Text
modifiers in FeedMoreMenu (the Text composables that currently use
.width(192.dp)) to match the container width (use .width(180.dp)) or switch both
container and Text to use match-parent behavior (e.g., fillMaxWidth()) so the
widths are consistent and no clipping occurs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8c901f3e-0dbe-4cdc-a073-9460a42870b4

📥 Commits

Reviewing files that changed from the base of the PR and between 3c07bff and d7b7247.

📒 Files selected for processing (5)
  • app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt
  • data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt
  • domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetFeedsUseCase.kt
  • feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt
  • feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt

Comment on lines +44 to 50
suspend fun saveRemovedFeed(feedId: Long) {
runCatching {
feedApi.deleteFeed(feedId)
}.onSuccess {
_cachedFeeds.removeIf { it.id == feedId }

val novel = novelId?.let { id ->
libraryLocalDataSource.selectNovelByNovelId(id)
}

if (novel != null) {
val updatedNovel = novel.copy(myFeeds = novel.myFeeds.filterNot { it == content })
libraryLocalDataSource.insertNovel(updatedNovel)
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

삭제 실패를 삼키지 마세요.

지금 구현은 4xx/5xx/네트워크 실패를 모두 무시해서 호출부가 삭제 실패를 감지할 수 없습니다. API 예외를 그대로 전파하고, 캐시는 성공 후에만 갱신하는 편이 안전합니다.

수정 예시
         suspend fun saveRemovedFeed(feedId: Long) {
-            runCatching {
-                feedApi.deleteFeed(feedId)
-            }.onSuccess {
-                _cachedFeeds.removeIf { it.id == feedId }
-            }
+            feedApi.deleteFeed(feedId)
+            _cachedFeeds.removeIf { it.id == feedId }
         }
📝 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
suspend fun saveRemovedFeed(feedId: Long) {
runCatching {
feedApi.deleteFeed(feedId)
}.onSuccess {
_cachedFeeds.removeIf { it.id == feedId }
val novel = novelId?.let { id ->
libraryLocalDataSource.selectNovelByNovelId(id)
}
if (novel != null) {
val updatedNovel = novel.copy(myFeeds = novel.myFeeds.filterNot { it == content })
libraryLocalDataSource.insertNovel(updatedNovel)
}
}
}
suspend fun saveRemovedFeed(feedId: Long) {
feedApi.deleteFeed(feedId)
_cachedFeeds.removeIf { it.id == feedId }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt` around
lines 44 - 50, The current saveRemovedFeed function swallows all API errors by
using runCatching and never propagating failures; change it so
feedApi.deleteFeed(feedId) is invoked directly (or rethrow the caught exception
in runCatching.onFailure) and only call _cachedFeeds.removeIf { it.id == feedId
} after a successful delete; reference saveRemovedFeed,
feedApi.deleteFeed(feedId) and _cachedFeeds.removeIf to locate and update the
logic so exceptions bubble to callers and the cache is updated only on success.

Comment on lines +19 to +20
private val cachedFeeds = mutableListOf<FeedEntity>()
private val cachedRecommendedFeeds = mutableListOf<FeedEntity>()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

싱글톤 캐시가 동시 접근에 안전하지 않습니다.

FeedRepository@Singleton인데 fetchFeeds()saveRemovedFeed()가 같은 MutableList를 락 없이 수정합니다. 새로고침/페이징/삭제가 겹치면 clear/addAll/removeIf가 서로 엇갈려 캐시가 유실되거나 순서가 꼬일 수 있으니, 캐시 갱신 구간을 하나의 임계영역으로 묶는 편이 안전합니다.

Also applies to: 48-56, 69-71

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 19 - 20, FeedRepository's singleton cachedFeeds and
cachedRecommendedFeeds are mutated concurrently in fetchFeeds() and
saveRemovedFeed(), causing race conditions; protect all cache reads/writes by
introducing a dedicated lock (e.g., private val cacheLock = Any()) and wrap
every mutation and compound read-modify operation on cachedFeeds and
cachedRecommendedFeeds in a synchronized(cacheLock) block (apply this to
fetchFeeds(), saveRemovedFeed() and any other methods touching those lists) so
clear/addAll/removeIf operate as an atomic critical section; alternatively swap
to a thread-safe collection and still synchronize compound updates to preserve
ordering and atomicity.

Comment on lines +54 to +56
val existingIds = targetCache.map { it.id }.toSet()
val newUniqueFeeds = result.feeds.filterNot { it.id in existingIds }
targetCache.addAll(newUniqueFeeds)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

한 페이지 안의 중복 feed는 그대로 들어옵니다.

여기서는 기존 캐시에 있던 ID만 걸러서, 서버가 같은 응답 안에 동일한 id를 두 번 내려주면 둘 다 targetCache에 추가됩니다. 응답 자체도 ID 기준으로 한 번 더 정규화하거나, 필터링하면서 Set을 함께 갱신해 주세요.

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 54 - 56, The current logic only filters feeds against existingIds
so duplicate ids inside the incoming page (result.feeds) will still be added;
update the flow in FeedRepository so you first deduplicate result.feeds by id
(e.g., build a local Map/Set from result.feeds or iterate and keep only the
first occurrence), then compute newUniqueFeeds by filtering those deduplicated
feeds against existingIds and add them to targetCache, ensuring you also update
the id set (existingIds or a mutable Set) as you accept items so duplicates
within the same page are prevented.

Comment on lines +66 to 74
runCatching {
feedApi.deleteFeed(feedId)
}.onSuccess {
// 모든 캐시에서 해당 피드 제거 (안전하게 두 곳 모두 확인 가능)
cachedFeeds.removeIf { it.id == feedId }
cachedRecommendedFeeds.removeIf { it.id == feedId }
}.onFailure {
Log.d("FeedRepository", "saveRemovedFeed 함수 failed : ${it.message}")
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

삭제 실패를 삼키지 않는 편이 안전합니다.

지금은 실패 시 로그만 남기고 정상 복귀해서, 호출부가 에러 표시나 optimistic UI 롤백을 할 기회를 잃습니다. 로그 후 예외를 다시 던지거나 Result를 반환해서 실패를 상위 레이어로 전달해 주세요.

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

In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 66 - 74, The runCatching block in saveRemovedFeed currently
swallows errors (calls feedApi.deleteFeed and only logs onFailure), so change it
to propagate failures: either rethrow the caught exception after logging inside
onFailure or change saveRemovedFeed to return a Result/throws and propagate the
failure to callers; locate the runCatching around feedApi.deleteFeed (and the
cachedFeeds/cachedRecommendedFeeds removals) and implement one of these two
approaches so callers can handle rollback or show errors.

shape = RoundedCornerShape(size = 12.dp),
)
.width(IntrinsicSize.Max),
).width(width = 180.dp),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

컨테이너와 텍스트 아이템 간의 너비 불일치

컨테이너의 너비가 180.dp로 설정되어 있지만 (Line 58), 내부 Text 아이템들은 192.dp로 설정되어 있습니다 (Line 67). 텍스트가 컨테이너보다 12dp 더 넓어 레이아웃 오버플로우 또는 클리핑 문제가 발생할 수 있습니다.

🔧 너비 일치를 위한 수정 제안
                 Text(
                     text = label,
                     textAlign = TextAlign.Center,
                     style = WebsosoTheme.typography.body2,
                     color = if (isMyFeed) Black else Secondary100,
                     modifier = Modifier
-                        .width(width = 192.dp)
+                        .width(width = 180.dp)
                         .debouncedClickable {

또는 fillMaxWidth()를 사용하여 부모 너비에 맞추는 방법도 고려해 보세요.

Also applies to: 67-67

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

In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt`
at line 58, The container is set to .width(width = 180.dp) but the internal Text
items use .width(width = 192.dp), causing potential overflow; update the Text
modifiers in FeedMoreMenu (the Text composables that currently use
.width(192.dp)) to match the container width (use .width(180.dp)) or switch both
container and Text to use match-parent behavior (e.g., fillMaxWidth()) so the
widths are consistent and no clipping occurs.

@m6z1 m6z1 merged commit ebfd7f6 into develop Mar 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants