Skip to content

[3단계 - Navigation & Flow | 4단계 - 주문] 모스 미션 제출합니다.#177

Open
katie0109 wants to merge 39 commits into
woowacourse:katie0109from
katie0109:step3
Open

[3단계 - Navigation & Flow | 4단계 - 주문] 모스 미션 제출합니다.#177
katie0109 wants to merge 39 commits into
woowacourse:katie0109from
katie0109:step3

Conversation

@katie0109
Copy link
Copy Markdown

@katie0109 katie0109 commented May 25, 2026

셀프 체크리스트

  • 프로그램이 정상적으로 작동하는가?
  • 모든 테스트가 통과하는가?
  • 이전에 받은 피드백을 모두 반영하였는가?
  • 코딩 스타일 가이드를 준수하였는가?
    • IDE 코드 자동 정렬을 적용하였는가?
    • 린트 검사를 통과하였는가?
  • 프로그래밍 요구 사항을 준수하였는가?
  • README.md에 기능 목록을 정리하고 명확히 기술하였는가?

어떤 부분에 집중하여 리뷰해야 할까요?

  • 이번 3,4단계 미션에서는 알림 설정과 관련해 상태 관리 방식, 책임 분리, 그리고 사용자 설정이 실제 동작에 반영되는 흐름에 집중해서 구현했습니다. 특히 설정값이 화면 상태에만 머무르지 않고 알림 동작과 일관되게 연결되는지 확인하고 싶습니다.
    리뷰에서는 알림 설정의 상태를 어디에서 관리하는 것이 적절한지 UI, ViewModel, 도메인 로직의 책임 분리가 자연스러운지 설정 변경이 실제 알림 동작에 반영되는 구조가 타당한지 를 중심으로 봐주시면 감사하겠습니다.🙇‍♀️

코드 리뷰 커뮤니케이션

  • 이번 구현에서는 쿠폰 할인 금액이 정확히 계산되도록 여러 부분을 함께 수정했습니다. 특히 CouponMapper는 다른 mapper와 달리 object로 구현했는데, 쿠폰 타입 정규화와 시간 파싱처럼 매핑 단계에서 함께 처리할 책임이 있다고 판단했습니다. 다만 코드베이스 전반의 스타일과 비교했을 때 이 선택이 적절했는지 의견 부탁드립니다.
  • 결제/쿠폰 계산은 사용자가 선택한 상품만 대상으로 해야 해서 selectedItemIds로 필터링하도록 구현했습니다.
    현재는 선택 상태를 DB에 따로 저장하지 않아서, 계산이 필요한 시점마다 장바구니 전체 목록에서 선택된 ID에 해당하는 상품만 다시 걸러내고 있습니다. 이 방식이 괜찮은지, 아니면 선택 상태를 별도로 저장하는 편이 더 나은지 궁금합니다.

스크린샷

구분 구매 상품 쿠폰 적용 쿠폰 가격 계산 미결제 알림 알림 설정
화면 Screenshot_20260525_145343_Shopping Screenshot_20260525_145356_Shopping Screenshot_20260525_145400_Shopping Screenshot_20260525_145506_Shopping Screenshot_20260525_145336_Shopping

katie0109 added 30 commits May 22, 2026 13:46
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4f2964d0-4f4b-4fd6-85e4-d0a6ad5be740

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

이 PR은 Android 쇼핑 앱의 주문 결제 기능을 구현하고 Navigation 아키텍처를 재설계하는 대규모 변경입니다. 쿠폰 도메인 모델부터 데이터 계층, 결제 UI 화면까지 일관된 결제 시스템을 추가하고, Activity 기반의 화면 전환을 Jetpack Compose Navigation으로 통합합니다. SharedFlow 기반의 UI 이벤트 처리를 도입하여 ViewModel에서 화면 간 네비게이션을 제어하며, 결제 알림 및 리마인더 시스템을 구현합니다. 또한 MockServer 및 기존 Activity 클래스(ProductListActivity, ProductDetailActivity, CartActivity)를 제거하고, 카트 저장소에 변경 이벤트 스트림을 추가하여 화면 간 상태 동기화를 강화합니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR 설명이 기본 구조를 갖추었으나 필수 섹션이 불완전합니다. 체크리스트는 완료했으나 리뷰 중점사항이 일반적이고 코드 리뷰 커뮤니케이션 섹션에서 구체적 설계 선택에 대한 질문만 있을 뿐 변경 사항의 명확한 요약이 부족합니다. 변경 사항의 구체적 내용(쿠폰 계산 로직, 알림 설정 메커니즘, Navigation 구조 변경 등)을 더 명확히 설명하고, 각 주요 변경의 의도와 구현 방식을 요약해 주시기 바랍니다.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 3단계(Navigation & Flow)와 4단계(주문/결제/알림) 미션 내용을 명확하게 요약하고 있으며, 전체 변경사항의 핵심을 잘 나타냅니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 17

🧹 Nitpick comments (15)
app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponTypes.kt (1)

14-20: ⚡ Quick win

쿠폰 코드 매핑 전에 입력 정규화를 한 단계 추가하는 게 안전합니다.

Line 14-20은 대문자 완전일치만 허용해서, "fixed5000" 또는 공백 포함 입력이 UNKNOWN으로 분류될 수 있습니다. trim + uppercase 같은 정규화 단계 분리를 고려해 보시겠어요?

As per coding guidelines, "Break problems into smaller, manageable steps to help think through them logically."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponTypes.kt`
around lines 14 - 20, The fromCode function in CouponTypes.kt currently matches
codes case-sensitively and will treat " fixed5000 " or "fixed5000" as UNKNOWN;
normalize the input first by applying trim() and uppercase (e.g., val normalized
= code.trim().uppercase()) and then use normalized in the when expression to map
to FIXED5000, BOGO, FREESHIPPING, MIRACLESALE, or UNKNOWN; update any tests or
callers if they relied on exact-case behavior.
app/src/main/java/woowacourse/shopping/ui/payment/PaymentUiState.kt (2)

15-15: ⚡ Quick win

배송비가 항상 3000원인가요?

shippingFee가 기본값 3000으로 하드코딩되어 있습니다.

생각해 볼 점:

  1. 배송비가 주문 금액, 지역, 프로모션 등에 따라 달라질 수 있나요?
  2. 만약 변동 가능하다면, 이 값을 어디서 계산/주입하는 것이 적절할까요?
  3. 상수로 관리하는 것이 낫다면, ShoppingConstants 같은 곳으로 추출하는 건 어떨까요?

현재는 UI State에 비즈니스 로직(배송비 정책)이 포함된 형태입니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentUiState.kt` at line
15, PaymentUiState currently hardcodes shippingFee = 3000 in the data class;
remove the business-logic default and instead accept shippingFee as a provided
value (no hardcoded default) so the UI state is populated by the domain/service
layer that calculates fees, or if it truly is a constant move the value to a
dedicated constant (e.g., ShoppingConstants.SHIPPING_FEE) and reference that
constant from PaymentUiState; update any creators/factories that instantiate
PaymentUiState (tests, viewmodels) to pass the correct shippingFee from the
order calculation or constant.

9-17: 💤 Low value

Success 상태의 기본값들이 의미가 있을까요?

Success 데이터 클래스에서 subtotal, couponDiscount, totalPrice가 모두 0으로 기본값이 설정되어 있습니다.

질문:

  1. 이 값들은 계산되어야 하는 값들인가요, 아니면 초기 상태로 0이 의미가 있나요?
  2. Success 상태가 생성될 때 이미 계산된 값들로 채워져야 하는 건 아닐까요?
  3. 기본값을 제공하는 것이 실수로 계산되지 않은 상태를 허용하게 만들 위험은 없을까요?

