Skip to content

20260319 #689 전체 애니메이션 개선 및 통일감 확보#745

Open
Cassiiopeia wants to merge 3 commits intomainfrom
20260319_#689_전체_애니메이션_개선_및_통일감_확보

Hidden character warning

The head ref may contain hidden characters: "20260319_#689_\uc804\uccb4_\uc560\ub2c8\uba54\uc774\uc158_\uac1c\uc120_\ubc0f_\ud1b5\uc77c\uac10_\ud655\ubcf4"
Open

20260319 #689 전체 애니메이션 개선 및 통일감 확보#745
Cassiiopeia wants to merge 3 commits intomainfrom
20260319_#689_전체_애니메이션_개선_및_통일감_확보

Conversation

@Cassiiopeia
Copy link
Copy Markdown
Member

@Cassiiopeia Cassiiopeia commented Apr 1, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • 중앙화된 모션 시스템으로 일관된 애니메이션 타이밍 관리
    • 새로운 네비게이션 애니메이션 타입 (슬라이드 업, 수평 공유 축 전환)
    • 스켈레톤 기반 로딩 상태 UI
  • UI/UX Improvements

    • 향상된 터치 피드백 및 스케일 효과 위젯
    • 페이드-슬라이드 진입 애니메이션
    • 모달, 스낵바, 토글, 네비게이션 등 전체 애니메이션 일관성 개선

@Cassiiopeia Cassiiopeia requested a review from SeoHyun1024 April 1, 2026 16:18
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b739feba-92d8-43a5-98e8-0989371e526d

📥 Commits

Reviewing files that changed from the base of the PR and between afcb20a and 93e9ca9.

📒 Files selected for processing (2)
  • lib/screens/chat_room_screen.dart
  • pubspec.yaml
✅ Files skipped from review due to trivial changes (2)
  • pubspec.yaml
  • lib/screens/chat_room_screen.dart

Walkthrough

앱 전역의 애니메이션 상수를 중앙화하는 AppMotion 모델과 통일된 상호작용 위젯(AppPressable, AppFadeSlideIn, AppSkeleton)을 도입합니다. 기존의 하드코딩된 애니메이션 지속 시간과 곡선 및 제스처 핸들러를 이 표준화된 컴포넌트로 대체합니다.

Changes

Cohort / File(s) Summary
애니메이션 및 모션 상수
lib/models/app_motion.dart, lib/enums/navigation_types.dart
새로운 AppMotion 클래스(지속 시간 및 곡선 토큰) 추가, 네비게이션 타입에 slideUpsharedAxisHorizontal 추가, 기존 애니메이션 지속 시간 문서 업데이트
공유 UI 위젯
lib/widgets/common/app_pressable.dart, lib/widgets/common/app_fade_slide_in.dart, lib/widgets/common/app_skeleton.dart
사용자 상호작용(스케일, 리플), 페이드-슬라이드 애니메이션, 로딩 상태 스켈레톤을 위한 새로운 통일 위젯 추가
네비게이션 구현
lib/utils/common_utils.dart
navigateTo 메서드를 AppMotion 상수와 새로운 트랜지션(slideUp, sharedAxisHorizontal) 구현으로 업데이트
화면 통합
lib/screens/chat_room_screen.dart, lib/screens/chat_tab_screen.dart, lib/screens/home_tab_screen.dart, lib/screens/main_screen.dart, lib/screens/my_page_tab_screen.dart, lib/screens/notification_screen.dart, lib/screens/onboarding/onboarding_flow_screen.dart, lib/screens/register_tab_screen.dart, lib/screens/request_management_tab_screen.dart
AppMotion 애니메이션 상수 적용, AppPressable/AppFadeSlideIn 위젯으로 상호작용 패턴 통합, 리스트 항목에 스태거 애니메이션 추가
공통 위젯 업데이트
lib/widgets/common/common_modal.dart, lib/widgets/common/common_snack_bar.dart, lib/widgets/common/completed_toggle_switch.dart, lib/widgets/common/completion_button.dart, lib/widgets/common/custom_floating_button.dart, lib/widgets/common/glass_header_delegate.dart, lib/widgets/common/range_slider_widget.dart, lib/widgets/common/romrom_context_menu.dart, lib/widgets/common/scrollable_header.dart, lib/widgets/common/toggle_selector.dart, lib/widgets/common/triple_toggle_switch.dart, lib/widgets/common_app_bar.dart
하드코딩된 애니메이션 지속 시간/곡선을 AppMotion 상수로 교체, InkWellAppPressable로 리팩토링
특화된 위젯 업데이트
lib/widgets/chat_input_bar.dart, lib/widgets/chat_room_list_item.dart, lib/widgets/custom_bottom_navigation_bar.dart, lib/widgets/home_feed_ai_sort_button.dart, lib/widgets/home_feed_item_widget.dart, lib/widgets/login_button.dart, lib/widgets/notification_item_widget.dart, lib/widgets/onboarding_progress_header.dart, lib/widgets/request_list_item_card_widget.dart, lib/widgets/request_management_item_card_widget.dart, lib/widgets/sent_request_item_card.dart
제스처 핸들러를 GestureDetector/InkWell에서 AppPressable로 통합, 요청 카드에 선택적 onTap 콜백 추가
스켈레톤 위젯
lib/widgets/skeletons/home_tab_skeleton.dart, lib/widgets/skeletons/notification_skeleton.dart
홈 탭 및 알림 목록에 대한 새로운 로딩 플레이스홀더 위젯 추가
패키지 의존성
pubspec.yaml
animations: ^2.0.0 패키지 추가

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • SeoHyun1024
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 전체 애니메이션 개선과 통일감 확보라는 핵심 변경 사항을 명확하게 요약하고 있으며, 대규모 리팩토링의 주요 목표를 정확히 반영하고 있습니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 20260319_#689_전체_애니메이션_개선_및_통일감_확보

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

Caution

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

⚠️ Outside diff range comments (4)
lib/widgets/common/custom_floating_button.dart (1)

57-79: ⚠️ Potential issue | 🔴 Critical

중복 탭 핸들러로 인해 콜백이 두 번 실행됩니다.

AppPressable(Line 58)과 InkWell(Line 69) 모두 동일한 onTap 콜백을 등록하고 있습니다. AppPressable은 내부적으로 GestureDetector를 사용하고, InkWell도 별도로 탭을 처리하므로 사용자가 버튼을 누르면 enabledOnPressed 또는 disabledOnPressed두 번 호출됩니다.

lib/widgets/common/app_pressable.dart:159-169의 구현을 보면, enableRipple이 기본값(true)일 때 AppPressable 내부에서 이미 InkWell을 사용합니다. 따라서 내부 InkWellonTap을 제거하거나, AppPressableonTapnull로 설정해야 합니다.

