Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions lib/enums/navigation_types.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/// 화면 전환 방식을 정의.
/// [context.navigateTo]의 [type] 파라미터에서 사용.
///
/// 스택 유지: [push], [pushReplacement], [fadePush]
/// 스택 유지: [push], [pushReplacement], [fadePush], [slideUp], [sharedAxisHorizontal]
/// 스택 클리어: [pushAndRemoveUntil], [fadeTransition], [clearStackImmediate]
enum NavigationTypes {
push, // 기존 화면 위에 추가
pushReplacement, // 기존 화면을 대체
pushAndRemoveUntil, // 기존 화면을 지우고 추가
fadeTransition, // 스택을 지우고 Fade 애니메이션(800ms, easeInOut)으로 전환
clearStackImmediate, // 스택을 지우고 애니메이션 없이 즉시 전환
fadePush, // 스택 유지 + Fade 애니메이션 (Hero 전환과 함께 사용)
fadeTransition, // 스택을 지우고 Fade 애니메이션(400ms)으로 전환
clearStackImmediate, // 스택을 지우고 Fade 애니메이션(400ms)으로 전환 (구 즉시 전환 대체)
fadePush, // 스택 유지 + Fade 애니메이션
slideUp, // 스택 유지 + 아래에서 위로 슬라이드 (모달 느낌)
sharedAxisHorizontal, // 스택 유지 + 수평 SharedAxis (리스트→상세)
}
52 changes: 52 additions & 0 deletions lib/models/app_motion.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';

/// 앱 전역 애니메이션 토큰
///
/// Duration과 Curve를 의미 있는 이름으로 중앙 관리.
/// 하드코딩된 Duration(milliseconds: N), Curves.easeInOut 사용 금지.
/// 반드시 이 클래스의 상수를 사용할 것.
class AppMotion {
AppMotion._();

// ─────────────────────────────────────────────
// Duration 토큰 (5단계)
// ─────────────────────────────────────────────

/// 100ms — 터치 피드백, 즉각 반응
static const Duration instant = Duration(milliseconds: 100);

/// 200ms — 기본 UI 전환, 스크롤 반응, 스켈레톤 fade
static const Duration fast = Duration(milliseconds: 200);

/// 300ms — 카드/리스트 등장, 상태 변경, 탭 전환
static const Duration normal = Duration(milliseconds: 300);

/// 400ms — 페이지 전환, 모달 등장/퇴장
static const Duration slow = Duration(milliseconds: 400);

/// 500ms — 온보딩, 특수 진입 (일반 UI에는 사용 금지)
static const Duration emphasis = Duration(milliseconds: 500);

// ─────────────────────────────────────────────
// Curve 토큰 (4종)
// ─────────────────────────────────────────────

/// 일반적인 상태 변화 — 토글, 색상 전환
static const Curve standard = Curves.easeInOut;

/// 등장/진입 — 리스트 아이템, 카드 등장 (빠른 시작, 부드러운 끝)
static const Curve entry = Curves.easeOut;

/// 감속 — 컨텍스트 메뉴, 오버레이 (물리적 자연스러움)
static const Curve decelerate = Curves.easeOutCubic;

/// 버튼 눌림 spring back — AppPressable의 복귀 애니메이션
static const Curve springOut = ElasticOutCurve(0.5);

// ─────────────────────────────────────────────
// 스켈레톤 shimmer 설정
// ─────────────────────────────────────────────

/// 리스트 stagger 간격 — 아이템 인덱스 당 딜레이 (ms)
static const int staggerDelayMs = 30;
}
3 changes: 2 additions & 1 deletion lib/screens/chat_room_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:romrom_fe/models/apis/objects/chat_message.dart';
import 'package:romrom_fe/models/apis/objects/chat_room.dart';
import 'package:romrom_fe/models/apis/objects/chat_user_state.dart';
import 'package:romrom_fe/models/app_colors.dart';
import 'package:romrom_fe/models/app_motion.dart';
import 'package:romrom_fe/models/app_theme.dart';
import 'package:romrom_fe/services/apis/chat_api.dart';
import 'package:romrom_fe/services/apis/image_api.dart';
Expand Down Expand Up @@ -425,7 +426,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
if (_scrollController.hasClients) {
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
_scrollController.animateTo(0.0, duration: AppMotion.normal, curve: AppMotion.entry);
}
});
}
Expand Down
111 changes: 58 additions & 53 deletions lib/screens/chat_tab_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import 'package:romrom_fe/widgets/common/common_snack_bar.dart';
import 'package:romrom_fe/widgets/common/triple_toggle_switch.dart';
import 'package:romrom_fe/widgets/skeletons/chat_room_list_skeleton.dart';
import 'package:romrom_fe/screens/profile/member_profile_screen.dart';
import 'package:romrom_fe/models/app_motion.dart';
import 'package:romrom_fe/widgets/common/app_fade_slide_in.dart';