생각해 보세요: UI 상태가 항상 유효한 값을 가지도록 강제하는 것과, 기본값으로 유연성을 제공하는 것 사이의 트레이드오프는 무엇인가요?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentUiState.kt` around
lines 9 - 17, The Success data class currently supplies default zeros for
subtotal, couponDiscount, and totalPrice which can mask missing calculations;
remove those default values (make subtotal, couponDiscount, totalPrice
non-optional constructor parameters) or provide a single factory method (e.g., a
Success.Companion.createFrom(cartItems, availableCoupons, selectedCoupon,
shippingFee)) that computes and returns a fully populated Success instance so
callers cannot accidentally construct an incomplete UI state; update call sites
that instantiate Success to use the computed values or the factory.
app/src/main/java/woowacourse/shopping/domain/exception/ProductException.kt (1)

20-22: 💤 Low value

data object에서 object로 변경한 의도를 생각해 보셨나요?

Sealed hierarchy 내의 다른 타입들(ServerError, NetworkError, NotFound)은 data class로 정의되어 있는데, EmptyBody만 일반 object로 변경되었습니다.

다음을 고려해 보세요:

  1. data object와 일반 object의 차이점은 무엇인가요? (힌트: toString(), equals(), hashCode() 생성)
  2. EmptyBody가 프로퍼티를 갖지 않는다면, data object의 이점이 여전히 있을까요?
  3. Sealed hierarchy 내에서 일관된 패턴을 유지하는 것이 중요할까요?

관련 문서: Kotlin data objects

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/domain/exception/ProductException.kt`
around lines 20 - 22, EmptyBody is declared as a plain object while other sealed
ProductException subtypes (ServerError, NetworkError, NotFound) are data
classes; change EmptyBody to a data object (i.e., make it a "data object
EmptyBody : ProductException(...)") so it benefits from generated
toString/equals/hashCode and maintains a consistent sealed-hierarchy pattern
with the other types.
app/src/main/java/woowacourse/shopping/constant/ShoppingColor.kt (1)

9-14: ⚖️ Poor tradeoff

앱의 테마 시스템에 대해 고려해 보셨나요?

6개의 결제 관련 색상 상수가 하드코딩된 값으로 추가되었습니다. 기존 코드도 같은 패턴을 따르고 있긴 하지만, 생각해 볼 점이 있습니다:

질문:

  1. 다크 모드를 지원할 계획이 있나요?
  2. Material3의 ColorScheme를 사용하면 어떤 이점이 있을까요?
  3. 색상 값이 여러 곳에서 사용될 때 중앙 집중식 테마 관리의 장점은 무엇일까요?

참고: Material3 Color System

현재 구현은 기존 코드베이스와 일관성이 있지만, 향후 유지보수성을 위해 테마 시스템 도입을 고려해 볼 가치가 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/constant/ShoppingColor.kt` around
lines 9 - 14, The six hardcoded ARGB color constants in ShoppingColor.kt
(PAYMENT_BUTTON_COLOR, PAYMENT_DIVIDER_COLOR, PAYMENT_CARD_BORDER_COLOR,
PAYMENT_SUMMARY_BACKGROUND_COLOR, PAYMENT_DESCRIPTION_GRAY, PAYMENT_TITLE_GRAY)
should be moved out of raw ints and integrated into the app theme: create named
color tokens in the central theme (light/dark variants) and expose them via
Material3 ColorScheme or your Theme object, then replace usages of these
constants to read from the ColorScheme (or Theme.colors) so dark mode and
centralized overrides work; update any callers that reference these constants to
use the themed color tokens instead.
app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderReceiver.kt (1)

9-20: ⚡ Quick win

BroadcastReceiver의 실행 환경을 고려해 보셨나요?

BroadcastReceiveronReceive는 메인 스레드에서 실행되며 약 10초의 제한 시간이 있습니다. 현재 구현에서 고려해 볼 점들:

  1. NotificationHelper.createNotificationChannelshowPaymentReminder 호출이 예외를 발생시키면 어떻게 될까요?
  2. 이 두 메서드가 메인 스레드에서 안전하게 실행될 수 있을 만큼 빠른지 확인해 보셨나요?

다음을 생각해 보세요:

  • 알림 생성 중 예외가 발생할 수 있는 시나리오는 무엇일까요? (예: 권한 거부, 시스템 리소스 부족)
  • 이러한 예외를 어떻게 처리하면 좋을까요?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderReceiver.kt`
around lines 9 - 20, onReceive in PaymentReminderReceiver runs on the main
thread with a short timeout, so wrap the notification work in a background task
and add robust exception handling: call goAsync() inside
PaymentReminderReceiver.onReceive to obtain a PendingResult, then offload
NotificationHelper.createNotificationChannel and
NotificationHelper.showPaymentReminder to a background thread/coroutine (or
WorkManager) and call finish() on the PendingResult when done; surround the
background work with try/catch to handle and log exceptions (e.g., permission
issues, resource errors) and ensure NotificationSettings.isNotificationEnabled
is checked before dispatching work; also validate the intent extra
(PaymentReminderScheduler.EXTRA_SELECTED_ITEM_IDS) and handle null/empty safely.
app/src/main/java/woowacourse/shopping/data/remote/dto/CouponResponseDto.kt (1)

10-10: 💤 Low value

쿠폰 타입의 타입 안정성에 대해 생각해 보셨나요?

discountType이 현재 String으로 정의되어 있습니다. PR 설명에서 CouponMapper가 타입 정규화를 처리한다고 하셨는데요:

질문:

  1. String 타입을 사용할 때의 장단점은 무엇인가요?
  2. 잘못된 문자열 값이 들어올 경우 어디서 감지되나요? (DTO 레벨? Mapper 레벨? Domain 레벨?)
  3. 매핑 단계에서 변환한다고 하셨는데, 그렇다면 DTO가 String을 받는 현재 설계가 적절해 보이시나요?

생각해 볼 점: DTO는 서버 응답을 있는 그대로 표현하고, 매퍼에서 도메인 타입으로 변환하는 접근이 합리적인지 검토해 보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/data/remote/dto/CouponResponseDto.kt`
at line 10, The DTO field discountType is currently a raw String which risks
invalid values slipping past compile-time; update the design by either 1) making
CouponResponseDto use a dedicated enum (e.g., DiscountType) to enforce
type-safety at the DTO boundary, or 2) if the API can return arbitrary strings,
keep String but add strict validation and clear error handling in CouponMapper
(the mapping function that converts CouponResponseDto -> domain coupon) to
translate/normalize strings into the domain DiscountType enum and surface
mapping errors early; locate CouponResponseDto and the CouponMapper mapping
logic and implement one of these approaches so invalid types are detected and
handled at the mapper/domain boundary rather than silently propagated.
app/src/main/java/woowacourse/shopping/ui/util/NotificationSettings.kt (1)

10-28: ⚡ Quick win

반복되는 SharedPreferences 접근 코드를 개선할 수 있습니다.

각 함수에서 context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)를 반복적으로 호출하고 있습니다.

이 중복을 줄이는 방법에는 어떤 것들이 있을까요?

  • private 프로퍼티로 SharedPreferences 인스턴스를 캐싱하는 방법
  • 확장 함수를 활용하는 방법
  • Context를 생성자로 받아 초기화 시점에 SharedPreferences를 받는 방법

각 방법의 장단점을 고려해보고, 코드의 가독성과 성능을 개선해보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/util/NotificationSettings.kt`
around lines 10 - 28, Refactor repeated context.getSharedPreferences calls in
NotificationSettings.kt by caching a SharedPreferences instance and use it
across functions: create a private lazy or initialized property (e.g., private
lateinit or private val sharedPrefs) that is set once (via an init method that
accepts Context or by providing Context to an initialize(context) function) and
then replace calls inside isNotificationEnabled, setNotificationEnabled,
markReminderScheduled, and hasScheduledReminder to use that cached sharedPrefs;
alternatively consider a Context extension that returns the prefs to keep call
sites concise—ensure initialization happens before any getter/setter is used to
avoid NPEs.
app/src/main/java/woowacourse/shopping/ui/productList/ProductListUiState.kt (1)

7-7: 💤 Low value

data object에서 object로 변경한 이유가 궁금합니다.

Loadingdata object에서 일반 object로 변경되었습니다.

Kotlin 1.9부터 도입된 data object는 싱글톤 객체에 equals(), hashCode(), toString()을 자동 생성해주는데, 이 케이스에서는 이러한 메서드들이 필요하지 않다고 판단하신 것인가요? 아니면 다른 이유가 있으신가요?

기능적으로는 차이가 없지만, 일관성 측면에서 팀의 코딩 컨벤션이나 선호하는 스타일이 있다면 참고하시면 좋을 것 같습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/productList/ProductListUiState.kt`
at line 7, Change the singleton declaration back to a data object to keep
consistency and retain generated equals/hashCode/toString: replace the current
"object Loading : ProductListUiState" with "data object Loading :
ProductListUiState" in the ProductListUiState declaration (refer to the
ProductListUiState.Loading symbol) so it uses Kotlin 1.9's data object behavior.
app/src/main/java/woowacourse/shopping/ui/common/SettingsScreen.kt (1)

36-38: ⚡ Quick win

상태 관리 접근 방식을 검토해보세요.

NotificationSettings.isNotificationEnabled(context)remember 블록 안에서 호출하고 있습니다. 이 방식은 화면이 처음 Composition될 때의 값만 읽어오며, 다른 화면이나 외부에서 설정이 변경되더라도 이 화면은 갱신되지 않습니다.

이것이 의도된 동작인가요? 만약 설정 값의 변경을 관찰해야 한다면 어떤 패턴을 사용할 수 있을까요? (힌트: StateFlow, LiveData 등의 옵저버블 패턴)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/common/SettingsScreen.kt` around
lines 36 - 38, 현재 SettingsScreen의 notificationEnabled 변수가 remember {
mutableStateOf(NotificationSettings.isNotificationEnabled(context)) }로 초기화되어 초기
Composition 시 값만 읽히므로 외부에서 설정이 변경되어도 화면이 갱신되지 않습니다;
NotificationSettings.isNotificationEnabled(context) 호출을 remember 안에서 제거하고,
NotificationSettings에 StateFlow<Boolean> 또는 LiveData를 추가(예:
NotificationSettings.notificationEnabledFlow)하여 SettingsScreen에서 해당
Flow/LiveData를 수집해 UI 상태로 변환(collectAsState 또는 observeAsState 등)하도록 변경하거나,
NotificationSettings에 콜백/리스너를 제공해 변경시 mutableStateOf를 업데이트하도록 구현해 변경을 관찰 가능하게
만드세요.
app/src/test/kotlin/woowacourse/shopping/domain/model/coupon/CouponCalculatorTest.kt (1)

161-193: ⚖️ Poor tradeoff

시간 제한 쿠폰에 대한 경계 조건 테스트 추가를 고려해 보세요.

MIRACLESALE_오전_적용 테스트에서 availableStartTime/availableEndTime이 설정되지 않은 쿠폰을 사용하고 있어, 실제로는 "시간 제한 없음" 케이스를 테스트하고 있습니다.

다음과 같은 추가 테스트를 고려해 보세요:

  • 시간 범위가 설정된 쿠폰의 범위 내/외 시간 테스트
  • 자정을 넘는 시간 범위 (예: 22:00~06:00) 테스트
  • 만료된 쿠폰 테스트

이러한 테스트들이 CouponCalculator의 시간 로직 검증에 도움이 됩니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/test/kotlin/woowacourse/shopping/domain/model/coupon/CouponCalculatorTest.kt`
around lines 161 - 193, Add boundary/time-range tests for
CouponCalculator.apply: currently MIRACLESALE_오전_적용 and PERCENT_시간제한_없으면_항상_적용
use coupons without availableStartTime/availableEndTime so they only cover "no
time limit". Create additional unit tests that construct Coupon instances with
availableStartTime and availableEndTime set and call
CouponCalculator.apply(cart, coupon, now=...) to assert applied/discount for (1)
times inside the range, (2) times just outside the range (boundary minutes), (3)
an overnight range that crosses midnight (e.g., availableStartTime=22:00,
availableEndTime=06:00) with now at 23:00 and 05:00, and (4) an expired coupon
(expireAt before now) to assert not applied; reference the Coupon class fields
availableStartTime/availableEndTime/expireAt and the CouponCalculator.apply(...)
and existing test names to add these cases.
app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailViewModel.kt (1)

94-107: 💤 Low value

delay(1000) 하드코딩과 동작 의도에 대해 생각해 보세요.

장바구니에 담은 후 1초 지연 후 자동 네비게이션을 수행하고 있습니다. 몇 가지 고려할 점이 있습니다:

  1. 사용자가 스낵바를 보고 1초 내에 다른 액션을 수행하면 어떻게 될까요?
  2. 지연 시간을 상수로 추출하면 의도가 더 명확해집니다.
  3. 사용자가 화면을 빠르게 이탈하면 viewModelScope가 취소되어 이벤트가 방출되지 않을 수 있는데, 이것이 의도된 동작인지 확인해 보세요.

스낵바에 "장바구니로 이동" 액션 버튼을 추가하는 방식도 UX 관점에서 고려해 볼 만합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailViewModel.kt`
around lines 94 - 107, The addToCart() function currently uses a hardcoded
delay(1000) after emitting _uiEvent ShowSnackbar which can cause unintended
cancellation or poor UX; extract the timeout into a named constant (e.g.,
AUTO_NAV_DELAY_MS) and replace delay(1000) with that constant, and instead of
auto-navigating unconditionally from viewModelScope, change the flow to either
(A) expose a snackbar event with an action button that triggers
_uiEvent.NavigateToCart when the user taps it, or (B) perform the delayed
navigation using viewModelScope.launch { withContext(NonCancellable) {
delay(AUTO_NAV_DELAY_MS); _uiEvent.emit(UiEvent.NavigateToCart) } } if you
intend navigation to proceed even if the caller cancels; update references to
_uiState, cartRepository, _uiEvent, and addToCart() accordingly so the intent is
clear.
app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderScheduler.kt (1)

41-46: 💤 Low value

setAndAllowWhileIdle 사용은 적절하지만, 알람 정확도에 대해 인지하고 있어야 합니다.

setAndAllowWhileIdle은 Doze 모드에서도 실행되지만, 시스템이 배터리 최적화를 위해 실행 시점을 조정할 수 있어 정확한 시간에 실행되지 않을 수 있습니다. 현재 결제 리마인더 용도로는 적절한 선택입니다.

만약 정확한 타이밍이 필요하다면 setExactAndAllowWhileIdleSCHEDULE_EXACT_ALARM 권한이 필요하므로, 현재 접근 방식이 권한 요청 없이 동작하는 합리적인 트레이드오프입니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderScheduler.kt`
around lines 41 - 46, The current use of AlarmManager.setAndAllowWhileIdle in
PaymentReminderScheduler (the alarmManager.setAndAllowWhileIdle call) is inexact
and may be deferred by Doze; if you need guaranteed exact firing change the call
to AlarmManager.setExactAndAllowWhileIdle and add handling to request/declare
the SCHEDULE_EXACT_ALARM permission (and fall back to setAndAllowWhileIdle when
the permission isn't granted). Update the scheduler logic around the existing
alarmManager.cancel(...) / set... sequence to perform the permission check,
request or gracefully degrade to the inexact setter, and keep the same
PendingIntent usage (PaymentReminderScheduler / the pendingIntent creation) so
behavior remains consistent.
app/src/main/java/woowacourse/shopping/ui/productList/ProductListViewModel.kt (1)

64-70: 💤 Low value

addProductrefreshCart() 호출과 cartEvents 수신이 중복될 수 있습니다.

addProduct 내에서 refreshCart()를 직접 호출하고, 동시에 cartEvents를 통해서도 refreshCart()가 트리거됩니다. 이로 인해 장바구니 데이터를 두 번 가져올 수 있습니다.

현재 동작에 문제는 없지만, 네트워크 호출 최적화가 필요하다면 둘 중 하나의 방식으로 통일하는 것을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/woowacourse/shopping/ui/productList/ProductListViewModel.kt`
around lines 64 - 70, The addProduct method calls refreshCart() directly and
cartEvents also triggers refreshCart(), causing duplicate fetches; remove the
direct refreshCart() call inside addProduct (keep emitting
_uiEvent.ShowSnackbar) and rely on the existing cartEvents flow to refresh the
cart, or alternatively implement a debounce/guard if you prefer event-driven +
immediate refresh; update the addProduct function (referencing addProduct,
refreshCart, and cartEvents) to eliminate the redundant refresh invocation.
app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponCalculator.kt (1)

120-123: 💤 Low value

퍼센트 할인 계산 시 toInt() 사용에 대해 검토해 보세요.

(subtotal * rate).toInt()는 소수점 이하를 버림 처리합니다. 예를 들어 15,001원의 10% 할인은 1,500.1원이지만 1,500원으로 계산됩니다.

비즈니스 요구사항에 따라 roundToInt()(반올림) 또는 현재 방식(버림)이 적절할 수 있습니다. 의도된 동작이라면 주석으로 명시하면 좋겠습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponCalculator.kt`
around lines 120 - 123, The percent-discount calculation currently truncates
fractions using (subtotal * rate).toInt(), losing cents; change this to use
rounding by replacing that expression with (subtotal * rate).roundToInt() (and
add the kotlin.math.roundToInt import), keep appliedDiscount =
discount.coerceAtMost(subtotal) and the returned CouponApplyResult as-is; if
truncation was intentional instead, add a clear inline comment above the
rate/discount calculation in CouponCalculator.kt explaining that .toInt()
(flooring) is deliberate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.gitignore:
- Line 42: 현재 .gitignore에 추가된 app/src/main/res/xml/network_security_config.xml
항목 때문에 빌드/CI 환경에서 네트워크 보안 리소스가 누락될 위험이 있으니 해당 파일을 .gitignore에서 제거하고 저장소에 포함시켜
버전관리를 복원하세요; 만약 network_security_config.xml 내부에 민감한 값(인증서, 도메인 키 등)이 있다면 파일 자체를
커밋하지 말고, 대신 network_security_config.xml에서 민감값을 분리하여 환경별 주입(빌드 변수, CI 시크릿, 또는 리소스
템플릿 + 변환 스텝)으로 대체하도록 리팩토링하세요.

In `@app/build.gradle.kts`:
- Around line 36-40: buildConfigField("String", "BASE_URL", ...)에서
envProperties.getProperty("BASE_URL")에 대한 기본값 처리가 빠져 null이 문자열로 들어갈 수 있으니
buildConfigField 호출부(심볼: buildConfigField, 상수: BASE_URL, 호출:
envProperties.getProperty("BASE_URL"))에 elvis 연산자(?:)로 안전한 기본값을 추가하세요; 예를 들어 개발
환경에서 사용할 로컬 엔드포인트("http://localhost:8080")를 기본값으로 사용하도록 변경하여
API_USERNAME/API_PASSWORD와 동일한 방식으로 처리하세요.

In `@app/src/main/java/woowacourse/shopping/data/remote/api/CouponApi.kt`:
- Line 1: The file contains a leading BOM before the package declaration
'package woowacourse.shopping.data.remote.api'; remove the invisible BOM
character by re-saving CouponApi.kt as UTF-8 without BOM (or remove the BOM byte
manually) so the package line starts immediately with 'package' and re-run
formatting/analysis to confirm no hidden characters remain.

In
`@app/src/main/java/woowacourse/shopping/data/repository/cart/CartRepositoryImpl.kt`:
- Line 27: The getAllCartItems implementation currently requests an unbounded
page size using Int.MAX_VALUE which can overload client/server; modify
CartRepositoryImpl.getAllCartItems to use a safe capped page size (e.g., a
constant like DEFAULT_MAX_PAGE_SIZE or a configurable value) or iterate
paginated calls to cartRemoteDataSource.getCartItems(offset, limit) until all
items are fetched, ensuring you replace the Int.MAX_VALUE call with the chosen
limit and handle repeated requests if full retrieval is required.
- Around line 33-36: The current flow calls
cartRemoteDataSource.addCartItem(...) then re-queries with
getAllCartItems().findByProductId(product.id) which can return the wrong cart
entry under concurrent or pre-existing-product conditions; change the
implementation to obtain the created cart item's identifier directly from the
add-call instead of reverse-lookup: update cartRemoteDataSource.addCartItem to
return the created CartItem or its id (or read the created resource id from the
API response / Location header) and return that id, keeping
_cartEvents.emit(Unit) as-is; if you cannot change the remote signature, make
addCartItem accept/return a unique client-generated token (e.g., requestUuid)
and match on that token to reliably identify the newly-created cart entry.

In
`@app/src/main/java/woowacourse/shopping/data/repository/CouponRepositoryImpl.kt`:
- Around line 11-15: In CouponRepositoryImpl.getAvailableCoupons(), do not
swallow all exceptions; narrow the catch to expected network/IO exceptions or
rethrow coroutine cancellation signals: catch and rethrow CancellationException
(or check isActive) and only handle specific exceptions from
couponRemoteDataSource.getCoupons(), then map with CouponMapper.toDomain and
return emptyList() only for recoverable errors; ensure CancellationException and
other unchecked exceptions propagate instead of being converted to an empty
list.

In `@app/src/main/java/woowacourse/shopping/ui/cart/CartViewModel.kt`:
- Around line 230-239: refreshCartItems does not reconcile selectedItemsState
with the current set of cart IDs, so removed items can remain selected; after
obtaining allResult (via cartRepository.getAllCartItems()) and before or
immediately after updating allCartItemsState/pagedCartItemsState, compute the
current cartId set from allResult.values and update selectedItemsState to the
intersection of its previous value and that cartId set (i.e., remove any
selected ids that no longer exist) so selection stays consistent with
refreshCartItems, pagedCartItemsState, and allCartItemsState.
- Around line 83-88: The code is calling refresh() both directly in action
handlers (e.g., refreshCartItems() inside methods that modify cart items) and
via the cartRepository.cartEvents collector which already triggers refresh();
remove the direct calls from the action handlers and let the cartEvents
subscription (inside viewModelScope.launch that calls refresh()) be the single
source of truth for reloading. Keep the initial refresh() on ViewModel init,
ensure action handlers only perform repository updates (e.g., calls on
cartRepository like add/remove/update) and that those repository methods emit
cartEvents so the existing collector will call refresh(), and remove any
remaining direct calls to refreshCartItems()/refresh() in those handlers.

In `@app/src/main/java/woowacourse/shopping/ui/common/SettingsScreen.kt`:
- Around line 65-72: The onCheckedChange block updates NotificationSettings and
only cancels schedules when unchecked but does nothing when re-enabled; update
the behavior so that when checked == true you schedule or reschedule reminders
(e.g., call PaymentReminderScheduler.schedule(context) and/or
NotificationHelper.schedule(context) after
NotificationSettings.setNotificationEnabled) and when unchecked keep the
existing cancel calls, and move this orchestration out of the Composable into a
ViewModel or a dedicated UseCase so the UI (onCheckedChange) only dispatches the
new enabled state while the ViewModel/UseCase (e.g.,
NotificationSettingsViewModel or NotificationUseCase) performs the scheduling
logic and checks for outstanding unpaid items before scheduling.

In `@app/src/main/java/woowacourse/shopping/ui/MainActivity.kt`:
- Around line 55-64: The loop currently calls
navController.navigate(PaymentRoute(selectedItemIds = pendingSelectedItemIds))
repeatedly which can push duplicate Payment destinations; change the logic so
you check navController.currentBackStackEntry?.destination?.route (and the
navigated flag) first and only invoke navController.navigate once when Payment
is not already present, or use a single-top/navigation option (e.g.,
launchSingleTop/popUpTo behavior) when calling navController.navigate to prevent
duplicate stacking; update the code around the loop variables navigated,
timeoutMillis, currentRoute, PaymentRoute and pendingSelectedItemIds to ensure
navigate is invoked at most once per navigation attempt.
- Around line 68-70: The catch block currently swallows all exceptions (catch
(e: Exception) { delay(200) }) which hides failure causes and prevents proper
handling; update the catch to log the exception (e.g., using Log.e or Timber)
including e.message/stacktrace, and implement a retry decision: inspect the
caught exception (e) and only perform delay(200) and retry for transient errors,
otherwise log and rethrow or abort navigation; ensure this change is applied in
the coroutine/handler around the navigation call in MainActivity.kt so the
exception is visible and non-retryable errors are not silently ignored.

In `@app/src/main/java/woowacourse/shopping/ui/navigation/AppNavHost.kt`:
- Around line 145-146: 현재 nav 이동 시 CartUiState.Success가 아니면 selectedIds가
emptyList로 대체되어 유실될 수 있으니, 결제 네비게이션에서는 uiState를 직접 읽지 말고 선택 아이디의 단일 신뢰 소스(예:
cartViewModel의 별도 프로퍼티/함수)에서 값을 가져오거나 선택 이벤트에서 selectedIds를 캡처해 전달하세요; 예를 들어
cartViewModel에 selectedItemIds: StateFlow/LiveData 또는 getSelectedItemIds():
List<Int> 같은 접근자를 추가하거나, 선택을 처리하는 곳에서 selectedItems를 읽어
PaymentRoute(selectedItemIds = ...)로 넘기도록 변경하여 CartUiState.Success 체크로 인한 빈 리스트
대체를 방지하세요.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentScreen.kt`:
- Around line 72-74: The call to PaymentReminderScheduler.schedule in
PaymentScreen is using a hardcoded test delay (10 * 1000L) which causes
too-early reminders; replace this magic value with a configurable constant or
resource (e.g., REMINDER_DELAY_MS) or read from app settings, and use that
constant when calling PaymentReminderScheduler.schedule(context, reminderDelay,
selectedItemIds); update its declaration (in the same file or a central config)
and adjust any tests to override the config rather than changing the production
call.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt`:
- Around line 94-99: The empty-selection branch currently emits a message but
continues executing order and navigation; update the selectedIds.isEmpty()
branch in PaymentViewModel to emit PaymentUiEvent.ShowMessage("선택된 상품이 없습니다.")
and then immediately return so cartRepository.order(selectedIds) and subsequent
_uiEvent emissions never run for empty selections; after the change, add a
UI/unit test that verifies when selectedIds is empty the ShowMessage is emitted
once and cartRepository.order and NavigateToProductList are not invoked.
- Around line 44-48: The refresh() and onClickPay() flows lack exception
handling causing the UI to stay in Loading or emit wrong events; wrap repository
calls (cartRepository.getAllCartItems(), couponRepository.getAvailableCoupons(),
orderRepository.order()) in try/catch, on failure restore _uiState to a failure
state (e.g., PaymentUiState.Error with message) or revert to the previous
success state (use previousSelectedCoupon captured before setting Loading) and
ensure onClickPay() does not emit success/navigation events when an exception
occurs; add unit tests for network failure scenarios to assert
PaymentUiState.Error is set and navigation events are not emitted.

In `@app/src/main/java/woowacourse/shopping/ui/util/NotificationSettings.kt`:
- Around line 5-29: NotificationSettings가 UI 레이어에서 SharedPreferences를 직접 다루고
있으므로 데이터 레이어로 이동시키고 책임을 분리하세요: NotificationSettings 객체와 그
메소드(isNotificationEnabled, setNotificationEnabled, markReminderScheduled,
hasScheduledReminder)를 data 계층의 인터페이스(예: NotificationPreferences 또는
NotificationDataSource)로 추출한 뒤 SharedPreferences 접근 구현을 그 구현체에 두고 리포지토리/데이터소스
인터페이스를 ViewModel 등에서 생성자 주입으로 사용하도록 변경하세요; 이렇게 하면 저장 방식(DataStore 등) 변경과 단위 테스트가
용이해집니다.

In `@gradle/libs.versions.toml`:
- Line 23: The libs.versions.toml entry navigation = "2.8.9" may reference a
non-existent artifact version; locate the catalog alias `navigation` in
gradle/libs.versions.toml and check which exact Maven artifact it maps to (e.g.,
androidx.navigation:navigation-runtime-ktx or
androidx.navigation:navigation-compose), then verify that artifact/version
exists on Maven Central; if 2.8.9 is not published for that artifact, change the
`navigation` value to a published version (or correct the artifact mapping) so
the dependency coordinates resolve during build.

---

Nitpick comments:
In `@app/src/main/java/woowacourse/shopping/constant/ShoppingColor.kt`:
- Around line 9-14: The six hardcoded ARGB color constants in ShoppingColor.kt
(PAYMENT_BUTTON_COLOR, PAYMENT_DIVIDER_COLOR, PAYMENT_CARD_BORDER_COLOR,
PAYMENT_SUMMARY_BACKGROUND_COLOR, PAYMENT_DESCRIPTION_GRAY, PAYMENT_TITLE_GRAY)
should be moved out of raw ints and integrated into the app theme: create named
color tokens in the central theme (light/dark variants) and expose them via
Material3 ColorScheme or your Theme object, then replace usages of these
constants to read from the ColorScheme (or Theme.colors) so dark mode and
centralized overrides work; update any callers that reference these constants to
use the themed color tokens instead.

In `@app/src/main/java/woowacourse/shopping/data/remote/dto/CouponResponseDto.kt`:
- Line 10: The DTO field discountType is currently a raw String which risks
invalid values slipping past compile-time; update the design by either 1) making
CouponResponseDto use a dedicated enum (e.g., DiscountType) to enforce
type-safety at the DTO boundary, or 2) if the API can return arbitrary strings,
keep String but add strict validation and clear error handling in CouponMapper
(the mapping function that converts CouponResponseDto -> domain coupon) to
translate/normalize strings into the domain DiscountType enum and surface
mapping errors early; locate CouponResponseDto and the CouponMapper mapping
logic and implement one of these approaches so invalid types are detected and
handled at the mapper/domain boundary rather than silently propagated.

In `@app/src/main/java/woowacourse/shopping/domain/exception/ProductException.kt`:
- Around line 20-22: EmptyBody is declared as a plain object while other sealed
ProductException subtypes (ServerError, NetworkError, NotFound) are data
classes; change EmptyBody to a data object (i.e., make it a "data object
EmptyBody : ProductException(...)") so it benefits from generated
toString/equals/hashCode and maintains a consistent sealed-hierarchy pattern
with the other types.

In
`@app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponCalculator.kt`:
- Around line 120-123: The percent-discount calculation currently truncates
fractions using (subtotal * rate).toInt(), losing cents; change this to use
rounding by replacing that expression with (subtotal * rate).roundToInt() (and
add the kotlin.math.roundToInt import), keep appliedDiscount =
discount.coerceAtMost(subtotal) and the returned CouponApplyResult as-is; if
truncation was intentional instead, add a clear inline comment above the
rate/discount calculation in CouponCalculator.kt explaining that .toInt()
(flooring) is deliberate.

In `@app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponTypes.kt`:
- Around line 14-20: The fromCode function in CouponTypes.kt currently matches
codes case-sensitively and will treat " fixed5000 " or "fixed5000" as UNKNOWN;
normalize the input first by applying trim() and uppercase (e.g., val normalized
= code.trim().uppercase()) and then use normalized in the when expression to map
to FIXED5000, BOGO, FREESHIPPING, MIRACLESALE, or UNKNOWN; update any tests or
callers if they relied on exact-case behavior.

In `@app/src/main/java/woowacourse/shopping/ui/common/SettingsScreen.kt`:
- Around line 36-38: 현재 SettingsScreen의 notificationEnabled 변수가 remember {
mutableStateOf(NotificationSettings.isNotificationEnabled(context)) }로 초기화되어 초기
Composition 시 값만 읽히므로 외부에서 설정이 변경되어도 화면이 갱신되지 않습니다;
NotificationSettings.isNotificationEnabled(context) 호출을 remember 안에서 제거하고,
NotificationSettings에 StateFlow<Boolean> 또는 LiveData를 추가(예:
NotificationSettings.notificationEnabledFlow)하여 SettingsScreen에서 해당
Flow/LiveData를 수집해 UI 상태로 변환(collectAsState 또는 observeAsState 등)하도록 변경하거나,
NotificationSettings에 콜백/리스너를 제공해 변경시 mutableStateOf를 업데이트하도록 구현해 변경을 관찰 가능하게
만드세요.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentUiState.kt`:
- Line 15: PaymentUiState currently hardcodes shippingFee = 3000 in the data
class; remove the business-logic default and instead accept shippingFee as a
provided value (no hardcoded default) so the UI state is populated by the
domain/service layer that calculates fees, or if it truly is a constant move the
value to a dedicated constant (e.g., ShoppingConstants.SHIPPING_FEE) and
reference that constant from PaymentUiState; update any creators/factories that
instantiate PaymentUiState (tests, viewmodels) to pass the correct shippingFee
from the order calculation or constant.
- Around line 9-17: The Success data class currently supplies default zeros for
subtotal, couponDiscount, and totalPrice which can mask missing calculations;
remove those default values (make subtotal, couponDiscount, totalPrice
non-optional constructor parameters) or provide a single factory method (e.g., a
Success.Companion.createFrom(cartItems, availableCoupons, selectedCoupon,
shippingFee)) that computes and returns a fully populated Success instance so
callers cannot accidentally construct an incomplete UI state; update call sites
that instantiate Success to use the computed values or the factory.

In
`@app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailViewModel.kt`:
- Around line 94-107: The addToCart() function currently uses a hardcoded
delay(1000) after emitting _uiEvent ShowSnackbar which can cause unintended
cancellation or poor UX; extract the timeout into a named constant (e.g.,
AUTO_NAV_DELAY_MS) and replace delay(1000) with that constant, and instead of
auto-navigating unconditionally from viewModelScope, change the flow to either
(A) expose a snackbar event with an action button that triggers
_uiEvent.NavigateToCart when the user taps it, or (B) perform the delayed
navigation using viewModelScope.launch { withContext(NonCancellable) {
delay(AUTO_NAV_DELAY_MS); _uiEvent.emit(UiEvent.NavigateToCart) } } if you
intend navigation to proceed even if the caller cancels; update references to
_uiState, cartRepository, _uiEvent, and addToCart() accordingly so the intent is
clear.

In `@app/src/main/java/woowacourse/shopping/ui/productList/ProductListUiState.kt`:
- Line 7: Change the singleton declaration back to a data object to keep
consistency and retain generated equals/hashCode/toString: replace the current
"object Loading : ProductListUiState" with "data object Loading :
ProductListUiState" in the ProductListUiState declaration (refer to the
ProductListUiState.Loading symbol) so it uses Kotlin 1.9's data object behavior.