🐛 InkWell onTap 제거 제안
 return Center(
   child: AppPressable(
     onTap: isEnabled ? enabledOnPressed : disabledOnPressed,
     enabled: isEnabled,
     borderRadius: BorderRadius.circular(10.r),
     child: SizedBox(
       width: buttonWidth.w,
       height: buttonHeight.h,
       child: Material(
         color: backgroundColor,
         borderRadius: BorderRadius.circular(10.r),
-        child: InkWell(
-          customBorder: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.r)),
-          onTap: isEnabled ? enabledOnPressed : disabledOnPressed,
-          highlightColor: highlightColor,
-          splashColor: splashColor,
-          borderRadius: BorderRadius.circular(10.r),
-          child: Center(
-            child: Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: false),
-          ),
-        ),
+        child: Center(
+          child: Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: false),
+        ),
       ),
     ),
   ),
 );

또는 ripple 효과를 유지하려면 AppPressableenableRipple: truerippleColor: highlightColor를 설정하고 내부 InkWell을 제거하세요.

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

In `@lib/widgets/common/custom_floating_button.dart` around lines 57 - 79, The
button registers the same tap callback twice because AppPressable and the inner
InkWell both set onTap; fix by removing the duplicate tap handler: either remove
the inner InkWell.onTap (in the code snippet around the InkWell widget) and let
AppPressable handle taps, or set AppPressable.onTap to null and keep the InkWell
handler—prefer removing the InkWell.onTap so AppPressable remains the single
source of truth; if you still need ripple, enable AppPressable.enableRipple =
true and pass rippleColor (use highlightColor) so the visual effect is preserved
while avoiding double callbacks.
lib/widgets/chat_input_bar.dart (1)

97-124: ⚠️ Potential issue | 🟡 Minor

suffixIcon 크기와 constraints 불일치 가능성 확인 필요

AppPressable 내부 Containermargin: EdgeInsets.all(4.w)width: 40.w를 가지고 있어 실제 필요 공간은 약 48.w입니다. 하지만 suffixIconConstraintsmaxWidth: 40.w로 설정되어 있어 클리핑이나 오버플로우가 발생할 수 있습니다.