enum LoadMode { initial, paging, refresh }

Expand Down Expand Up @@ -326,59 +328,62 @@ class _ChatTabScreenState extends State<ChatTabScreen> with TickerProviderStateM
delegate: SliverChildBuilderDelegate((context, index) {
final chatRoomDetail = _getFilteredChatRooms()[index];

return Column(
children: [
ChatRoomListItem(
accountStatus: chatRoomDetail.targetMember?.accountStatus,
profileImageUrl: chatRoomDetail.targetMember?.profileUrl ?? '',
memberId: chatRoomDetail.targetMember?.memberId,
nickname: chatRoomDetail.targetMember?.nickname ?? '',
location: chatRoomDetail.targetMemberEupMyeonDong ?? '',
timeAgo: getTimeAgo(chatRoomDetail.lastMessageTime ?? DateTime.now()),
messagePreview: chatRoomDetail.lastMessageContent ?? '',
unreadCount: chatRoomDetail.unreadCount ?? 0,
targetItemImageUrl: chatRoomDetail.targetItemImageUrl,
isNew: chatRoomDetail.unreadCount != null && chatRoomDetail.unreadCount! > 0,
onProfileTap: () {
final targetMember = chatRoomDetail.targetMember;
if (targetMember?.memberId != null) {
context.navigateTo(screen: MemberProfileScreen(memberId: targetMember!.memberId!));
}
},
onTap: () async {
debugPrint('채팅방 클릭: ${chatRoomDetail.chatRoomId}');

// 채팅방 입장 시 unreadCount 초기화를 위해 목록 업데이트
final roomId = chatRoomDetail.chatRoomId!;
final roomIndex = _chatRoomsDetail.indexWhere((r) => r.chatRoomId == roomId);

if (roomIndex != -1) {
setState(() {
final room = _chatRoomsDetail[roomIndex];
_chatRoomsDetail[roomIndex] = ChatRoomDetailDto(
chatRoomId: room.chatRoomId,
targetMember: room.targetMember,
targetMemberEupMyeonDong: room.targetMemberEupMyeonDong,
lastMessageContent: room.lastMessageContent,
lastMessageTime: room.lastMessageTime,
unreadCount: 0, // 읽음 처리
chatRoomType: room.chatRoomType,
);
});
}

final refreshed = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => ChatRoomScreen(chatRoomId: roomId)));

// 엄격히 true일 때만 새로고침
if (refreshed == true) {
_loadChatRooms(mode: LoadMode.refresh);
}
},
),
SizedBox(height: 8.h),
],
return AppFadeSlideIn(
delay: Duration(milliseconds: index * AppMotion.staggerDelayMs),
child: Column(
children: [
ChatRoomListItem(
accountStatus: chatRoomDetail.targetMember?.accountStatus,
profileImageUrl: chatRoomDetail.targetMember?.profileUrl ?? '',
memberId: chatRoomDetail.targetMember?.memberId,
nickname: chatRoomDetail.targetMember?.nickname ?? '',
location: chatRoomDetail.targetMemberEupMyeonDong ?? '',
timeAgo: getTimeAgo(chatRoomDetail.lastMessageTime ?? DateTime.now()),
messagePreview: chatRoomDetail.lastMessageContent ?? '',
unreadCount: chatRoomDetail.unreadCount ?? 0,
targetItemImageUrl: chatRoomDetail.targetItemImageUrl,
isNew: chatRoomDetail.unreadCount != null && chatRoomDetail.unreadCount! > 0,
onProfileTap: () {
final targetMember = chatRoomDetail.targetMember;
if (targetMember?.memberId != null) {
context.navigateTo(screen: MemberProfileScreen(memberId: targetMember!.memberId!));
}
},
onTap: () async {
debugPrint('채팅방 클릭: ${chatRoomDetail.chatRoomId}');

// 채팅방 입장 시 unreadCount 초기화를 위해 목록 업데이트
final roomId = chatRoomDetail.chatRoomId!;
final roomIndex = _chatRoomsDetail.indexWhere((r) => r.chatRoomId == roomId);

if (roomIndex != -1) {
setState(() {
final room = _chatRoomsDetail[roomIndex];
_chatRoomsDetail[roomIndex] = ChatRoomDetailDto(
chatRoomId: room.chatRoomId,
targetMember: room.targetMember,
targetMemberEupMyeonDong: room.targetMemberEupMyeonDong,
lastMessageContent: room.lastMessageContent,
lastMessageTime: room.lastMessageTime,
unreadCount: 0, // 읽음 처리
chatRoomType: room.chatRoomType,
);
});
}

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


// 엄격히 true일 때만 새로고침
if (refreshed == true) {
_loadChatRooms(mode: LoadMode.refresh);
}
},
),
SizedBox(height: 8.h),
],
),
);
}, childCount: _getFilteredChatRooms().length),
),
Expand Down
65 changes: 28 additions & 37 deletions lib/screens/home_tab_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:romrom_fe/services/apis/trade_api.dart';