In
`@app/src/main/java/woowacourse/shopping/ui/productList/ProductListViewModel.kt`:
- Around line 64-70: The addProduct method calls refreshCart() directly and
cartEvents also triggers refreshCart(), causing duplicate fetches; remove the
direct refreshCart() call inside addProduct (keep emitting
_uiEvent.ShowSnackbar) and rely on the existing cartEvents flow to refresh the
cart, or alternatively implement a debounce/guard if you prefer event-driven +
immediate refresh; update the addProduct function (referencing addProduct,
refreshCart, and cartEvents) to eliminate the redundant refresh invocation.

In `@app/src/main/java/woowacourse/shopping/ui/util/NotificationSettings.kt`:
- Around line 10-28: Refactor repeated context.getSharedPreferences calls in
NotificationSettings.kt by caching a SharedPreferences instance and use it
across functions: create a private lazy or initialized property (e.g., private
lateinit or private val sharedPrefs) that is set once (via an init method that
accepts Context or by providing Context to an initialize(context) function) and
then replace calls inside isNotificationEnabled, setNotificationEnabled,
markReminderScheduled, and hasScheduledReminder to use that cached sharedPrefs;
alternatively consider a Context extension that returns the prefs to keep call
sites concise—ensure initialization happens before any getter/setter is used to
avoid NPEs.