🔧 constraints 조정 제안
 suffixIconConstraints: BoxConstraints(
-  minWidth: 40.w,
-  minHeight: 40.w,
-  maxWidth: 40.w,
-  maxHeight: 40.w,
+  minWidth: 48.w,
+  minHeight: 48.w,
+  maxWidth: 48.w,
+  maxHeight: 48.w,
 ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/widgets/chat_input_bar.dart` around lines 97 - 124, The suffixIcon's
visual size (AppPressable -> Container with margin: EdgeInsets.all(4.w) and
width: 40.w) actually needs about 48.w, but suffixIconConstraints currently caps
at maxWidth/maxHeight: 40.w which can cause clipping; update the BoxConstraints
in suffixIconConstraints to at least min/maxWidth and min/maxHeight: 48.w (or
remove the Container margin and keep 40.w) so the Container inside AppPressable
fits without overflow — adjust either the Container width/margin or the
BoxConstraints (in chat_input_bar.dart referencing AppPressable and
suffixIconConstraints) to match the true rendered size.
lib/screens/register_tab_screen.dart (2)

643-655: ⚠️ Potential issue | 🟡 Minor

코딩 가이드라인 위반: context.navigateTo() 대신 MaterialPageRoute를 직접 사용하고 있습니다.

코딩 가이드라인에 따르면 화면 전환 시 context.navigateTo()를 사용해야 합니다.

🔧 제안된 수정
   Future<void> _navigateToItemDetail(Item item) async {
     if (item.itemId == null) return;

-    final result = await Navigator.push(
-      context,
-      MaterialPageRoute(
-        builder: (_) => ItemDetailDescriptionScreen(
+    final result = await context.navigateTo(
+      screen: ItemDetailDescriptionScreen(
           itemId: item.itemId!,
           imageSize: Size(MediaQuery.of(context).size.width, 400.h),
           currentImageIndex: 0,
           heroTag: 'itemImage_${item.itemId}_0',
           isMyItem: true,
           isRequestManagement: false,
         ),
-      ),
     );

As per coding guidelines: "Use context.navigateTo() for screen navigation (MaterialPageRoute direct usage prohibited)"

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

In `@lib/screens/register_tab_screen.dart` around lines 643 - 655, The code uses
Navigator.push with MaterialPageRoute to open ItemDetailDescriptionScreen (see
Navigator.push and MaterialPageRoute wrapping ItemDetailDescriptionScreen),
which violates the guideline to use context.navigateTo(); replace the
Navigator.push/MaterialPageRoute call with a call to context.navigateTo(...)
that passes the ItemDetailDescriptionScreen (and the same parameters: itemId:
item.itemId, imageSize, currentImageIndex: 0, heroTag, isMyItem: true,
isRequestManagement: false) so navigation is performed via the
context.navigateTo API.

664-675: ⚠️ Potential issue | 🟡 Minor

코딩 가이드라인 위반: _navigateToEditItem에서도 MaterialPageRoute를 직접 사용하고 있습니다.

이 메서드도 context.navigateTo()를 사용하도록 수정해야 합니다.

🔧 제안된 수정
   Future<void> _navigateToEditItem(Item item) async {
-    final result = await Navigator.push(
-      context,
-      MaterialPageRoute(
-        builder: (_) => ItemModificationScreen(
+    final result = await context.navigateTo(
+      screen: ItemModificationScreen(
           itemId: item.itemId,
           onClose: () {
             Navigator.pop(context);
           },
         ),
-      ),
     );

As per coding guidelines: "Use context.navigateTo() for screen navigation (MaterialPageRoute direct usage prohibited)"

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

In `@lib/screens/register_tab_screen.dart` around lines 664 - 675, The
_navigateToEditItem function currently uses MaterialPageRoute directly; replace
that with the app's navigation helper by calling context.navigateTo(...) and
passing a builder that returns ItemModificationScreen with the same parameters
(itemId: item.itemId and onClose callback), preserving the await of the
Navigator result; ensure the onClose still closes the screen appropriately (use
the existing close callback) and that the awaited result variable remains named
result.
🧹 Nitpick comments (8)
lib/widgets/onboarding_progress_header.dart (1)

155-190: 정사각형 인디케이터에 height: 24.w를 사용하세요.

프로그레스 인디케이터가 원형(정사각형 기반)이므로, iPad 반응형 대응을 위해 height.w 단위를 사용하는 것이 좋습니다. As per coding guidelines: "Use height: N.w instead of height: N.h for images and square elements"

♻️ 수정 제안
     // 완료된 단계
     return Container(
       width: 24.w,
-      height: 24.h,
+      height: 24.w,
       decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primaryYellow),

     // 현재 단계
     return Container(
       width: 24.w,
-      height: 24.h,
+      height: 24.w,
       decoration: BoxDecoration(

     // 대기 단계
     return Container(
       width: 24.w,
-      height: 24.h,
+      height: 24.w,
       decoration: BoxDecoration(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/widgets/onboarding_progress_header.dart` around lines 155 - 190, The
three circular Container widgets used for the progress indicator use height:
24.h which makes them non-square on some responsive breakpoints; update each
Container (the one returning the checked icon, the one for step ==
widget.currentStep, and the fallback pending step) to use height: 24.w instead
of 24.h so the width and height remain square across devices (look for the
Containers that set width: 24.w, height: 24.h and replace the height unit to
24.w).
lib/widgets/notification_item_widget.dart (1)

118-121: 정사각형 요소에는 height: 24.w를 사용하세요.

iPad 반응형 대응을 위해 정사각형 요소는 widthheight 모두 .w 단위를 사용해야 합니다. As per coding guidelines: "Use height: N.w (width-based scaling) instead of height: N.h for images and square elements for safer iPad responsiveness"

♻️ 수정 제안
         Container(
           width: 24.w,
-          height: 24.h,
+          height: 24.w,
           margin: EdgeInsets.only(top: 1.h, left: 8.w),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/widgets/notification_item_widget.dart` around lines 118 - 121, The
Container that creates a square icon currently uses width: 24.w and height:
24.h; update the height to use width-based scaling so both sides use .w (change
height: 24.h to height: 24.w) to keep the element square for iPad
responsiveness—locate the Container inside the NotificationItemWidget (where
width: 24.w is set) and make the single-line replacement.
lib/widgets/home_feed_item_widget.dart (1)

169-184: 기존 코드에서 MaterialPageRoute 직접 사용이 발견되었습니다.

이 PR의 범위는 아니지만, 코딩 가이드라인에 따르면 context.navigateTo()를 사용해야 합니다. 추후 리팩토링 시 고려해 주세요. As per coding guidelines: "Use context.navigateTo() for screen navigation (MaterialPageRoute direct usage prohibited)"

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

In `@lib/widgets/home_feed_item_widget.dart` around lines 169 - 184, Replace the
direct use of MaterialPageRoute in the onTap handler inside
home_feed_item_widget (the async onTap that awaits Navigator.push) with the
project's navigation helper by calling context.navigateTo<dynamic>(...) and
passing the ItemDetailDescriptionScreen instance with the same arguments
(itemId: widget.item.itemUuid ?? '', imageSize: Size(screenWidth, screenWidth),
currentImageIndex: index, heroTag: 'itemImage_${widget.item.itemUuid ??
widget.item.id}_$index', homeFeedItem: widget.item, isMyItem: false,
isRequestManagement: false, isTradeRequestAllowed: true) so the call still
awaits and captures the result variable as before.
lib/widgets/common_app_bar.dart (1)

65-73: leading 아이콘의 hit test 영역 확대 권장

leadingWidth: 72.wSizedBox.square(dimension: 32.w)를 배치한 경우, Flutter의 AppBar가 자동으로 icon을 중앙 정렬하므로 시각적 배치는 문제없습니다. 다만, AppPressable의 tap 영역이 32.w로 제한되어 72.w의 leadingWidth 공간을 충분히 활용하지 못하고 있습니다.

사용성 개선을 위해 AppPressable을 SizedBox(width: 72.w, height: 56.w, child: Center(child: ...))로 감싸거나, AppPressable 자체를 leadingWidth에 맞춰 확대하여 전체 tap 영역을 활용하도록 수정하는 것을 권장합니다.

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

In `@lib/widgets/common_app_bar.dart` around lines 65 - 73, The leading icon's
tappable area is currently limited because AppPressable wraps a
SizedBox.square(dimension: 32.w) while the AppBar uses leadingWidth: 72.w;
expand the hit-test area by making the AppPressable occupy the full leading area
(e.g., wrap AppPressable with a SizedBox(width: 72.w, height: 56.w, child:
Center(child: ...)) or change AppPressable to use width: 72.w and height: 56.w)
so that the touch target matches the visual leadingWidth; update the widget
containing leading (the leading: AppPressable(...) block) to center the 32.w
icon inside the larger tappable container.
lib/screens/chat_tab_screen.dart (1)

331-387: SliverList 내 AppFadeSlideIn 사용 시 스크롤할 때마다 애니메이션이 재실행됩니다.

SliverList는 화면 밖으로 나간 아이템의 위젯을 파괴하고 다시 화면에 들어올 때 재생성합니다. AppFadeSlideIn은 StatefulWidget이므로 위젯이 재생성될 때마다 initState가 실행되고, 새로운 AnimationController를 통해 애니메이션이 처음부터 다시 시작됩니다.

이를 해결하기 위해 다음 중 하나를 검토하세요:

  • AutomaticKeepAliveClientMixin을 사용하여 위젯 상태를 메모리에 유지하기
  • 애니메이션 완료 후 상태를 추적하여 재실행되지 않도록 방지하기
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/screens/chat_tab_screen.dart` around lines 331 - 387, The AppFadeSlideIn
animation replays when SliverList rebuilds because its State is
destroyed/recreated; fix by preserving its state: either make AppFadeSlideIn's
State mixin with AutomaticKeepAliveClientMixin (add "with
AutomaticKeepAliveClientMixin" to its State class, override get wantKeepAlive =>
true, and call super.build(context) so the AnimationController isn't disposed)
or give each AppFadeSlideIn a stable identity so it isn't rebuilt (wrap the
returned widget with a Key like Key(chatRoomDetail.chatRoomId) or pass a
PageStorageKey(chatRoomDetail.chatRoomId) into AppFadeSlideIn); reference
AppFadeSlideIn and chatRoomDetail.chatRoomId (and the surrounding
ChatRoomListItem) when making the change.
lib/screens/notification_screen.dart (1)

411-416: separatorBuilder를 제거하고 itemBuilder에서 직접 처리하는 것을 고려해 보세요.

SizedBox.shrink()를 반환하는 separatorBuilder는 실질적으로 구분선을 렌더링하지 않으므로, ListView.builder를 사용하거나 separatorBuilder 파라미터를 제거하는 것이 더 명확합니다.

♻️ 제안된 수정
-          child: ListView.separated(
+          child: ListView.builder(
             shrinkWrap: true,
             physics: const NeverScrollableScrollPhysics(),
             itemCount: notifications.length,
-            separatorBuilder: (_, index) => const SizedBox.shrink(),
             itemBuilder: (context, index) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/screens/notification_screen.dart` around lines 411 - 416, The
ListView.separated usage currently supplies a no-op separatorBuilder returning
SizedBox.shrink(); replace it with a plain ListView.builder (or remove the
separatorBuilder) to make intent clear: use ListView.builder with the same
shrinkWrap, physics: const NeverScrollableScrollPhysics(), itemCount:
notifications.length and the existing itemBuilder logic (referencing
ListView.separated, separatorBuilder, itemBuilder, notifications.length,
shrinkWrap, and NeverScrollableScrollPhysics in the diff) so separators are not
redundantly provided.
lib/screens/register_tab_screen.dart (1)

495-500: AppPressableenabled 파라미터를 활용하여 중복 탭 방지를 단순화할 수 있습니다.

현재 _isFabProcessing 플래그로 내부 가드를 구현했지만, AppPressableenabled 파라미터를 사용하면 시각적 피드백과 탭 방지가 함께 처리됩니다.

♻️ 제안된 수정
              child: AppPressable(
                scaleDown: AppPressable.scaleButton,
                enableRipple: false,
                borderRadius: BorderRadius.circular(100.r),
+               enabled: !_isFabProcessing,
                onTap: () async {
-                 if (_isFabProcessing) return;
                  setState(() => _isFabProcessing = true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/screens/register_tab_screen.dart` around lines 495 - 500, Replace the
manual tap-guard inside the AppPressable onTap with the widget's enabled
parameter: set enabled: !_isFabProcessing on the AppPressable and remove the
early-return check in the onTap handler; ensure you still flip _isFabProcessing
to true at the start of the async operation and back to false when it completes
or fails (inside the same async method invoked by onTap) so the visual disabled
state and tap prevention are kept in sync (symbols to update: AppPressable,
enabled, _isFabProcessing, onTap).
lib/widgets/common/app_fade_slide_in.dart (1)

44-45: slideOffset 단위 정의(px)와 실제 애니메이션 단위가 불일치합니다.

Line 44-45는 px 기준으로 설명하지만, Line 67의 SlideTransition 오프셋은 child 크기 비율 기반이라 위젯 크기에 따라 이동량이 달라집니다. 동일한 모션 토큰을 써도 화면별 체감이 달라질 수 있습니다.

수정 제안 (px 의미를 실제로 보장)
 class _AppFadeSlideInState extends State<AppFadeSlideIn> with SingleTickerProviderStateMixin {
   late AnimationController _controller;
   late Animation<double> _opacity;
-  late Animation<Offset> _slide;
+  late Animation<double> _translateY;
@@
-    _slide = Tween<Offset>(
-      begin: Offset(0, widget.slideOffset / 100),
-      end: Offset.zero,
-    ).animate(CurvedAnimation(parent: _controller, curve: widget.curve));
+    _translateY =
+        Tween<double>(begin: widget.slideOffset, end: 0).animate(CurvedAnimation(parent: _controller, curve: widget.curve));
@@
   Widget build(BuildContext context) {
     return FadeTransition(
       opacity: _opacity,
-      child: SlideTransition(position: _slide, child: widget.child),
+      child: AnimatedBuilder(
+        animation: _translateY,
+        child: widget.child,
+        builder: (context, child) => Transform.translate(
+          offset: Offset(0, _translateY.value),
+          child: child,
+        ),
+      ),
     );
   }
 }
In Flutter, does SlideTransition.position use an Offset in logical pixels or as a fraction of the child's size?

Also applies to: 66-69, 90-93

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

In `@lib/widgets/common/app_fade_slide_in.dart` around lines 44 - 45, 현재
slideOffset 필드가 "px"로 문서화되어 있지만 SlideTransition에 전달되는 Offset은 자식 크기 대비 비율이므로 동작이
일치하지 않습니다; slideOffset을 실제 픽셀 단위로 보장하려면 build 내부에서 LayoutBuilder/BoxConstraints로
자식 높이(또는 너비, 방향에 따라)를 얻어 dyFraction = slideOffset / constraints.maxHeight(또는 적절한
축 크기)로 변환한 후 그 비율을 SlideTransition의 Offset으로 사용하도록 변경하세요 (참조: slideOffset 필드,
SlideTransition 사용 부분, build 메서드에서 Tween/Animation 생성 지점). Ensure you handle
zero-height defensively and update the doc comment to state that slideOffset
accepts pixels and is converted to a fractional Offset using the child's size.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/screens/chat_tab_screen.dart`:
- Around line 374-376: The code uses
Navigator.of(context).push<bool>(MaterialPageRoute(builder: (_) =>
ChatRoomScreen(chatRoomId: roomId))) directly; change this to use the project
navigation helper by calling context.navigateTo(...) with the ChatRoomScreen
route instead, passing the same ChatRoomScreen(chatRoomId: roomId) as the target
and preserving the returned bool (refreshed) semantics; update the call site
where Navigator.of(...).push<bool>(MaterialPageRoute(...)) appears to use
context.navigateTo(ChatRoomScreen(chatRoomId: roomId)) or the equivalent
context.navigateTo(...) overload used in the app.

In `@lib/screens/main_screen.dart`:
- Around line 59-65: AnimatedSwitcher + KeyedSubtree
(ValueKey<int>(_currentTabIndex)) rebuilds each tab from _navigationTabScreens
and causes duplicate API calls in screens like NotificationScreen,
RegisterTabScreen, RequestManagementTabScreen (initState calls
_loadNotifications/_loadMyItems/_loadInitialItems). Replace AnimatedSwitcher
with an IndexedStack to preserve each child instance and apply fade with an
AnimatedOpacity (or FadeTransition) on the active child to keep the visual
animation, or alternatively implement AutomaticKeepAliveClientMixin in the
mentioned screens so their state and API calls are preserved; update
main_screen.dart to use IndexedStack(_navigationTabScreens) and control opacity
for the selected index or add keep-alive mixin to
NotificationScreen/RegisterTabScreen/RequestManagementTabScreen to prevent
reinitialization.

In `@lib/screens/request_management_tab_screen.dart`:
- Around line 763-764: The code force-unwraps giveItem.address and
giveItem.createdDate (address: giveItem.address!, createdDate:
giveItem.createdDate!), which can crash at runtime; change these to safe
null-aware values by checking for null and providing fallbacks (e.g., address:
giveItem.address ?? await resolveAndCacheAddress(giveItem) ?? '' and
createdDate: giveItem.createdDate ?? DateTime.now()), or pre-resolve and cache
the address via resolveAndCacheAddress(giveItem) before building the widget so
you pass non-null values into the constructor; update the code paths that
construct the widget to use these null-safe expressions or pre-processing to
avoid any forced unwrapping.

In `@lib/widgets/common/app_pressable.dart`:
- Around line 155-181: GestureDetector is currently passing
widget.onTap/widget.onLongPress while InkWell also handles them when
widget.enableRipple is true, causing duplicate callbacks; update the
GestureDetector onTap/onLongPress to only forward taps when enableRipple is
false (e.g. use onTap: widget.enableRipple ? null : (widget.enabled ?
widget.onTap : null) and same for onLongPress) while keeping
_onTapDown/_onTapUp/_onTapCancel for the scale animation, and keep InkWell
handling taps when widget.enableRipple is true (ensure InkWell still uses
widget.enabled to gate its onTap/onLongPress, and preserve effectiveRadius,
highlightColor and splashColor).

In `@lib/widgets/common/completion_button.dart`:
- Around line 62-90: Remove the duplicate tap handler by letting AppPressable
handle taps and deleting the inner InkWell onTap; specifically, in the
CompletionButton widget remove the onTap passed to the inner InkWell (the calls
using enabledOnPressed/disabledOnPressed) so only AppPressable's onTap (and its
enableRipple behavior) invokes callbacks; ensure effectiveEnabled,
enabledOnPressed and disabledOnPressed remain wired to AppPressable and keep
InkWell for visual ripple only (no tap handler).

In `@lib/widgets/common/romrom_context_menu.dart`:
- Around line 87-90: The reverseCurve is hardcoded to Curves.easeInCubic which
violates the AppMotion token policy; update the CurvedAnimation instantiation
that uses _animationController and curve: AppMotion.decelerate to set
reverseCurve: AppMotion.accelerate (add AppMotion.accelerate constant if missing
in the AppMotion class) so both forward and reverse curves use the AppMotion
tokens consistently.

---

Outside diff comments:
In `@lib/screens/register_tab_screen.dart`:
- Around line 643-655: The code uses Navigator.push with MaterialPageRoute to
open ItemDetailDescriptionScreen (see Navigator.push and MaterialPageRoute
wrapping ItemDetailDescriptionScreen), which violates the guideline to use
context.navigateTo(); replace the Navigator.push/MaterialPageRoute call with a
call to context.navigateTo(...) that passes the ItemDetailDescriptionScreen (and
the same parameters: itemId: item.itemId, imageSize, currentImageIndex: 0,
heroTag, isMyItem: true, isRequestManagement: false) so navigation is performed
via the context.navigateTo API.
- Around line 664-675: The _navigateToEditItem function currently uses
MaterialPageRoute directly; replace that with the app's navigation helper by
calling context.navigateTo(...) and passing a builder that returns
ItemModificationScreen with the same parameters (itemId: item.itemId and onClose
callback), preserving the await of the Navigator result; ensure the onClose
still closes the screen appropriately (use the existing close callback) and that
the awaited result variable remains named result.

In `@lib/widgets/chat_input_bar.dart`:
- Around line 97-124: The suffixIcon's visual size (AppPressable -> Container
with margin: EdgeInsets.all(4.w) and width: 40.w) actually needs about 48.w, but
suffixIconConstraints currently caps at maxWidth/maxHeight: 40.w which can cause
clipping; update the BoxConstraints in suffixIconConstraints to at least
min/maxWidth and min/maxHeight: 48.w (or remove the Container margin and keep
40.w) so the Container inside AppPressable fits without overflow — adjust either
the Container width/margin or the BoxConstraints (in chat_input_bar.dart
referencing AppPressable and suffixIconConstraints) to match the true rendered
size.

In `@lib/widgets/common/custom_floating_button.dart`:
- Around line 57-79: The button registers the same tap callback twice because
AppPressable and the inner InkWell both set onTap; fix by removing the duplicate
tap handler: either remove the inner InkWell.onTap (in the code snippet around
the InkWell widget) and let AppPressable handle taps, or set AppPressable.onTap
to null and keep the InkWell handler—prefer removing the InkWell.onTap so
AppPressable remains the single source of truth; if you still need ripple,
enable AppPressable.enableRipple = true and pass rippleColor (use
highlightColor) so the visual effect is preserved while avoiding double
callbacks.

---

Nitpick comments:
In `@lib/screens/chat_tab_screen.dart`:
- Around line 331-387: The AppFadeSlideIn animation replays when SliverList
rebuilds because its State is destroyed/recreated; fix by preserving its state:
either make AppFadeSlideIn's State mixin with AutomaticKeepAliveClientMixin (add
"with AutomaticKeepAliveClientMixin" to its State class, override get
wantKeepAlive => true, and call super.build(context) so the AnimationController
isn't disposed) or give each AppFadeSlideIn a stable identity so it isn't
rebuilt (wrap the returned widget with a Key like Key(chatRoomDetail.chatRoomId)
or pass a PageStorageKey(chatRoomDetail.chatRoomId) into AppFadeSlideIn);
reference AppFadeSlideIn and chatRoomDetail.chatRoomId (and the surrounding
ChatRoomListItem) when making the change.

In `@lib/screens/notification_screen.dart`:
- Around line 411-416: The ListView.separated usage currently supplies a no-op
separatorBuilder returning SizedBox.shrink(); replace it with a plain
ListView.builder (or remove the separatorBuilder) to make intent clear: use
ListView.builder with the same shrinkWrap, physics: const
NeverScrollableScrollPhysics(), itemCount: notifications.length and the existing
itemBuilder logic (referencing ListView.separated, separatorBuilder,
itemBuilder, notifications.length, shrinkWrap, and NeverScrollableScrollPhysics
in the diff) so separators are not redundantly provided.

In `@lib/screens/register_tab_screen.dart`:
- Around line 495-500: Replace the manual tap-guard inside the AppPressable
onTap with the widget's enabled parameter: set enabled: !_isFabProcessing on the
AppPressable and remove the early-return check in the onTap handler; ensure you
still flip _isFabProcessing to true at the start of the async operation and back
to false when it completes or fails (inside the same async method invoked by
onTap) so the visual disabled state and tap prevention are kept in sync (symbols
to update: AppPressable, enabled, _isFabProcessing, onTap).

In `@lib/widgets/common_app_bar.dart`:
- Around line 65-73: The leading icon's tappable area is currently limited
because AppPressable wraps a SizedBox.square(dimension: 32.w) while the AppBar
uses leadingWidth: 72.w; expand the hit-test area by making the AppPressable
occupy the full leading area (e.g., wrap AppPressable with a SizedBox(width:
72.w, height: 56.w, child: Center(child: ...)) or change AppPressable to use
width: 72.w and height: 56.w) so that the touch target matches the visual
leadingWidth; update the widget containing leading (the leading:
AppPressable(...) block) to center the 32.w icon inside the larger tappable
container.

In `@lib/widgets/common/app_fade_slide_in.dart`:
- Around line 44-45: 현재 slideOffset 필드가 "px"로 문서화되어 있지만 SlideTransition에 전달되는
Offset은 자식 크기 대비 비율이므로 동작이 일치하지 않습니다; slideOffset을 실제 픽셀 단위로 보장하려면 build 내부에서
LayoutBuilder/BoxConstraints로 자식 높이(또는 너비, 방향에 따라)를 얻어 dyFraction = slideOffset
/ constraints.maxHeight(또는 적절한 축 크기)로 변환한 후 그 비율을 SlideTransition의 Offset으로
사용하도록 변경하세요 (참조: slideOffset 필드, SlideTransition 사용 부분, build 메서드에서
Tween/Animation 생성 지점). Ensure you handle zero-height defensively and update the
doc comment to state that slideOffset accepts pixels and is converted to a
fractional Offset using the child's size.

In `@lib/widgets/home_feed_item_widget.dart`:
- Around line 169-184: Replace the direct use of MaterialPageRoute in the onTap
handler inside home_feed_item_widget (the async onTap that awaits
Navigator.push) with the project's navigation helper by calling
context.navigateTo<dynamic>(...) and passing the ItemDetailDescriptionScreen
instance with the same arguments (itemId: widget.item.itemUuid ?? '', imageSize:
Size(screenWidth, screenWidth), currentImageIndex: index, heroTag:
'itemImage_${widget.item.itemUuid ?? widget.item.id}_$index', homeFeedItem:
widget.item, isMyItem: false, isRequestManagement: false, isTradeRequestAllowed:
true) so the call still awaits and captures the result variable as before.

In `@lib/widgets/notification_item_widget.dart`:
- Around line 118-121: The Container that creates a square icon currently uses
width: 24.w and height: 24.h; update the height to use width-based scaling so
both sides use .w (change height: 24.h to height: 24.w) to keep the element
square for iPad responsiveness—locate the Container inside the
NotificationItemWidget (where width: 24.w is set) and make the single-line
replacement.

In `@lib/widgets/onboarding_progress_header.dart`:
- Around line 155-190: The three circular Container widgets used for the
progress indicator use height: 24.h which makes them non-square on some
responsive breakpoints; update each Container (the one returning the checked
icon, the one for step == widget.currentStep, and the fallback pending step) to
use height: 24.w instead of 24.h so the width and height remain square across
devices (look for the Containers that set width: 24.w, height: 24.h and replace
the height unit to 24.w).
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dd56260a-9b5c-48f1-9df2-8770034eefa8

📥 Commits

Reviewing files that changed from the base of the PR and between 5a0cb12 and afcb20a.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (42)
  • lib/enums/navigation_types.dart
  • lib/models/app_motion.dart
  • lib/screens/chat_room_screen.dart
  • lib/screens/chat_tab_screen.dart
  • lib/screens/home_tab_screen.dart
  • lib/screens/main_screen.dart
  • lib/screens/my_page_tab_screen.dart
  • lib/screens/notification_screen.dart
  • lib/screens/onboarding/onboarding_flow_screen.dart
  • lib/screens/register_tab_screen.dart
  • lib/screens/request_management_tab_screen.dart
  • lib/screens/splash_screen.dart
  • lib/utils/common_utils.dart
  • lib/widgets/chat_input_bar.dart
  • lib/widgets/chat_room_list_item.dart
  • lib/widgets/common/app_fade_slide_in.dart
  • lib/widgets/common/app_pressable.dart
  • lib/widgets/common/app_skeleton.dart
  • lib/widgets/common/common_modal.dart
  • lib/widgets/common/common_snack_bar.dart
  • lib/widgets/common/completed_toggle_switch.dart
  • lib/widgets/common/completion_button.dart
  • lib/widgets/common/custom_floating_button.dart
  • lib/widgets/common/glass_header_delegate.dart
  • lib/widgets/common/range_slider_widget.dart
  • lib/widgets/common/romrom_context_menu.dart
  • lib/widgets/common/scrollable_header.dart
  • lib/widgets/common/toggle_selector.dart
  • lib/widgets/common/triple_toggle_switch.dart
  • lib/widgets/common_app_bar.dart
  • lib/widgets/custom_bottom_navigation_bar.dart
  • lib/widgets/home_feed_ai_sort_button.dart
  • lib/widgets/home_feed_item_widget.dart
  • lib/widgets/login_button.dart
  • lib/widgets/notification_item_widget.dart
  • lib/widgets/onboarding_progress_header.dart
  • lib/widgets/request_list_item_card_widget.dart
  • lib/widgets/request_management_item_card_widget.dart
  • lib/widgets/sent_request_item_card.dart
  • lib/widgets/skeletons/home_tab_skeleton.dart
  • lib/widgets/skeletons/notification_skeleton.dart
  • pubspec.yaml

Comment on lines +374 to +376
final refreshed = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => ChatRoomScreen(chatRoomId: roomId)));
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