import 'package:romrom_fe/enums/item_condition.dart' as item_cond;
import 'package:romrom_fe/utils/common_utils.dart';
import 'package:romrom_fe/widgets/common/app_pressable.dart';
import 'package:romrom_fe/widgets/common/common_snack_bar.dart';
import 'package:romrom_fe/widgets/common/common_modal.dart';
import 'package:romrom_fe/widgets/common/report_menu_button.dart';
Expand Down Expand Up @@ -487,9 +488,12 @@ class _HomeTabScreenState extends State<HomeTabScreen> {
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator(color: AppColors.primaryYellow));
return const Center(child: CircularProgressIndicator(color: AppColors.primaryYellow, strokeWidth: 2));
}
return _buildContent();
}

Widget _buildContent() {
// 피드 아이템이 없을 때 메시지 표시
if (_feedItems.isEmpty) {
return Center(
Expand All @@ -498,13 +502,12 @@ class _HomeTabScreenState extends State<HomeTabScreen> {
children: [
Text('물품이 없습니다.', style: CustomTextStyles.h3),
const SizedBox(height: 16),
Material(
color: AppColors.primaryYellow,
borderRadius: BorderRadius.circular(4.r),
child: InkWell(
onTap: _loadInitialItems,
highlightColor: darkenBlend(AppColors.primaryYellow),
splashColor: darkenBlend(AppColors.primaryYellow).withValues(alpha: 0.3),
AppPressable(
onTap: _loadInitialItems,
scaleDown: AppPressable.scaleButton,
enableRipple: false,
child: Material(
color: AppColors.primaryYellow,
borderRadius: BorderRadius.circular(4.r),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
Expand Down Expand Up @@ -567,34 +570,20 @@ class _HomeTabScreenState extends State<HomeTabScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: 32.w,
child: OverflowBox(
maxWidth: 56.w,
maxHeight: 56.w,
child: Material(
color: AppColors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkResponse(
onTap: () async {
await context.navigateTo(screen: const NotificationScreen());
if (!mounted) return;
_loadUnreadNotificationStatus();
},
radius: 18.w,
customBorder: const CircleBorder(),
highlightColor: AppColors.buttonHighlightColorGray.withValues(alpha: 0.5),
splashColor: AppColors.buttonHighlightColorGray.withValues(alpha: 0.3),
child: SizedBox.square(
dimension: 56.w,
child: Center(
child: _hasUnreadNotification
? SvgPicture.asset('assets/images/alertWithBadge.svg', width: 30.w, height: 30.w)
: Icon(AppIcons.alert, size: 30.w, color: AppColors.textColorWhite),
),
),
),
AppPressable(
onTap: () async {
await context.navigateTo(screen: const NotificationScreen());
if (!mounted) return;
_loadUnreadNotificationStatus();
},
scaleDown: AppPressable.scaleIcon,
enableRipple: false,
child: SizedBox.square(
dimension: 32.w,
child: Center(
child: _hasUnreadNotification
? SvgPicture.asset('assets/images/alertWithBadge.svg', width: 30.w, height: 30.w)
: Icon(AppIcons.alert, size: 30.w, color: AppColors.textColorWhite),
),
),
),
Expand Down Expand Up @@ -637,7 +626,7 @@ class _HomeTabScreenState extends State<HomeTabScreen> {
right: 0,
bottom: 24.h,
child: Center(
child: GestureDetector(
child: AppPressable(
onTap: () async {
final result = await context.navigateTo<Map<String, dynamic>>(
screen: ItemRegisterScreen(
Expand All @@ -652,6 +641,8 @@ class _HomeTabScreenState extends State<HomeTabScreen> {
showCoachMark();
}
},
scaleDown: AppPressable.scaleButton,
enableRipple: false,
child: Container(
width: 123.w,
height: 48.h,
Expand Down
9 changes: 8 additions & 1 deletion lib/screens/main_screen.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:romrom_fe/models/app_motion.dart';
import 'package:romrom_fe/screens/chat_tab_screen.dart';
import 'package:romrom_fe/screens/home_tab_screen.dart';
import 'package:romrom_fe/screens/my_page_tab_screen.dart';
Expand Down Expand Up @@ -55,7 +56,13 @@ class _MainScreenState extends State<MainScreen> {
return Scaffold(
resizeToAvoidBottomInset: true,
extendBody: false,
body: _navigationTabScreens[_currentTabIndex],
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]),
),
Comment on lines +59 to +65
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.

bottomNavigationBar: CustomBottomNavigationBar(
selectedIndex: _currentTabIndex,
onTap: (index) {
Expand Down
10 changes: 7 additions & 3 deletions lib/screens/my_page_tab_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:romrom_fe/screens/my_page/block_management_screen.dart';
import 'package:romrom_fe/screens/search_range_setting_screen.dart';
import 'package:romrom_fe/utils/common_utils.dart';
import 'package:romrom_fe/widgets/common/common_snack_bar.dart';
import 'package:romrom_fe/widgets/common/app_pressable.dart';
import 'package:romrom_fe/widgets/user_profile_circular_avatar.dart';

class MyPageTabScreen extends StatefulWidget {
Expand Down Expand Up @@ -179,14 +180,16 @@ class _MyPageTabScreenState extends State<MyPageTabScreen> {

/// 닉네임 박스 위젯
Widget _buildNicknameBox() {
return GestureDetector(
return AppPressable(
onTap: () async {
final result = await context.navigateTo<bool>(screen: const MyProfileEditScreen());

if (result == true) {
await _loadUserInfo();
}
},
scaleDown: AppPressable.scaleCard,
enableRipple: false,
child: Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 16, right: 18, top: 16, bottom: 16),
Expand Down Expand Up @@ -264,9 +267,10 @@ class _MyPageTabScreenState extends State<MyPageTabScreen> {
}) {
final bool hasTrailingText = trailingText != null && trailingText.isNotEmpty;

return InkWell(
return AppPressable(
onTap: hasTrailingText ? null : onTap,
borderRadius: BorderRadius.circular(10.r),
scaleDown: AppPressable.scaleCard,
enableRipple: false,
child: Container(
height: 60.h,
padding: EdgeInsets.only(left: 16.w, right: 18.w),
Expand Down
Loading
Loading