In `@app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderReceiver.kt`:
- Around line 9-20: onReceive in PaymentReminderReceiver runs on the main thread
with a short timeout, so wrap the notification work in a background task and add
robust exception handling: call goAsync() inside
PaymentReminderReceiver.onReceive to obtain a PendingResult, then offload
NotificationHelper.createNotificationChannel and
NotificationHelper.showPaymentReminder to a background thread/coroutine (or
WorkManager) and call finish() on the PendingResult when done; surround the
background work with try/catch to handle and log exceptions (e.g., permission
issues, resource errors) and ensure NotificationSettings.isNotificationEnabled
is checked before dispatching work; also validate the intent extra
(PaymentReminderScheduler.EXTRA_SELECTED_ITEM_IDS) and handle null/empty safely.

In `@app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderScheduler.kt`:
- Around line 41-46: The current use of AlarmManager.setAndAllowWhileIdle in
PaymentReminderScheduler (the alarmManager.setAndAllowWhileIdle call) is inexact
and may be deferred by Doze; if you need guaranteed exact firing change the call
to AlarmManager.setExactAndAllowWhileIdle and add handling to request/declare
the SCHEDULE_EXACT_ALARM permission (and fall back to setAndAllowWhileIdle when
the permission isn't granted). Update the scheduler logic around the existing
alarmManager.cancel(...) / set... sequence to perform the permission check,
request or gracefully degrade to the inexact setter, and keep the same
PendingIntent usage (PaymentReminderScheduler / the pendingIntent creation) so
behavior remains consistent.

In
`@app/src/test/kotlin/woowacourse/shopping/domain/model/coupon/CouponCalculatorTest.kt`:
- Around line 161-193: Add boundary/time-range tests for CouponCalculator.apply:
currently MIRACLESALE_오전_적용 and PERCENT_시간제한_없으면_항상_적용 use coupons without
availableStartTime/availableEndTime so they only cover "no time limit". Create
additional unit tests that construct Coupon instances with availableStartTime
and availableEndTime set and call CouponCalculator.apply(cart, coupon, now=...)
to assert applied/discount for (1) times inside the range, (2) times just
outside the range (boundary minutes), (3) an overnight range that crosses
midnight (e.g., availableStartTime=22:00, availableEndTime=06:00) with now at
23:00 and 05:00, and (4) an expired coupon (expireAt before now) to assert not
applied; reference the Coupon class fields
availableStartTime/availableEndTime/expireAt and the CouponCalculator.apply(...)
and existing test names to add these cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 332d886b-29c4-4090-8bb3-f01945534762

📥 Commits

Reviewing files that changed from the base of the PR and between 4989417 and e68c015.

📒 Files selected for processing (64)
  • .env
  • .gitignore
  • README.md
  • app/build.gradle.kts
  • app/src/debug/AndroidManifest.xml
  • app/src/debug/java/woowacourse/shopping/mock/MockServerInitializer.kt
  • app/src/debug/java/woowacourse/shopping/mock/ShoppingMockServer.kt
  • app/src/main/AndroidManifest.xml
  • app/src/main/java/woowacourse/shopping/constant/Format.kt
  • app/src/main/java/woowacourse/shopping/constant/ShoppingColor.kt
  • app/src/main/java/woowacourse/shopping/data/datasource/cart/CartRemoteDataSourceImpl.kt
  • app/src/main/java/woowacourse/shopping/data/datasource/coupon/CouponRemoteDataSource.kt
  • app/src/main/java/woowacourse/shopping/data/datasource/coupon/CouponRemoteDataSourceImpl.kt
  • app/src/main/java/woowacourse/shopping/data/datasource/product/ProductRemoteDataSource.kt
  • app/src/main/java/woowacourse/shopping/data/datasource/product/ProductRemoteDataSourceImpl.kt
  • app/src/main/java/woowacourse/shopping/data/mapper/CartItemMapper.kt
  • app/src/main/java/woowacourse/shopping/data/mapper/CouponMapper.kt
  • app/src/main/java/woowacourse/shopping/data/remote/RetrofitProvider.kt
  • app/src/main/java/woowacourse/shopping/data/remote/api/CartApi.kt
  • app/src/main/java/woowacourse/shopping/data/remote/api/CouponApi.kt
  • app/src/main/java/woowacourse/shopping/data/remote/dto/CartItemCountResponseDto.kt
  • app/src/main/java/woowacourse/shopping/data/remote/dto/CouponResponseDto.kt
  • app/src/main/java/woowacourse/shopping/data/repository/CouponRepositoryImpl.kt
  • app/src/main/java/woowacourse/shopping/data/repository/cart/CartRepositoryImpl.kt
  • app/src/main/java/woowacourse/shopping/data/repository/product/RemoteProductRepository.kt
  • app/src/main/java/woowacourse/shopping/di/AppContainer.kt
  • app/src/main/java/woowacourse/shopping/domain/exception/ProductException.kt
  • app/src/main/java/woowacourse/shopping/domain/model/cart/CartItems.kt
  • app/src/main/java/woowacourse/shopping/domain/model/coupon/Coupon.kt
  • app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponCalculator.kt
  • app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponType.kt
  • app/src/main/java/woowacourse/shopping/domain/model/coupon/CouponTypes.kt
  • app/src/main/java/woowacourse/shopping/domain/model/product/Products.kt
  • app/src/main/java/woowacourse/shopping/domain/repository/CartRepository.kt
  • app/src/main/java/woowacourse/shopping/domain/repository/CouponRepository.kt
  • app/src/main/java/woowacourse/shopping/domain/repository/ProductRepository.kt
  • app/src/main/java/woowacourse/shopping/ui/MainActivity.kt
  • app/src/main/java/woowacourse/shopping/ui/UiEvent.kt
  • app/src/main/java/woowacourse/shopping/ui/cart/CartActivity.kt
  • app/src/main/java/woowacourse/shopping/ui/cart/CartUiState.kt
  • app/src/main/java/woowacourse/shopping/ui/cart/CartViewModel.kt
  • app/src/main/java/woowacourse/shopping/ui/common/SettingsScreen.kt
  • app/src/main/java/woowacourse/shopping/ui/navigation/AppNavHost.kt
  • app/src/main/java/woowacourse/shopping/ui/navigation/Routes.kt
  • app/src/main/java/woowacourse/shopping/ui/payment/PaymentScreen.kt
  • app/src/main/java/woowacourse/shopping/ui/payment/PaymentUiState.kt
  • app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt
  • app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailActivity.kt
  • app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailUiState.kt
  • app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailViewModel.kt
  • app/src/main/java/woowacourse/shopping/ui/productList/ProductListActivity.kt
  • app/src/main/java/woowacourse/shopping/ui/productList/ProductListScreen.kt
  • app/src/main/java/woowacourse/shopping/ui/productList/ProductListUiState.kt
  • app/src/main/java/woowacourse/shopping/ui/productList/ProductListViewModel.kt
  • app/src/main/java/woowacourse/shopping/ui/util/NotificationHelper.kt
  • app/src/main/java/woowacourse/shopping/ui/util/NotificationSettings.kt
  • app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderReceiver.kt
  • app/src/main/java/woowacourse/shopping/ui/util/PaymentReminderScheduler.kt
  • app/src/main/res/drawable/ic_alarm.xml
  • app/src/main/res/drawable/ic_settings.xml
  • app/src/main/res/xml/network_security_config.xml
  • app/src/test/kotlin/woowacourse/shopping/domain/model/coupon/CouponCalculatorTest.kt
  • build.gradle.kts
  • gradle/libs.versions.toml
💤 Files with no reviewable changes (8)
  • app/src/main/java/woowacourse/shopping/ui/cart/CartActivity.kt
  • .env
  • app/src/main/java/woowacourse/shopping/ui/productList/ProductListActivity.kt
  • app/src/main/res/xml/network_security_config.xml
  • app/src/debug/java/woowacourse/shopping/mock/MockServerInitializer.kt
  • app/src/main/java/woowacourse/shopping/ui/productDetail/ProductDetailActivity.kt
  • app/src/debug/java/woowacourse/shopping/mock/ShoppingMockServer.kt
  • app/src/debug/AndroidManifest.xml

Comment thread .gitignore
.DS_Store

.env
app/src/main/res/xml/network_security_config.xml
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

network_security_config.xml.gitignore에 넣으면 빌드 재현성이 깨질 수 있습니다.

Line 42 항목 때문에 팀원/CI 환경에서 보안 설정 리소스가 누락될 위험이 큽니다. 해당 파일은 버전 관리에 포함하고, 민감값이 있다면 별도 주입 방식으로 분리하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore at line 42, 현재 .gitignore에 추가된
app/src/main/res/xml/network_security_config.xml 항목 때문에 빌드/CI 환경에서 네트워크 보안 리소스가
누락될 위험이 있으니 해당 파일을 .gitignore에서 제거하고 저장소에 포함시켜 버전관리를 복원하세요; 만약
network_security_config.xml 내부에 민감한 값(인증서, 도메인 키 등)이 있다면 파일 자체를 커밋하지 말고, 대신
network_security_config.xml에서 민감값을 분리하여 환경별 주입(빌드 변수, CI 시크릿, 또는 리소스 템플릿 + 변환
스텝)으로 대체하도록 리팩토링하세요.

Comment thread app/build.gradle.kts
Comment on lines 36 to 40
buildConfigField(
"String",
"BASE_URL",
"\"${envProperties.getProperty("BASE_URL")}\""
"\"${envProperties.getProperty("BASE_URL")}\"",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

BASE_URL에 대한 기본값(fallback) 처리가 누락되었습니다.

Line 39에서 .env 파일에 BASE_URL이 정의되지 않은 경우 null이 문자열로 들어가게 됩니다. 45번, 51번 라인의 API_USERNAME, API_PASSWORD와 달리 elvis 연산자(?:)를 사용한 기본값 처리가 없습니다.

이 문제를 해결하려면 어떤 기본값을 제공하는 것이 적절할까요? 개발 환경에서 사용할 수 있는 로컬 URL을 기본값으로 설정하는 방법을 고려해보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/build.gradle.kts` around lines 36 - 40, buildConfigField("String",
"BASE_URL", ...)에서 envProperties.getProperty("BASE_URL")에 대한 기본값 처리가 빠져 null이
문자열로 들어갈 수 있으니 buildConfigField 호출부(심볼: buildConfigField, 상수: BASE_URL, 호출:
envProperties.getProperty("BASE_URL"))에 elvis 연산자(?:)로 안전한 기본값을 추가하세요; 예를 들어 개발
환경에서 사용할 로컬 엔드포인트("http://localhost:8080")를 기본값으로 사용하도록 변경하여
API_USERNAME/API_PASSWORD와 동일한 방식으로 처리하세요.

@@ -0,0 +1,8 @@
package woowacourse.shopping.data.remote.api
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

파일 시작 BOM 문자를 제거해 주세요.

Line 1의 package 앞 BOM 문자는 일부 툴(포맷터/정적분석)에서 불필요한 노이즈나 실패 원인이 됩니다. UTF-8(BOM 없음)으로 저장하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/data/remote/api/CouponApi.kt` at line
1, The file contains a leading BOM before the package declaration 'package
woowacourse.shopping.data.remote.api'; remove the invisible BOM character by
re-saving CouponApi.kt as UTF-8 without BOM (or remove the BOM byte manually) so
the package line starts immediately with 'package' and re-run
formatting/analysis to confirm no hidden characters remain.

override suspend fun getCartItemsCount(): Int = cartRemoteDataSource.getCartItemsCount()

override suspend fun getAllCartItems(): PagedCartItems = cartRemoteDataSource.getCartItems(0, Int.MAX_VALUE).toDomain()
override suspend fun getAllCartItems(): CartItems = cartRemoteDataSource.getCartItems(0, Int.MAX_VALUE).toCartItems()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Int.MAX_VALUE 페이지 크기 요청은 장애 유발 포인트가 됩니다.

Line 27의 사실상 무제한 조회는 서버/클라이언트 모두에 부담이 큽니다. 상한을 두고 필요한 범위만 읽는 방식으로 제한하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/woowacourse/shopping/data/repository/cart/CartRepositoryImpl.kt`
at line 27, The getAllCartItems implementation currently requests an unbounded
page size using Int.MAX_VALUE which can overload client/server; modify
CartRepositoryImpl.getAllCartItems to use a safe capped page size (e.g., a
constant like DEFAULT_MAX_PAGE_SIZE or a configurable value) or iterate
paginated calls to cartRemoteDataSource.getCartItems(offset, limit) until all
items are fetched, ensuring you replace the Int.MAX_VALUE call with the chosen
limit and handle repeated requests if full retrieval is required.

Comment on lines 33 to +36
cartRemoteDataSource.addCartItem(product.id, quantity)
_cartEvents.emit(Unit)
return getAllCartItems().findByProductId(product.id)?.id
?: throw IllegalStateException("장바구니에 상품이 추가되지 않았습니다.")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

추가 직후 cartId를 역조회하는 방식은 식별 정확도가 떨어질 수 있습니다.

Line 35에서 product.id로 전체 장바구니를 다시 조회해 id를 찾으면, 동일 상품 기존 항목/동시 변경 상황에서 의도한 cartId가 아닐 수 있습니다. 추가 API 응답 또는 더 안정적인 식별 전략으로 바꿀 수 있는지 확인해보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/woowacourse/shopping/data/repository/cart/CartRepositoryImpl.kt`
around lines 33 - 36, The current flow calls
cartRemoteDataSource.addCartItem(...) then re-queries with
getAllCartItems().findByProductId(product.id) which can return the wrong cart
entry under concurrent or pre-existing-product conditions; change the
implementation to obtain the created cart item's identifier directly from the
add-call instead of reverse-lookup: update cartRemoteDataSource.addCartItem to
return the created CartItem or its id (or read the created resource id from the
API response / Location header) and return that id, keeping
_cartEvents.emit(Unit) as-is; if you cannot change the remote signature, make
addCartItem accept/return a unique client-generated token (e.g., requestUuid)
and match on that token to reliably identify the newly-created cart entry.

Comment on lines +72 to +74
//PaymentReminderScheduler.schedule(context, 5 * 60 * 1000L, selectedItemIds)
PaymentReminderScheduler.schedule(context, 10 * 1000L, selectedItemIds)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

리마인더 지연 시간이 테스트 값(10초)으로 고정되어 있습니다.

Line 73은 사용자에게 너무 이른 알림을 유발할 가능성이 큽니다. 실제 요구사항 기준 지연값으로 분리(예: 상수/설정화)했는지 다시 확인해보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentScreen.kt` around
lines 72 - 74, The call to PaymentReminderScheduler.schedule in PaymentScreen is
using a hardcoded test delay (10 * 1000L) which causes too-early reminders;
replace this magic value with a configurable constant or resource (e.g.,
REMINDER_DELAY_MS) or read from app settings, and use that constant when calling
PaymentReminderScheduler.schedule(context, reminderDelay, selectedItemIds);
update its declaration (in the same file or a central config) and adjust any
tests to override the config rather than changing the production call.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

테스트용으로 넣은 값이 프로덕션 환경까지 넘어가는 경우 앱은 치명적일 수 있습니다. 값을 상수화하는 것도 좋아보이네요. 👍

Comment on lines +44 to +48
viewModelScope.launch {
val previousSelectedCoupon = (_uiState.value as? PaymentUiState.Success)?.selectedCoupon
_uiState.value = PaymentUiState.Loading
val allCart = cartRepository.getAllCartItems()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

저장소 호출 실패 시 상태 복구 경로가 필요합니다.

getAllCartItems(), getAvailableCoupons(), order() 실패 시 예외 처리 경로가 없어 결제 화면이 로딩 상태에 머물거나 이벤트가 누락될 수 있습니다.
여기서는 “실패를 UI 상태/이벤트로 변환”하는 최소 경로를 먼저 잡아보는 게 좋겠습니다.

    1. refresh()에서 실패 시 PaymentUiState.Error 또는 메시지 이벤트로 전환
    1. onClickPay()에서 실패 시 성공 이벤트/네비게이션 방지
    1. 네트워크 실패 케이스 단위 테스트로 회귀 방지

Also applies to: 58-59, 97-99

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt` around
lines 44 - 48, The refresh() and onClickPay() flows lack exception handling
causing the UI to stay in Loading or emit wrong events; wrap repository calls
(cartRepository.getAllCartItems(), couponRepository.getAvailableCoupons(),
orderRepository.order()) in try/catch, on failure restore _uiState to a failure
state (e.g., PaymentUiState.Error with message) or revert to the previous
success state (use previousSelectedCoupon captured before setting Loading) and
ensure onClickPay() does not emit success/navigation events when an exception
occurs; add unit tests for network failure scenarios to assert
PaymentUiState.Error is set and navigation events are not emitted.

Comment on lines +94 to +99
if (selectedIds.isEmpty()) {
_uiEvent.emit(PaymentUiEvent.ShowMessage("선택된 상품이 없습니다."))
}
cartRepository.order(selectedIds)
_uiEvent.emit(PaymentUiEvent.ShowMessage("주문이 완료되었습니다"))
_uiEvent.emit(PaymentUiEvent.NavigateToProductList)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

선택 상품이 없을 때 결제 플로우를 즉시 중단해 주세요.

Line 95에서 안내 메시지를 보낸 뒤에도 Line 97~99가 계속 실행됩니다. 이 경우 빈 주문 요청/완료 메시지/화면 이동까지 이어질 수 있어요.
이 지점에서 “메시지 전송 후 return”이 맞는지 먼저 확인해보면 어떨까요?

    1. selectedIds.isEmpty() 분기에서 함수 종료
    1. 종료 후에는 주문 API/성공 이벤트가 절대 실행되지 않는지 점검
    1. 빈 선택 케이스 UI 테스트 1개 추가
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt` around
lines 94 - 99, The empty-selection branch currently emits a message but
continues executing order and navigation; update the selectedIds.isEmpty()
branch in PaymentViewModel to emit PaymentUiEvent.ShowMessage("선택된 상품이 없습니다.")
and then immediately return so cartRepository.order(selectedIds) and subsequent
_uiEvent emissions never run for empty selections; after the change, add a
UI/unit test that verifies when selectedIds is empty the ShowMessage is emitted
once and cartRepository.order and NavigateToProductList are not invoked.

Comment on lines +5 to +29
object NotificationSettings {
private const val PREFS_NAME = "settings"
private const val KEY_NOTIFICATION = "notification"
private const val KEY_HAS_SCHEDULED_REMINDER = "has_scheduled_reminder"

fun isNotificationEnabled(context: Context): Boolean {
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return sp.getBoolean(KEY_NOTIFICATION, true)
}

fun setNotificationEnabled(context: Context, enabled: Boolean) {
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
sp.edit().putBoolean(KEY_NOTIFICATION, enabled).apply()
}

fun markReminderScheduled(context: Context, scheduled: Boolean) {
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
sp.edit().putBoolean(KEY_HAS_SCHEDULED_REMINDER, scheduled).apply()
}

fun hasScheduledReminder(context: Context): Boolean {
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return sp.getBoolean(KEY_HAS_SCHEDULED_REMINDER, false)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

알림 설정의 레이어 위치를 재검토해보세요.

PR 설명에서 요청하신 "알림 설정의 상태 관리 위치와 책임 분리"와 직접적으로 관련된 부분입니다.

현재 NotificationSettingsui.util 패키지에 위치하며 SharedPreferences를 직접 다룹니다. 다음 질문들을 고민해보세요:

  1. 레이어 분리: UI 레이어에서 데이터 저장소(SharedPreferences)를 직접 접근하는 것이 Clean Architecture 관점에서 적절한가요?

    • 데이터 레이어(data.datasource 또는 data.repository)로 이동하면 어떤 이점이 있을까요?
    • 나중에 저장 방식을 변경(예: DataStore)해야 한다면 영향 범위는 어떻게 될까요?
  2. 책임 분리: 이 객체는 순수하게 "설정 값 읽기/쓰기"만 담당하나요, 아니면 비즈니스 로직도 포함되나요?

    • markReminderScheduled처럼 알림 스케줄링과 관련된 상태를 관리하는 것은 도메인 로직에 가깝지 않나요?
  3. 테스트 용이성: 현재 구조에서 이 클래스를 사용하는 ViewModel이나 다른 컴포넌트를 테스트할 때 어려움은 없을까요?

Repository 패턴이나 DataSource 인터페이스 적용을 고려해보시고, 각 레이어의 책임과 의존성 방향을 다시 한 번 점검해보세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/woowacourse/shopping/ui/util/NotificationSettings.kt`
around lines 5 - 29, NotificationSettings가 UI 레이어에서 SharedPreferences를 직접 다루고
있으므로 데이터 레이어로 이동시키고 책임을 분리하세요: NotificationSettings 객체와 그
메소드(isNotificationEnabled, setNotificationEnabled, markReminderScheduled,
hasScheduledReminder)를 data 계층의 인터페이스(예: NotificationPreferences 또는
NotificationDataSource)로 추출한 뒤 SharedPreferences 접근 구현을 그 구현체에 두고 리포지토리/데이터소스
인터페이스를 ViewModel 등에서 생성자 주입으로 사용하도록 변경하세요; 이렇게 하면 저장 방식(DataStore 등) 변경과 단위 테스트가
용이해집니다.

Comment thread gradle/libs.versions.toml
room = "2.8.3"
kotlinx-serialization = "1.11.0"
okhttp = "4.12.0"
navigation = "2.8.9"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check Maven Central for navigation-compose version and GitHub advisories

# Check Maven Central for available versions
echo "=== Checking Maven Central for androidx.navigation:navigation-compose ==="
curl -s "https://search.maven.org/solrsearch/select?q=g:androidx.navigation+AND+a:navigation-compose&rows=20&wt=json" | jq -r '.response.docs[] | .v' | sort -V | tail -10

# Check for security advisories
echo -e "\n=== Checking for security advisories ==="
gh api graphql -f query='
{
  securityVulnerabilities(first: 10, ecosystem: MAVEN, package: "androidx.navigation:navigation-compose") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

Repository: woowacourse/android-shopping-order

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

VERSION="2.8.9"

echo "=== Checking Maven Central for AndroidX Navigation artifact versions ==="
for ARTIFACT in navigation navigation-compose; do
  echo -e "\n-- ${ARTIFACT} (androidx.navigation:${ARTIFACT}) --"
  curl -s "https://search.maven.org/solrsearch/select?q=g:androidx.navigation+AND+a:${ARTIFACT}&rows=20&wt=json" \
    | jq -r '.response.docs[].v' | sort -V | tail -20
  echo "Check contains ${VERSION} for ${ARTIFACT}:"
  curl -s "https://search.maven.org/solrsearch/select?q=g:androidx.navigation+AND+a:${ARTIFACT}+AND+v:${VERSION}&rows=1&wt=json" \
    | jq -r '.response.numFound'
done

echo -e "\n=== Checking OSV (GitHub GraphQL) for security advisories ==="
gh api graphql -f query='
{
  securityVulnerabilities(first: 10, ecosystem: MAVEN, package: "androidx.navigation:navigation-compose") {
    nodes {
      advisory { summary severity publishedAt }
      vulnerableVersionRange
      firstPatchedVersion { identifier }
    }
  }
}'

Repository: woowacourse/android-shopping-order

Length of output: 467


navigation = "2.8.9" 버전 존재 여부 재확인 필요

Maven Central 기준 androidx.navigation:navigation / androidx.navigation:navigation-compose에서 버전 2.8.9는 조회 0건으로 확인됩니다. 해당 좌표로 실제 의존성이 걸려 있다면 빌드/의존성 해소 실패 가능성이 큽니다.
또한 OSV/GitHub advisory 조회에서는 androidx.navigation:navigation-compose 취약점 노드는 없음이었습니다.
gradle/libs.versions.toml에서 navigation 버전이 매핑되는 정확한 아티팩트(예: runtime-ktx 등)를 먼저 확인한 뒤, 그 좌표로 2.8.9의 존재 여부를 다시 확인하거나 존재하는 버전으로 수정하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gradle/libs.versions.toml` at line 23, The libs.versions.toml entry
navigation = "2.8.9" may reference a non-existent artifact version; locate the
catalog alias `navigation` in gradle/libs.versions.toml and check which exact
Maven artifact it maps to (e.g., androidx.navigation:navigation-runtime-ktx or
androidx.navigation:navigation-compose), then verify that artifact/version
exists on Maven Central; if 2.8.9 is not published for that artifact, change the
`navigation` value to a published version (or correct the artifact mapping) so
the dependency coordinates resolve during build.

@katie0109
Copy link
Copy Markdown
Author

안녕하세요 베르! 3,4단계 잘 부탁드립니다 :)
미션 진행 중에 서버 터짐 이슈가 발생했는데 그 이유가 이전 미션에서 서버 도메인 주소를 그대로 노출하여 발생한 게 아닌가라는 추측이 있었습니다. 그래서 환경 변수 파일과 gitignore로 해당 파일을 숨겨놓았습니다.
그래서 로컬에서 아래 두 파일을 생성한 뒤 실행해주시면 됩니다!

  • 루트에 .env 생성
BASE_URL=http://techcourse-lv2-alb-250216202.ap-northeast-2.elb.amazonaws.com/
API_USERNAME=katie0109
API_PASSWORD=password
  • app/src/main/res/xml/network_security_config.xml 생성
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">techcourse-lv2-alb-250216202.ap-northeast-2.elb.amazonaws.com</domain>
    </domain-config>
</network-security-config>

Copy link
Copy Markdown
Member

@SeongHoonC SeongHoonC left a comment

Choose a reason for hiding this comment

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

안녕하세요 모스!

미션 전반적으로 잘 구현해주셨습니다. 👍
본문 내용에 남겨주신 부분을 고려해 리뷰 남겼습니다.
변경사항이 꽤 많아 이번 단계에서 중요한 내용을 위주로 피드백 남겼습니다.

MVVM 패턴에서 View, ViewModel 의 관심사를 고려했을 때 이벤트를 어떻게 설계하는게 좋을지 고민해보시면 좋을 것 같아요.

반영 후 재요청해주세요. 화이팅입니다 :)

Comment on lines +72 to +74
//PaymentReminderScheduler.schedule(context, 5 * 60 * 1000L, selectedItemIds)
PaymentReminderScheduler.schedule(context, 10 * 1000L, selectedItemIds)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

테스트용으로 넣은 값이 프로덕션 환경까지 넘어가는 경우 앱은 치명적일 수 있습니다. 값을 상수화하는 것도 좋아보이네요. 👍

val message: String,
) : PaymentUiEvent

object NavigateToProductList : PaymentUiEvent
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

UiEvent 의 이름에 대해서 어떻게 생각하시나요?

  1. View 의 동작을 결정할 때
  • 상품 목록으로 이동한다.
  1. 이벤트만 전달할 때
  • 주문에 성공했다.

  • Toast 가 아니라 다이얼로그를 띄운다면 ViewModel 이 수정되어야할까요?

  • 주문 성공 시 상세 화면으로 이동하는 것으로 변경되면 ViewModel 이 수정되어야할까요?

ViewModel 에서 동작을 명시한다는 것은 ViewModel 이 View 를 알고있다고 볼 수 있습니다. 그럼 View 를 import 하지 않더라도 영향을 받게됩니다. 알지 않더라도 화면을 변경해야하는거죠. 네비게이션은 누구의 관심사인가요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AppNavHost 가 Navigation 뿐만 아니라 event collect, route 에서 값 읽기 뷰모델 생성 등 너무 많은 일을 하고있어요. 화면이 많아지면 많아질수록 이 파일을 변경할 이유가 많아집니다. 네비게이션 책임만 남기고 화면의 책임을 분리해주세요.

Comment on lines +62 to +65
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
modifier = Modifier.fillMaxSize(),
) { innerPadding ->
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

snackbar 를 띄우기 위한 Scaffold, snackbarHostState 가 반복되고 있어요. 이 Scaffold 는 꼭 화면별로 따로 존재해야할까요? 더 상위에 위치시키고 여기서 showSnackbar 함수만 호출해도 되지않을까요?

Comment on lines +195 to +200
navController.navigate(ProductListRoute) {
popUpTo<PaymentRoute> {
inclusive = true
}
launchSingleTop = true
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

주문 완료 후 장바구니가 Back Stack에 남습니다. 결제 완료하고 뒤로가기 해보면 아실 수 있을거에요! 지금 남는 이유에 대해 학습하고 코멘트 남긴 후 수정해주세요 😄

private val _uiState = MutableStateFlow<PaymentUiState>(PaymentUiState.Loading)
val uiState: StateFlow<PaymentUiState> = _uiState.asStateFlow()

private val _uiEvent = MutableSharedFlow<PaymentUiEvent>()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

이벤트 수집이 Lifecycle aware 하게 변경되면 이벤트가 collector 가 없는 백그라운드에서 이벤트가 소실될 수 있을 것 같네요. 즉, 다시 돌아왔을 때 이벤트가 발생하지 않는거죠. 소실되지 않도록 하려면 어떻게 해야할까요?

@@ -0,0 +1,22 @@
package woowacourse.shopping.domain.model.coupon

object CouponTypes {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

enum 이나 sealed interface 를 활용해보는건 어떤가요?

Comment on lines +3 to +13
sealed interface UiEvent {
data class ShowSnackbar(
val message: String,
) : UiEvent

object NavigateToProductList : UiEvent

object NavigateToCart : UiEvent

object NavigateToPayment : UiEvent
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

공통으로 사용할 UiEvent 를 만들고 사용하고 있어요.
특정 화면으로 네비게이션하는 이벤트를 모든 Screen, ViewModel 에서 알고 있어야할지 고민해보면 좋을 것 같아요.

productDetailViewModel.uiEvent.collect { event ->
                    when (event) {
                        is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
                        UiEvent.NavigateToCart -> navController.navigate(CartRoute)
                        UiEvent.NavigateToProductList -> Unit
                        UiEvent.NavigateToPayment -> Unit
                    }
                }

private const val CHANNEL_ID = "payment_reminder_channel"
private const val CHANNEL_NAME = "결제 알림"
private const val NOTIF_ID = 2000
const val EXTRA_SELECTED_ITEM_IDS = "extra_selected_item_ids"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

extra_selected_item_ids 키가 여러 곳에 중복되고 있어요. 지금은 문자열이 똑같아서 문제가 발생하지 않지만 PaymentReminderContract 같은 공통된 상수로 빼서 관리할 필요가 있어보이네요

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

NotificationHelper가 알림 UI를 조립하는 역할뿐 아니라 MainActivity, action, flags, navigation extra까지 알고 있어요.
알림 클릭 시 어느 화면으로 이동할지에 대한 것도 있어 알림 표시 책임과 화면 이동 책임의 경계가 흐려지는 것 같아요.

알림 클릭용 activity PendingIntent 는 알람을 수신한 PaymentReminderReceiver 에서 만들고 NotificationHelper 는 알림 UI, 채널만 담당하도록 만드는건 어떤가요?

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.

2 participants