context.navigateTo() 대신 MaterialPageRoute를 직접 사용하고 있습니다.

코딩 가이드라인에 따르면 화면 전환 시 context.navigateTo()를 사용해야 합니다.

🔧 context.navigateTo() 사용으로 변경
-                            final refreshed = await Navigator.of(
-                              context,
-                            ).push<bool>(MaterialPageRoute(builder: (_) => ChatRoomScreen(chatRoomId: roomId)));
+                            final refreshed = await context.navigateTo<bool>(
+                              screen: ChatRoomScreen(chatRoomId: roomId),
+                            );

As per coding guidelines: "Use context.navigateTo() for screen navigation (MaterialPageRoute direct usage prohibited)"

📝 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
final refreshed = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => ChatRoomScreen(chatRoomId: roomId)));
final refreshed = await context.navigateTo<bool>(
screen: ChatRoomScreen(chatRoomId: roomId),
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/screens/chat_tab_screen.dart` around lines 374 - 376, The code uses
Navigator.of(context).push<bool>(MaterialPageRoute(builder: (_) =>
ChatRoomScreen(chatRoomId: roomId))) directly; change this to use the project
navigation helper by calling context.navigateTo(...) with the ChatRoomScreen
route instead, passing the same ChatRoomScreen(chatRoomId: roomId) as the target
and preserving the returned bool (refreshed) semantics; update the call site
where Navigator.of(...).push<bool>(MaterialPageRoute(...)) appears to use
context.navigateTo(ChatRoomScreen(chatRoomId: roomId)) or the equivalent
context.navigateTo(...) overload used in the app.

Comment on lines +59 to +65
body: AnimatedSwitcher(
duration: AppMotion.normal,
switchInCurve: AppMotion.entry,
switchOutCurve: AppMotion.entry,
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: KeyedSubtree(key: ValueKey<int>(_currentTabIndex), child: _navigationTabScreens[_currentTabIndex]),
),
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

AnimatedSwitcher로 인해 탭 전환 시마다 위젯이 재생성되어 API가 중복 호출됩니다.

AnimatedSwitcher는 자식 위젯을 완전히 파괴하고 재생성합니다. NotificationScreen, RegisterTabScreen, RequestManagementTabScreen 등의 initState에서 API 호출(_loadNotifications, _loadMyItems, _loadInitialItems)이 이루어지므로, 탭을 전환할 때마다 불필요한 네트워크 요청이 발생하고 스크롤 위치 등의 상태가 초기화됩니다.

기존의 IndexedStack 방식을 유지하면서 페이드 애니메이션만 적용하거나, AutomaticKeepAliveClientMixin을 활용하는 것을 권장합니다.

🔧 IndexedStack + AnimatedOpacity 조합 제안
-      body: AnimatedSwitcher(
-        duration: AppMotion.normal,
-        switchInCurve: AppMotion.entry,
-        switchOutCurve: AppMotion.entry,
-        transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
-        child: KeyedSubtree(key: ValueKey<int>(_currentTabIndex), child: _navigationTabScreens[_currentTabIndex]),
-      ),
+      body: IndexedStack(
+        index: _currentTabIndex,
+        children: _navigationTabScreens,
+      ),
📝 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
body: AnimatedSwitcher(
duration: AppMotion.normal,
switchInCurve: AppMotion.entry,
switchOutCurve: AppMotion.entry,
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: KeyedSubtree(key: ValueKey<int>(_currentTabIndex), child: _navigationTabScreens[_currentTabIndex]),
),
body: IndexedStack(
index: _currentTabIndex,
children: _navigationTabScreens,
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/screens/main_screen.dart` around lines 59 - 65, AnimatedSwitcher +
KeyedSubtree (ValueKey<int>(_currentTabIndex)) rebuilds each tab from
_navigationTabScreens and causes duplicate API calls in screens like
NotificationScreen, RegisterTabScreen, RequestManagementTabScreen (initState
calls _loadNotifications/_loadMyItems/_loadInitialItems). Replace
AnimatedSwitcher with an IndexedStack to preserve each child instance and apply
fade with an AnimatedOpacity (or FadeTransition) on the active child to keep the
visual animation, or alternatively implement AutomaticKeepAliveClientMixin in
the mentioned screens so their state and API calls are preserved; update
main_screen.dart to use IndexedStack(_navigationTabScreens) and control opacity
for the selected index or add keep-alive mixin to
NotificationScreen/RegisterTabScreen/RequestManagementTabScreen to prevent
reinitialization.

Comment on lines +763 to +764
address: giveItem.address!,
createdDate: giveItem.createdDate!,
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

addresscreatedDate의 강제 언래핑이 런타임 크래시를 유발할 수 있습니다.

giveItem.address!giveItem.createdDate!는 값이 null일 경우 앱이 크래시됩니다. resolveAndCacheAddress()가 실패하거나 값이 없는 경우를 대비한 fallback 처리가 필요합니다.

🛡️ 제안된 수정
-                     address: giveItem.address!,
-                     createdDate: giveItem.createdDate!,
+                     address: giveItem.address ?? '주소 미등록',
+                     createdDate: giveItem.createdDate ?? DateTime.now(),
📝 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
address: giveItem.address!,
createdDate: giveItem.createdDate!,
address: giveItem.address ?? '주소 미등록',
createdDate: giveItem.createdDate ?? DateTime.now(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/screens/request_management_tab_screen.dart` around lines 763 - 764, The
code force-unwraps giveItem.address and giveItem.createdDate (address:
giveItem.address!, createdDate: giveItem.createdDate!), which can crash at
runtime; change these to safe null-aware values by checking for null and
providing fallbacks (e.g., address: giveItem.address ?? await
resolveAndCacheAddress(giveItem) ?? '' and createdDate: giveItem.createdDate ??
DateTime.now()), or pre-resolve and cache the address via
resolveAndCacheAddress(giveItem) before building the widget so you pass non-null
values into the constructor; update the code paths that construct the widget to
use these null-safe expressions or pre-processing to avoid any forced
unwrapping.

Comment on lines +155 to +181
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTap: widget.enabled ? widget.onTap : null,
onLongPress: widget.enabled ? widget.onLongPress : null,
behavior: HitTestBehavior.opaque,
child: Transform.scale(
scale: _currentScale,
child: widget.enableRipple
? Material(
color: Colors.transparent,
borderRadius: effectiveRadius,
child: InkWell(
onTap: widget.enabled ? widget.onTap : null,
onLongPress: widget.enabled ? widget.onLongPress : null,
borderRadius: effectiveRadius,
highlightColor: widget.rippleColor ?? darkenBlend(Theme.of(context).colorScheme.surface),
splashColor: (widget.rippleColor ?? darkenBlend(Theme.of(context).colorScheme.surface)).withValues(
alpha: 0.3,
),
child: widget.child,
),
)
: widget.child,
),
);
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 | 🔴 Critical

onTap 콜백이 두 번 호출되는 버그가 있습니다.

enableRippletrue일 때, GestureDetectorInkWell 모두 onTap/onLongPress를 처리하고 있어 콜백이 두 번 실행됩니다. GestureDetector는 스케일 애니메이션만 담당하고, 실제 탭 콜백은 InkWell에서만 처리해야 합니다.

🐛 수정 제안
     return GestureDetector(
       onTapDown: _onTapDown,
       onTapUp: _onTapUp,
       onTapCancel: _onTapCancel,
-      onTap: widget.enabled ? widget.onTap : null,
-      onLongPress: widget.enabled ? widget.onLongPress : null,
+      onTap: widget.enableRipple ? null : (widget.enabled ? widget.onTap : null),
+      onLongPress: widget.enableRipple ? null : (widget.enabled ? widget.onLongPress : null),
       behavior: HitTestBehavior.opaque,
📝 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
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTap: widget.enabled ? widget.onTap : null,
onLongPress: widget.enabled ? widget.onLongPress : null,
behavior: HitTestBehavior.opaque,
child: Transform.scale(
scale: _currentScale,
child: widget.enableRipple
? Material(
color: Colors.transparent,
borderRadius: effectiveRadius,
child: InkWell(
onTap: widget.enabled ? widget.onTap : null,
onLongPress: widget.enabled ? widget.onLongPress : null,
borderRadius: effectiveRadius,
highlightColor: widget.rippleColor ?? darkenBlend(Theme.of(context).colorScheme.surface),
splashColor: (widget.rippleColor ?? darkenBlend(Theme.of(context).colorScheme.surface)).withValues(
alpha: 0.3,
),
child: widget.child,
),
)
: widget.child,
),
);
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTap: widget.enableRipple ? null : (widget.enabled ? widget.onTap : null),
onLongPress: widget.enableRipple ? null : (widget.enabled ? widget.onLongPress : null),
behavior: HitTestBehavior.opaque,
child: Transform.scale(
scale: _currentScale,
child: widget.enableRipple
? Material(
color: Colors.transparent,
borderRadius: effectiveRadius,
child: InkWell(
onTap: widget.enabled ? widget.onTap : null,
onLongPress: widget.enabled ? widget.onLongPress : null,
borderRadius: effectiveRadius,
highlightColor: widget.rippleColor ?? darkenBlend(Theme.of(context).colorScheme.surface),
splashColor: (widget.rippleColor ?? darkenBlend(Theme.of(context).colorScheme.surface)).withValues(
alpha: 0.3,
),
child: widget.child,
),
)
: widget.child,
),
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/widgets/common/app_pressable.dart` around lines 155 - 181,
GestureDetector is currently passing widget.onTap/widget.onLongPress while
InkWell also handles them when widget.enableRipple is true, causing duplicate
callbacks; update the GestureDetector onTap/onLongPress to only forward taps
when enableRipple is false (e.g. use onTap: widget.enableRipple ? null :
(widget.enabled ? widget.onTap : null) and same for onLongPress) while keeping
_onTapDown/_onTapUp/_onTapCancel for the scale animation, and keep InkWell
handling taps when widget.enableRipple is true (ensure InkWell still uses
widget.enabled to gate its onTap/onLongPress, and preserve effectiveRadius,
highlightColor and splashColor).

Comment on lines +62 to 90
child: AppPressable(
onTap: effectiveEnabled ? enabledOnPressed : disabledOnPressed,
enabled: effectiveEnabled,
borderRadius: BorderRadius.circular(10.r),
child: SizedBox(
width: buttonWidth.w,
height: buttonHeight.h,
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(10.r),
child: Center(
child: isLoading
? SizedBox(
width: 24.w,
height: 24.h,
child: CircularProgressIndicator(
strokeWidth: 2.w,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.textColorWhite),
),
)
: Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: false),
child: InkWell(
onTap: effectiveEnabled ? enabledOnPressed : disabledOnPressed,
customBorder: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.r)),
highlightColor: highlightColor,
splashColor: splashColor,
borderRadius: BorderRadius.circular(10.r),
child: Center(
child: isLoading
? SizedBox(
width: 24.w,
height: 24.h,
child: CircularProgressIndicator(
strokeWidth: 2.w,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.textColorWhite),
),
)
: Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: 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.

⚠️ Potential issue | 🔴 Critical

중복 탭 핸들러로 인해 콜백이 두 번 실행됩니다.

AppPressable은 내부적으로 GestureDetectoronTap을 사용하고, enableRipple이 기본값(true)일 때 자체적으로 InkWell도 렌더링합니다. 현재 코드에서 내부의 InkWell(Line 72-77)에도 onTap 핸들러가 있어서, 버튼을 탭하면 콜백이 두 번 호출될 수 있습니다.

AppPressableenableRipple을 활용하고 내부 InkWellonTap을 제거하거나, AppPressable 없이 기존 InkWell만 사용하는 것을 권장합니다.

🐛 AppPressable 활용 시 내부 InkWell onTap 제거
     return Center(
       child: AppPressable(
         onTap: effectiveEnabled ? enabledOnPressed : disabledOnPressed,
         enabled: effectiveEnabled,
         borderRadius: BorderRadius.circular(10.r),
+        enableRipple: true,
+        rippleColor: highlightColor,
         child: SizedBox(
           width: buttonWidth.w,
           height: buttonHeight.h,
           child: Material(
             color: backgroundColor,
             borderRadius: BorderRadius.circular(10.r),
-            child: InkWell(
-              onTap: effectiveEnabled ? enabledOnPressed : disabledOnPressed,
-              customBorder: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.r)),
-              highlightColor: highlightColor,
-              splashColor: splashColor,
-              borderRadius: BorderRadius.circular(10.r),
-              child: Center(
+            child: Center(
                 child: isLoading
                     ? SizedBox(
                         width: 24.w,
                         height: 24.h,
                         child: CircularProgressIndicator(
                           strokeWidth: 2.w,
                           valueColor: const AlwaysStoppedAnimation<Color>(AppColors.textColorWhite),
                         ),
                       )
                     : Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: false),
-              ),
             ),
           ),
         ),
       ),
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/widgets/common/completion_button.dart` around lines 62 - 90, Remove the
duplicate tap handler by letting AppPressable handle taps and deleting the inner
InkWell onTap; specifically, in the CompletionButton widget remove the onTap
passed to the inner InkWell (the calls using enabledOnPressed/disabledOnPressed)
so only AppPressable's onTap (and its enableRipple behavior) invokes callbacks;
ensure effectiveEnabled, enabledOnPressed and disabledOnPressed remain wired to
AppPressable and keep InkWell for visual ripple only (no tap handler).

Comment on lines 87 to 90
final curve = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
curve: AppMotion.decelerate,
reverseCurve: Curves.easeInCubic,
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 목적: 모션 토큰화 누락(하드코딩 Curves) 점검
# 기대 결과: context menu 내 Curves 하드코딩 0건(또는 허용 목록만 존재)

rg -n "reverseCurve:\s*Curves\." lib/widgets/common/romrom_context_menu.dart
rg -n "Curves\." lib/models/app_motion.dart

Repository: TEAM-ROMROM/RomRom-FE

Length of output: 329


🏁 Script executed:

#!/bin/bash
# Check imports and full context around lines 87-90 in romrom_context_menu.dart
head -50 lib/widgets/common/romrom_context_menu.dart | cat -n
echo "---"
sed -n '80,95p' lib/widgets/common/romrom_context_menu.dart | cat -n

Repository: TEAM-ROMROM/RomRom-FE

Length of output: 2524


🏁 Script executed:

#!/bin/bash
# Check complete AppMotion definition to understand the motion token pattern
cat -n lib/models/app_motion.dart

Repository: TEAM-ROMROM/RomRom-FE

Length of output: 1971


reverseCurve도 모션 토큰으로 통일하세요.

Line 90의 Curves.easeInCubic 하드코딩이 AppMotion 정책(반드시 이 클래스의 상수를 사용할 것)을 위반합니다.

AppMotion.decelerate의 역방향 애니메이션으로 AppMotion.accelerate를 추가하고 사용하세요:

수정 필요 사항
// lib/widgets/common/romrom_context_menu.dart
final curve = CurvedAnimation(
  parent: _animationController,
  curve: AppMotion.decelerate,
- reverseCurve: Curves.easeInCubic,
+ reverseCurve: AppMotion.accelerate,
);
// lib/models/app_motion.dart (Curve 토큰 섹션에 추가)
/// 퇴장/역방향 전환 — 빠른 끝맺음 (감속의 역)
static const Curve accelerate = Curves.easeInCubic;
📝 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
final curve = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
curve: AppMotion.decelerate,
reverseCurve: Curves.easeInCubic,
final curve = CurvedAnimation(
parent: _animationController,
curve: AppMotion.decelerate,
reverseCurve: AppMotion.accelerate,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/widgets/common/romrom_context_menu.dart` around lines 87 - 90, The
reverseCurve is hardcoded to Curves.easeInCubic which violates the AppMotion
token policy; update the CurvedAnimation instantiation that uses
_animationController and curve: AppMotion.decelerate to set reverseCurve:
AppMotion.accelerate (add AppMotion.accelerate constant if missing in the
AppMotion class) so both forward and reverse curves use the AppMotion tokens
consistently.

@SeoHyun1024
Copy link
Copy Markdown
Collaborator

확인했습니다 curve랑 duration을 공통 모델로 빼서 정의하셧군요

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

✅ 프로젝트 빌드 성공

APK 빌드가 완료되었습니다.

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