diff --git a/lib/enums/navigation_types.dart b/lib/enums/navigation_types.dart index 506be000..51757dc4 100644 --- a/lib/enums/navigation_types.dart +++ b/lib/enums/navigation_types.dart @@ -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 (리스트→상세) } diff --git a/lib/models/app_motion.dart b/lib/models/app_motion.dart new file mode 100644 index 00000000..129e898b --- /dev/null +++ b/lib/models/app_motion.dart @@ -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; +} diff --git a/lib/screens/chat_room_screen.dart b/lib/screens/chat_room_screen.dart index f17c2b24..35acf8b4 100644 --- a/lib/screens/chat_room_screen.dart +++ b/lib/screens/chat_room_screen.dart @@ -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'; @@ -425,7 +426,7 @@ class _ChatRoomScreenState extends State { 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); } }); } diff --git a/lib/screens/chat_tab_screen.dart b/lib/screens/chat_tab_screen.dart index 22702895..a894bf25 100644 --- a/lib/screens/chat_tab_screen.dart +++ b/lib/screens/chat_tab_screen.dart @@ -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 } @@ -326,59 +328,62 @@ class _ChatTabScreenState extends State 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(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(MaterialPageRoute(builder: (_) => ChatRoomScreen(chatRoomId: roomId))); + + // 엄격히 true일 때만 새로고침 + if (refreshed == true) { + _loadChatRooms(mode: LoadMode.refresh); + } + }, + ), + SizedBox(height: 8.h), + ], + ), ); }, childCount: _getFilteredChatRooms().length), ), diff --git a/lib/screens/home_tab_screen.dart b/lib/screens/home_tab_screen.dart index 8a735ae2..531a4b04 100644 --- a/lib/screens/home_tab_screen.dart +++ b/lib/screens/home_tab_screen.dart @@ -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'; @@ -487,9 +488,12 @@ class _HomeTabScreenState extends State { @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( @@ -498,13 +502,12 @@ class _HomeTabScreenState extends State { 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), @@ -567,34 +570,20 @@ class _HomeTabScreenState extends State { 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), ), ), ), @@ -637,7 +626,7 @@ class _HomeTabScreenState extends State { right: 0, bottom: 24.h, child: Center( - child: GestureDetector( + child: AppPressable( onTap: () async { final result = await context.navigateTo>( screen: ItemRegisterScreen( @@ -652,6 +641,8 @@ class _HomeTabScreenState extends State { showCoachMark(); } }, + scaleDown: AppPressable.scaleButton, + enableRipple: false, child: Container( width: 123.w, height: 48.h, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 132af4db..895db8b5 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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'; @@ -55,7 +56,13 @@ class _MainScreenState extends State { 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(_currentTabIndex), child: _navigationTabScreens[_currentTabIndex]), + ), bottomNavigationBar: CustomBottomNavigationBar( selectedIndex: _currentTabIndex, onTap: (index) { diff --git a/lib/screens/my_page_tab_screen.dart b/lib/screens/my_page_tab_screen.dart index 5dc9477b..3f654d4f 100644 --- a/lib/screens/my_page_tab_screen.dart +++ b/lib/screens/my_page_tab_screen.dart @@ -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 { @@ -179,7 +180,7 @@ class _MyPageTabScreenState extends State { /// 닉네임 박스 위젯 Widget _buildNicknameBox() { - return GestureDetector( + return AppPressable( onTap: () async { final result = await context.navigateTo(screen: const MyProfileEditScreen()); @@ -187,6 +188,8 @@ class _MyPageTabScreenState extends State { await _loadUserInfo(); } }, + scaleDown: AppPressable.scaleCard, + enableRipple: false, child: Container( width: double.infinity, padding: const EdgeInsets.only(left: 16, right: 18, top: 16, bottom: 16), @@ -264,9 +267,10 @@ class _MyPageTabScreenState extends State { }) { 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), diff --git a/lib/screens/notification_screen.dart b/lib/screens/notification_screen.dart index 7031eded..ab4b0657 100644 --- a/lib/screens/notification_screen.dart +++ b/lib/screens/notification_screen.dart @@ -15,8 +15,13 @@ import 'package:romrom_fe/services/apis/notification_api.dart'; import 'package:romrom_fe/utils/common_utils.dart'; import 'package:romrom_fe/utils/deep_link_router.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/common/glass_header_delegate.dart'; import 'package:romrom_fe/widgets/notification_item_widget.dart'; +import 'package:romrom_fe/widgets/common/app_fade_slide_in.dart'; +import 'package:romrom_fe/widgets/common/app_skeleton.dart'; +import 'package:romrom_fe/models/app_motion.dart'; +import 'package:romrom_fe/widgets/skeletons/notification_skeleton.dart'; /// 알림 화면 class NotificationScreen extends StatefulWidget { @@ -58,11 +63,11 @@ class _NotificationScreenState extends State with SingleTick _scrollController.addListener(_scrollListener); // 토글 애니메이션 컨트롤러 초기화 - _toggleAnimationController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); + _toggleAnimationController = AnimationController(duration: AppMotion.normal, vsync: this); _toggleAnimation = Tween( begin: 0.0, end: 1.0, - ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: Curves.easeInOut)); + ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: AppMotion.standard)); _loadNotifications(); _loadNotificationSettings(); @@ -338,23 +343,29 @@ class _NotificationScreenState extends State with SingleTick expandedExtra: 0.h, // 토글-알림목록 간격 제거 enableBlur: _isScrolled, centerTitle: true, // 타이틀 중앙 정렬 - leadingWidget: GestureDetector( + leadingWidget: AppPressable( onTap: () => Navigator.pop(context), + scaleDown: AppPressable.scaleIcon, + enableRipple: false, child: Icon(AppIcons.navigateBefore, size: 28.sp, color: AppColors.textColorWhite), ), trailingWidget: Row( children: [ Padding( padding: EdgeInsets.only(right: 8.w), // 기본 16 + 8 = 24px 우측 패딩 - child: GestureDetector( + child: AppPressable( onTap: _onDeleteAllNotificationTap, + scaleDown: AppPressable.scaleIcon, + enableRipple: false, child: Icon(AppIcons.trash, size: 30.sp, color: AppColors.textColorWhite), ), ), Padding( padding: EdgeInsets.only(right: 8.w), // 기본 16 + 8 = 24px 우측 패딩 - child: GestureDetector( + child: AppPressable( onTap: _onSettingsTap, + scaleDown: AppPressable.scaleIcon, + enableRipple: false, child: Icon(AppIcons.setting, size: 30.sp, color: AppColors.textColorWhite), ), ), @@ -374,46 +385,48 @@ class _NotificationScreenState extends State with SingleTick Widget _buildNotificationList() { final notifications = _isRightSelected ? _romromNotifications : _activityNotifications; - if (_isLoading) { - return SizedBox( - height: 300.h, - child: const Center(child: CircularProgressIndicator(color: AppColors.primaryYellow, strokeWidth: 2)), - ); - } - - if (notifications.isEmpty) { - return Container( - height: 300.h, - padding: EdgeInsets.symmetric(horizontal: 24.w), - child: Center( - child: Text( - _isRightSelected ? '롬롬 소식이 없습니다' : '알림이 없습니다', - style: CustomTextStyles.p2.copyWith(color: AppColors.opacity60White), + if (notifications.isEmpty && !_isLoading) { + return AppFadeSlideIn( + child: Container( + height: 300.h, + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Center( + child: Text( + _isRightSelected ? '롬롬 소식이 없습니다' : '알림이 없습니다', + style: CustomTextStyles.p2.copyWith(color: AppColors.opacity60White), + ), ), ), ); } - return Padding( - padding: EdgeInsets.fromLTRB(0.w, 4.h, 0.w, 0), - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: notifications.length, - separatorBuilder: (_, _) => SizedBox(height: 0.h), - itemBuilder: (context, index) { - final notification = notifications[index]; - return NotificationItemWidget( - data: notification, - isMuted: _mutedNotificationTypes[notification.type] ?? false, - onTap: () => _onNotificationTap(notification), - onMuteTap: () => _onToggleMuteNotification(notification.type), - onDeleteTap: () => _onDeleteNotification(notification.id), - ); - }, + return AppSkeleton( + isLoading: _isLoading, + skeleton: const NotificationListSkeleton(), + child: Padding( + padding: EdgeInsets.fromLTRB(0.w, 4.h, 0.w, 0), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: notifications.length, + separatorBuilder: (_, index) => const SizedBox.shrink(), + itemBuilder: (context, index) { + final notification = notifications[index]; + return AppFadeSlideIn( + delay: Duration(milliseconds: index * AppMotion.staggerDelayMs), + child: NotificationItemWidget( + data: notification, + isMuted: _mutedNotificationTypes[notification.type] ?? false, + onTap: () => _onNotificationTap(notification), + onMuteTap: () => _onToggleMuteNotification(notification.type), + onDeleteTap: () => _onDeleteNotification(notification.id), + ), + ); + }, + ), ), ), ); diff --git a/lib/screens/onboarding/onboarding_flow_screen.dart b/lib/screens/onboarding/onboarding_flow_screen.dart index 260c5896..0cca3aa4 100644 --- a/lib/screens/onboarding/onboarding_flow_screen.dart +++ b/lib/screens/onboarding/onboarding_flow_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:romrom_fe/enums/navigation_types.dart'; import 'package:romrom_fe/enums/onboarding_steps.dart'; +import 'package:romrom_fe/models/app_motion.dart'; import 'package:romrom_fe/screens/main_screen.dart'; import 'package:romrom_fe/screens/onboarding/category_selection_step.dart'; import 'package:romrom_fe/screens/onboarding/term_agreement_step.dart'; @@ -45,11 +46,7 @@ class _OnboardingFlowScreenState extends State { setState(() { _currentStep += 1; }); - _pageController.animateToPage( - _currentStep - 1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + _pageController.animateToPage(_currentStep - 1, duration: AppMotion.normal, curve: AppMotion.standard); } } @@ -59,11 +56,7 @@ class _OnboardingFlowScreenState extends State { setState(() { _currentStep -= 1; }); - _pageController.animateToPage( - _currentStep - 1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + _pageController.animateToPage(_currentStep - 1, duration: AppMotion.normal, curve: AppMotion.standard); } else { // 첫 페이지에서 뒤로가기 시 로그아웃 처리 후 로그인 화면으로 이동 final AuthService authService = AuthService(); diff --git a/lib/screens/register_tab_screen.dart b/lib/screens/register_tab_screen.dart index 5e68e2bc..e766a323 100644 --- a/lib/screens/register_tab_screen.dart +++ b/lib/screens/register_tab_screen.dart @@ -17,6 +17,7 @@ import 'package:romrom_fe/screens/my_page/my_location_verification_screen.dart'; import 'package:romrom_fe/widgets/common/romrom_context_menu.dart'; import 'package:romrom_fe/widgets/common/error_image_placeholder.dart'; import 'package:romrom_fe/widgets/common/cached_image.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/skeletons/register_tab_skeleton.dart'; import 'package:romrom_fe/widgets/common/glass_header_delegate.dart'; import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; @@ -25,6 +26,7 @@ import 'package:romrom_fe/utils/common_utils.dart'; import 'package:romrom_fe/services/apis/item_api.dart'; import 'package:romrom_fe/models/apis/requests/item_request.dart'; +import 'package:romrom_fe/models/app_motion.dart'; import 'package:romrom_fe/utils/error_utils.dart'; import 'package:romrom_fe/screens/main_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -66,11 +68,11 @@ class _RegisterTabScreenState extends State with TickerProvid super.initState(); // 토글 애니메이션 초기화 - _toggleAnimationController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); + _toggleAnimationController = AnimationController(duration: AppMotion.normal, vsync: this); _toggleAnimation = Tween( begin: 0.0, end: 1.0, - ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: Curves.easeInOut)); + ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: AppMotion.standard)); _loadMyItems(); _scrollController.addListener(_scrollListener); @@ -377,9 +379,10 @@ class _RegisterTabScreenState extends State with TickerProvid return Stack( children: [ - GestureDetector( + AppPressable( onTap: () => _navigateToItemDetail(item), - behavior: HitTestBehavior.opaque, + scaleDown: AppPressable.scaleCard, + enableRipple: false, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -482,121 +485,120 @@ class _RegisterTabScreenState extends State with TickerProvid child: IgnorePointer( ignoring: _isScrolling, child: AnimatedScale( - duration: const Duration(milliseconds: 200), + duration: AppMotion.fast, scale: _isScrolling ? 0.0 : 1.0, - curve: Curves.easeInOut, + curve: AppMotion.standard, child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: AppMotion.fast, opacity: _isScrolling ? 0.0 : 1.0, child: Center( - child: Container( - decoration: BoxDecoration( - color: AppColors.primaryYellow, - borderRadius: BorderRadius.circular(100.r), - boxShadow: const [BoxShadow(color: AppColors.opacity20Black, blurRadius: 4, offset: Offset(0, 4))], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(100.r), - onTap: () async { - if (_isFabProcessing) return; - setState(() => _isFabProcessing = true); - try { - // 위치 미등록 시 위치 등록 화면으로 이동 - final userInfo = UserInfo(); - if (userInfo.isMemberLocationSaved != true) { - final locationResult = await context.navigateTo( - screen: const MyLocationVerificationScreen(), - ); - if (!mounted) return; - if (locationResult != true) return; - userInfo.isMemberLocationSaved = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('isMemberLocationSaved', true); - } - - // 거래중(AVAILABLE) 물품 개수 확인 - 최대 10개 제한 - try { - final countResponse = await ItemApi().getMyItems( - ItemRequest(pageNumber: 0, pageSize: 1, itemStatus: ItemStatus.available.serverName), + child: AppPressable( + scaleDown: AppPressable.scaleButton, + enableRipple: false, + borderRadius: BorderRadius.circular(100.r), + onTap: () async { + if (_isFabProcessing) return; + setState(() => _isFabProcessing = true); + try { + // 위치 미등록 시 위치 등록 화면으로 이동 + final userInfo = UserInfo(); + if (userInfo.isMemberLocationSaved != true) { + final locationResult = await context.navigateTo( + screen: const MyLocationVerificationScreen(), + ); + if (!mounted) return; + if (locationResult != true) return; + userInfo.isMemberLocationSaved = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('isMemberLocationSaved', true); + } + + // 거래중(AVAILABLE) 물품 개수 확인 - 최대 10개 제한 + try { + final countResponse = await ItemApi().getMyItems( + ItemRequest(pageNumber: 0, pageSize: 1, itemStatus: ItemStatus.available.serverName), + ); + final totalCount = + countResponse.itemPage?.totalElements ?? countResponse.itemPage?.page?.totalElements ?? 0; + + if (totalCount >= _maxAvailableItemCount) { + if (mounted) { + CommonSnackBar.show( + context: context, + message: '물품은 최대 $_maxAvailableItemCount개까지 등록할 수 있습니다.', + type: SnackBarType.error, ); - final totalCount = - countResponse.itemPage?.totalElements ?? countResponse.itemPage?.page?.totalElements ?? 0; - - if (totalCount >= _maxAvailableItemCount) { - if (mounted) { - CommonSnackBar.show( - context: context, - message: '물품은 최대 $_maxAvailableItemCount개까지 등록할 수 있습니다.', - type: SnackBarType.error, - ); - } - return; - } - } catch (e) { - debugPrint('물품 개수 확인 실패: $e'); - if (mounted) { - CommonSnackBar.show( - context: context, - message: '물품 개수 확인에 실패했습니다. 다시 시도해주세요.', - type: SnackBarType.error, - ); - } - return; } - - final result = await context.navigateTo( - screen: ItemRegisterScreen( - onClose: () { - Navigator.pop(context); - }, - ), + return; + } + } catch (e) { + debugPrint('물품 개수 확인 실패: $e'); + if (mounted) { + CommonSnackBar.show( + context: context, + message: '물품 개수 확인에 실패했습니다. 다시 시도해주세요.', + type: SnackBarType.error, ); - - if (!mounted) return; - - debugPrint('===================================='); - debugPrint('ItemRegisterScreen에서 돌아옴: result=$result'); - debugPrint('result type: ${result.runtimeType}'); - if (result is Map) { - debugPrint(' - isFirstItemPosted: ${result['isFirstItemPosted']}'); - debugPrint(' - itemId: ${result['itemId']}'); - } - debugPrint('===================================='); - - // 등록 화면에서 돌아온 뒤 목록 새로고침 - _loadMyItems(isRefresh: true); - - // 첫 물건 등록 완료 시 홈탭으로 전환 후 코치마크 표시 - if (result is Map && result['isFirstItemPosted'] == true) { - debugPrint('첫 물건 등록 확인! 홈 탭으로 이동 시작...'); - _navigateToHomeAndShowCoachMark(); - } else { - debugPrint( - '첫 물건 등록 조건 불충족: isFirstItemPosted=${result is Map ? result['isFirstItemPosted'] : 'N/A'}, itemId=${result is Map ? result['itemId'] : 'N/A'}', - ); - } - } finally { - if (mounted) setState(() => _isFabProcessing = false); } - }, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 15.h), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(AppIcons.addItemPlus, size: 16.sp, color: AppColors.primaryBlack), - SizedBox(width: 8.w), - Text( - '등록하기', - style: CustomTextStyles.h3.copyWith( - fontWeight: FontWeight.w600, - color: AppColors.textColorBlack, - ), - ), - ], + return; + } + + final result = await context.navigateTo( + screen: ItemRegisterScreen( + onClose: () { + Navigator.pop(context); + }, ), + ); + + if (!mounted) return; + + debugPrint('===================================='); + debugPrint('ItemRegisterScreen에서 돌아옴: result=$result'); + debugPrint('result type: ${result.runtimeType}'); + if (result is Map) { + debugPrint(' - isFirstItemPosted: ${result['isFirstItemPosted']}'); + debugPrint(' - itemId: ${result['itemId']}'); + } + debugPrint('===================================='); + + // 등록 화면에서 돌아온 뒤 목록 새로고침 + _loadMyItems(isRefresh: true); + + // 첫 물건 등록 완료 시 홈탭으로 전환 후 코치마크 표시 + if (result is Map && result['isFirstItemPosted'] == true) { + debugPrint('첫 물건 등록 확인! 홈 탭으로 이동 시작...'); + _navigateToHomeAndShowCoachMark(); + } else { + debugPrint( + '첫 물건 등록 조건 불충족: isFirstItemPosted=${result is Map ? result['isFirstItemPosted'] : 'N/A'}, itemId=${result is Map ? result['itemId'] : 'N/A'}', + ); + } + } finally { + if (mounted) setState(() => _isFabProcessing = false); + } + }, + child: Container( + decoration: BoxDecoration( + color: AppColors.primaryYellow, + borderRadius: BorderRadius.circular(100.r), + boxShadow: const [BoxShadow(color: AppColors.opacity20Black, blurRadius: 4, offset: Offset(0, 4))], + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 15.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(AppIcons.addItemPlus, size: 16.sp, color: AppColors.primaryBlack), + SizedBox(width: 8.w), + Text( + '등록하기', + style: CustomTextStyles.h3.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textColorBlack, + ), + ), + ], ), ), ), diff --git a/lib/screens/request_management_tab_screen.dart b/lib/screens/request_management_tab_screen.dart index 99a32a9d..a634a0bf 100644 --- a/lib/screens/request_management_tab_screen.dart +++ b/lib/screens/request_management_tab_screen.dart @@ -13,6 +13,8 @@ import 'package:romrom_fe/models/apis/requests/item_request.dart'; import 'package:romrom_fe/models/apis/requests/trade_request.dart'; import 'package:romrom_fe/models/apis/responses/trade_response.dart'; import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_motion.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/models/request_management_item_card.dart'; import 'package:romrom_fe/screens/item_detail_description_screen.dart'; @@ -77,11 +79,11 @@ class _RequestManagementTabScreenState extends State _scrollController.addListener(_scrollListener); // 토글 애니메이션 컨트롤러 초기화 - _toggleAnimationController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); + _toggleAnimationController = AnimationController(duration: AppMotion.normal, vsync: this); _toggleAnimation = Tween( begin: 0.0, end: 1.0, - ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: Curves.easeInOut)); + ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: AppMotion.standard)); _loadInitialItems(); } @@ -312,35 +314,37 @@ class _RequestManagementTabScreenState extends State return Padding( padding: EdgeInsets.only(bottom: 16.h), - child: GestureDetector( - onTap: () async { + child: SentRequestItemCard( + onTap: () { // 거래완료 상태면 삭제 처리 if (request.tradeStatus == TradeStatus.traded.serverName) { - try { - await TradeApi().cancelTradeRequest( - TradeRequest(tradeRequestHistoryId: request.tradeRequestHistoryId), - ); - if (mounted) { - setState(() { - _sentRequests.removeWhere((e) => e.tradeRequestHistoryId == request.tradeRequestHistoryId); + TradeApi() + .cancelTradeRequest(TradeRequest(tradeRequestHistoryId: request.tradeRequestHistoryId)) + .then((_) { + if (mounted) { + setState(() { + _sentRequests.removeWhere((e) => e.tradeRequestHistoryId == request.tradeRequestHistoryId); + }); + CommonSnackBar.show(context: context, message: '삭제되었습니다.'); + } + }) + .catchError((e) { + debugPrint('거래완료 항목 삭제 실패: $e'); + if (mounted) { + CommonSnackBar.show(context: context, message: '삭제에 실패했습니다', type: SnackBarType.error); + } }); - CommonSnackBar.show(context: context, message: '삭제되었습니다.'); - } - } catch (e) { - debugPrint('거래완료 항목 삭제 실패: $e'); - if (mounted) { - CommonSnackBar.show(context: context, message: '삭제에 실패했습니다', type: SnackBarType.error); - } - } return; } // 기존 로직: 상세 페이지 이동 context.navigateTo( screen: ItemDetailDescriptionScreen( - itemId: takeItem.itemId!, // 내가 요청 보낸 카드로 이동 + itemId: takeItem.itemId!, + // 내가 요청 보낸 카드로 이동 imageSize: Size(MediaQuery.of(context).size.width, 400.h), currentImageIndex: 0, - heroTag: 'itemImage_${takeItem.itemId!}_0', // ← 인덱스 포함 + heroTag: 'itemImage_${takeItem.itemId!}_0', + // ← 인덱스 포함 isMyItem: false, isRequestManagement: true, tradeRequestHistoryId: request.tradeRequestHistoryId, @@ -348,65 +352,63 @@ class _RequestManagementTabScreenState extends State ), ); }, - child: SentRequestItemCard( - myItemImageUrl: giveItem.imageUrlList.isNotEmpty - ? giveItem.imageUrlList.first - : 'https://picsum.photos/400/300', - otherItemImageUrl: takeItem.imageUrlList.isNotEmpty - ? takeItem.imageUrlList.first - : 'https://picsum.photos/400/300', - otherUserProfileUrl: takeItem.member?.profileUrl ?? '', - title: takeItem.itemName ?? ' ', - location: takeItem.address ?? '주소 미등록', - createdDate: takeItem.createdDate ?? DateTime.now(), - tradeOptions: takeItem.itemTradeOptions != null - ? takeItem.itemTradeOptions! - .map((s) => ItemTradeOption.values.firstWhere((e) => e.serverName == s)) - .toList() - : [], - tradeStatus: request.tradeStatus != null - ? TradeStatus.values.firstWhere( - (e) => e.serverName == request.tradeStatus, - orElse: () => TradeStatus.chatting, - ) - : TradeStatus.chatting, - onEditTap: () { - context.navigateTo( - screen: ItemModificationScreen( - itemId: giveItem.itemId, - onClose: () { - Navigator.pop(context); - }, - ), - ); - }, - onCancelTap: () async { - final result = await context.showDeleteDialog( - title: '거래 요청 취소', - description: '거래 요청을 취소하시겠습니까?', - confirmText: '확인', - ); - - if (result == true) { - try { - await TradeApi().cancelTradeRequest( - TradeRequest(tradeRequestHistoryId: request.tradeRequestHistoryId), - ); - if (mounted) { - setState(() { - _sentRequests.removeAt(index); - }); - CommonSnackBar.show(context: context, message: '요청을 취소했습니다.'); - } - } catch (e) { - debugPrint('요청 취소 실패: $e'); - if (mounted) { - CommonSnackBar.show(context: context, message: '요청 취소에 실패했습니다', type: SnackBarType.error); - } + myItemImageUrl: giveItem.imageUrlList.isNotEmpty + ? giveItem.imageUrlList.first + : 'https://picsum.photos/400/300', + otherItemImageUrl: takeItem.imageUrlList.isNotEmpty + ? takeItem.imageUrlList.first + : 'https://picsum.photos/400/300', + otherUserProfileUrl: takeItem.member?.profileUrl ?? '', + title: takeItem.itemName ?? ' ', + location: takeItem.address ?? '주소 미등록', + createdDate: takeItem.createdDate ?? DateTime.now(), + tradeOptions: takeItem.itemTradeOptions != null + ? takeItem.itemTradeOptions! + .map((s) => ItemTradeOption.values.firstWhere((e) => e.serverName == s)) + .toList() + : [], + tradeStatus: request.tradeStatus != null + ? TradeStatus.values.firstWhere( + (e) => e.serverName == request.tradeStatus, + orElse: () => TradeStatus.chatting, + ) + : TradeStatus.chatting, + onEditTap: () { + context.navigateTo( + screen: ItemModificationScreen( + itemId: giveItem.itemId, + onClose: () { + Navigator.pop(context); + }, + ), + ); + }, + onCancelTap: () async { + final result = await context.showDeleteDialog( + title: '거래 요청 취소', + description: '거래 요청을 취소하시겠습니까?', + confirmText: '확인', + ); + + if (result == true) { + try { + await TradeApi().cancelTradeRequest( + TradeRequest(tradeRequestHistoryId: request.tradeRequestHistoryId), + ); + if (mounted) { + setState(() { + _sentRequests.removeAt(index); + }); + CommonSnackBar.show(context: context, message: '요청을 취소했습니다.'); + } + } catch (e) { + debugPrint('요청 취소 실패: $e'); + if (mounted) { + CommonSnackBar.show(context: context, message: '요청 취소에 실패했습니다', type: SnackBarType.error); } } - }, - ), + } + }, ), ); }), @@ -523,10 +525,12 @@ class _RequestManagementTabScreenState extends State leftText: '받은 요청', rightText: '보낸 요청', ), - statusBarHeight: MediaQuery.of(context).padding.top, // ★ 꼭 전달 + statusBarHeight: MediaQuery.of(context).padding.top, + // ★ 꼭 전달 toolbarHeight: 58.h, toggleHeight: 62.h, - expandedExtra: 16.h, // 큰 제목/여백 + expandedExtra: 16.h, + // 큰 제목/여백 enableBlur: _isScrolled, // 스크롤 시 더 진해지게 ), ), @@ -584,7 +588,9 @@ class _RequestManagementTabScreenState extends State onPageChanged: _onCardPageChanged, itemCount: _itemCards.length, itemBuilder: (context, index) { - return GestureDetector( + return AppPressable( + scaleDown: AppPressable.scaleCard, + enableRipple: false, onTap: () { context.navigateTo( screen: ItemDetailDescriptionScreen( @@ -749,33 +755,54 @@ class _RequestManagementTabScreenState extends State return Column( children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, // 빈 공간도 터치 가능 - onTap: () async { - await TradeApi() + RequestListItemCardWidget( + imageUrl: giveItem.imageUrlList.isNotEmpty + ? giveItem.imageUrlList.first + : 'https://picsum.photos/400/300', + title: giveItem.itemName ?? ' ', + address: giveItem.address!, + createdDate: giveItem.createdDate!, + isNew: request.isNew ?? false, + tradeOptions: giveItem.itemTradeOptions != null + ? giveItem.itemTradeOptions! + .map((s) => ItemTradeOption.values.firstWhere((e) => e.serverName == s)) + .toList() + : [], + tradeStatus: request.tradeStatus != null + ? TradeStatus.values.firstWhere( + (e) => e.serverName == request.tradeStatus, + orElse: () => TradeStatus.chatting, + ) + : TradeStatus.chatting, + onTap: () { + TradeApi() .getDetailedTradeRequest(request) .then((detailedRequest) { - setState(() { - // isNew 상태 갱신 - final targetIndex = _receivedRequests.indexWhere( - (r) => - r.tradeRequestHistoryId == - detailedRequest.tradeRequestHistory?.tradeRequestHistoryId, - ); - if (targetIndex != -1 && detailedRequest.tradeRequestHistory != null) { - _receivedRequests[targetIndex].isNew = detailedRequest.tradeRequestHistory!.isNew; - } - }); + if (mounted) { + setState(() { + // isNew 상태 갱신 + final targetIndex = _receivedRequests.indexWhere( + (r) => + r.tradeRequestHistoryId == + detailedRequest.tradeRequestHistory?.tradeRequestHistoryId, + ); + if (targetIndex != -1 && detailedRequest.tradeRequestHistory != null) { + _receivedRequests[targetIndex].isNew = detailedRequest.tradeRequestHistory!.isNew; + } + }); + } }) .catchError((e) { debugPrint('거래 요청 상세 조회 실패: $e'); }); context.navigateTo( screen: ItemDetailDescriptionScreen( - itemId: giveItem.itemId!, // 요청 받은 카드로 이동 + itemId: giveItem.itemId!, + // 요청 받은 카드로 이동 imageSize: Size(MediaQuery.of(context).size.width, 400.h), currentImageIndex: 0, - heroTag: 'itemImage_${request.giveItem.itemId!}_0', // ← 인덱스 포함 + heroTag: 'itemImage_${request.giveItem.itemId!}_0', + // ← 인덱스 포함 isMyItem: false, isRequestManagement: true, tradeRequestHistoryId: request.tradeRequestHistoryId, @@ -783,54 +810,30 @@ class _RequestManagementTabScreenState extends State ), ); }, - child: RequestListItemCardWidget( - imageUrl: giveItem.imageUrlList.isNotEmpty - ? giveItem.imageUrlList.first - : 'https://picsum.photos/400/300', - title: giveItem.itemName ?? ' ', - address: giveItem.address!, - createdDate: giveItem.createdDate!, - isNew: request.isNew ?? false, - tradeOptions: giveItem.itemTradeOptions != null - ? giveItem.itemTradeOptions! - .map((s) => ItemTradeOption.values.firstWhere((e) => e.serverName == s)) - .toList() - : [], - tradeStatus: request.tradeStatus != null - ? TradeStatus.values.firstWhere( - (e) => e.serverName == request.tradeStatus, - orElse: () => TradeStatus.chatting, - ) - : TradeStatus.chatting, - onMenuTap: () async { - final result = await context.showDeleteDialog(title: '거래 요청 삭제', description: '정말 삭제하시겠습니까?'); - - if (result == true) { - try { - await TradeApi().cancelTradeRequest( - TradeRequest(tradeRequestHistoryId: request.tradeRequestHistoryId), - ); - if (mounted) { - setState(() { - _receivedRequests.removeWhere( - (e) => e.tradeRequestHistoryId == request.tradeRequestHistoryId, - ); - }); - CommonSnackBar.show(context: context, message: '요청을 삭제했습니다.'); - } - } catch (e) { - debugPrint('요청 취소 실패: $e'); - if (mounted) { - CommonSnackBar.show( - context: context, - message: '요청 삭제에 실패했습니다', - type: SnackBarType.error, + onMenuTap: () async { + final result = await context.showDeleteDialog(title: '거래 요청 삭제', description: '정말 삭제하시겠습니까?'); + + if (result == true) { + try { + await TradeApi().cancelTradeRequest( + TradeRequest(tradeRequestHistoryId: request.tradeRequestHistoryId), + ); + if (mounted) { + setState(() { + _receivedRequests.removeWhere( + (e) => e.tradeRequestHistoryId == request.tradeRequestHistoryId, ); - } + }); + CommonSnackBar.show(context: context, message: '요청을 삭제했습니다.'); + } + } catch (e) { + debugPrint('요청 취소 실패: $e'); + if (mounted) { + CommonSnackBar.show(context: context, message: '요청 삭제에 실패했습니다', type: SnackBarType.error); } } - }, - ), + } + }, ), if (index < filteredRequests.length - 1) Divider(thickness: 1.5, color: AppColors.opacity10White, height: 32.h), diff --git a/lib/utils/common_utils.dart b/lib/utils/common_utils.dart index f4218883..ca73da20 100644 --- a/lib/utils/common_utils.dart +++ b/lib/utils/common_utils.dart @@ -1,10 +1,12 @@ import 'dart:io'; +import 'package:animations/animations.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:intl/intl.dart'; import 'package:romrom_fe/enums/navigation_types.dart'; import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_motion.dart'; import '../widgets/common/common_modal.dart'; import 'package:romrom_fe/utils/device_type.dart'; @@ -38,30 +40,37 @@ extension NavigationExtension on BuildContext { return Navigator.pushAndRemoveUntil(this, createRoute(screen, routeSettings), predicate ?? (route) => false); case NavigationTypes.fadeTransition: + // 800ms → 400ms 단축 (AppMotion.slow) return Navigator.pushAndRemoveUntil( this, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => screen, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( - opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut), + opacity: CurvedAnimation(parent: animation, curve: AppMotion.standard), child: child, ); }, - transitionDuration: const Duration(milliseconds: 800), - reverseTransitionDuration: const Duration(milliseconds: 300), + transitionDuration: AppMotion.slow, + reverseTransitionDuration: AppMotion.normal, settings: routeSettings, ), predicate ?? (route) => false, ); case NavigationTypes.clearStackImmediate: + // 즉시 전환 → 400ms fade 전환으로 개선 (딱딱함 제거) return Navigator.pushAndRemoveUntil( this, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => screen, - // transitionsBuilder 미지정: 기본 동작(child 그대로 반환)으로 전환 없이 즉시 표시 - transitionDuration: Duration.zero, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: AppMotion.entry), + child: child, + ); + }, + transitionDuration: AppMotion.slow, reverseTransitionDuration: Duration.zero, settings: routeSettings, ), @@ -75,12 +84,51 @@ extension NavigationExtension on BuildContext { pageBuilder: (context, animation, secondaryAnimation) => screen, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( - opacity: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + opacity: CurvedAnimation(parent: animation, curve: AppMotion.decelerate), + child: child, + ); + }, + transitionDuration: AppMotion.normal, + reverseTransitionDuration: AppMotion.fast, + settings: routeSettings, + ), + ); + + case NavigationTypes.slideUp: + // 아래에서 위로 슬라이드 — 모달/바텀시트 느낌 + return Navigator.push( + this, + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => screen, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final slide = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: AppMotion.decelerate)); + return SlideTransition(position: slide, child: child); + }, + transitionDuration: AppMotion.normal, + reverseTransitionDuration: AppMotion.fast, + settings: routeSettings, + ), + ); + + case NavigationTypes.sharedAxisHorizontal: + // 수평 SharedAxis — 리스트→상세 전환 + return Navigator.push( + this, + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => screen, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SharedAxisTransition( + animation: animation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, child: child, ); }, - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), + transitionDuration: AppMotion.slow, + reverseTransitionDuration: AppMotion.normal, settings: routeSettings, ), ); diff --git a/lib/widgets/chat_input_bar.dart b/lib/widgets/chat_input_bar.dart index 783f529c..3bf19d11 100644 --- a/lib/widgets/chat_input_bar.dart +++ b/lib/widgets/chat_input_bar.dart @@ -5,6 +5,7 @@ import 'package:romrom_fe/enums/context_menu_enums.dart'; import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/common/romrom_context_menu.dart'; /// 채팅방 하단 메시지 입력 바 @@ -93,29 +94,23 @@ class ChatInputBar extends StatelessWidget { border: OutlineInputBorder(borderRadius: BorderRadius.circular(100.r), borderSide: BorderSide.none), contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), suffixIcon: TextFieldTapRegion( - child: Material( - color: Colors.transparent, - child: ClipOval( - child: InkWell( - onTap: sendDisabled ? null : onSend, - customBorder: const CircleBorder(), - highlightColor: AppColors.buttonHighlightColorGray, - splashColor: AppColors.buttonHighlightColorGray.withValues(alpha: 0.3), - child: Container( - margin: EdgeInsets.all(4.w), - width: 40.w, - height: 40.w, - decoration: BoxDecoration( - color: sendDisabled ? AppColors.secondaryBlack2 : AppColors.primaryYellow, - shape: BoxShape.circle, - ), - child: Center( - child: Icon( - AppIcons.arrowUpward, - color: sendDisabled ? AppColors.secondaryBlack1 : AppColors.primaryBlack, - size: 32.w, - ), - ), + child: AppPressable( + onTap: sendDisabled ? null : onSend, + scaleDown: AppPressable.scaleIcon, + enableRipple: false, + child: Container( + margin: EdgeInsets.all(4.w), + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: sendDisabled ? AppColors.secondaryBlack2 : AppColors.primaryYellow, + shape: BoxShape.circle, + ), + child: Center( + child: Icon( + AppIcons.arrowUpward, + color: sendDisabled ? AppColors.secondaryBlack1 : AppColors.primaryBlack, + size: 32.w, ), ), ), diff --git a/lib/widgets/chat_room_list_item.dart b/lib/widgets/chat_room_list_item.dart index 4da51b7e..b73d945a 100644 --- a/lib/widgets/chat_room_list_item.dart +++ b/lib/widgets/chat_room_list_item.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/enums/account_status.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/common/cached_image.dart'; import 'package:romrom_fe/widgets/user_profile_circular_avatar.dart'; @@ -39,8 +40,10 @@ class ChatRoomListItem extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return AppPressable( onTap: onTap, + scaleDown: AppPressable.scaleCard, + enableRipple: false, child: Container( color: AppColors.transparent, padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 16.h), diff --git a/lib/widgets/common/app_fade_slide_in.dart b/lib/widgets/common/app_fade_slide_in.dart new file mode 100644 index 00000000..858c848e --- /dev/null +++ b/lib/widgets/common/app_fade_slide_in.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:romrom_fe/models/app_motion.dart'; + +/// 앱 전역 공통 등장 애니메이션 위젯 +/// +/// 모든 카드, 리스트 아이템, 화면 진입 요소의 등장을 통일. +/// - opacity 0 → 1 +/// - translateY +slideOffset → 0 +/// +/// 리스트 stagger 사용 예: +/// ```dart +/// ListView.builder( +/// itemBuilder: (context, index) => AppFadeSlideIn( +/// delay: Duration(milliseconds: index * AppMotion.staggerDelayMs), +/// child: MyListItem(items[index]), +/// ), +/// ) +/// ``` +/// +/// 단일 위젯 등장: +/// ```dart +/// AppFadeSlideIn( +/// child: MyCard(), +/// ) +/// ``` +class AppFadeSlideIn extends StatefulWidget { + const AppFadeSlideIn({ + super.key, + required this.child, + this.delay = Duration.zero, + this.duration = AppMotion.normal, + this.slideOffset = 12.0, + this.curve = AppMotion.entry, + }); + + final Widget child; + + /// stagger를 위한 지연 시간 + final Duration delay; + + /// 애니메이션 지속 시간 (기본: 300ms) + final Duration duration; + + /// 시작 y offset (px) — 양수: 아래에서 위로 + final double slideOffset; + + /// 애니메이션 Curve + final Curve curve; + + @override + State createState() => _AppFadeSlideInState(); +} + +class _AppFadeSlideInState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + late Animation _slide; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + + _opacity = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _controller, curve: widget.curve)); + + _slide = Tween( + begin: Offset(0, widget.slideOffset / 100), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: widget.curve)); + + if (widget.delay == Duration.zero) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _controller.forward(); + }); + } else { + Future.delayed(widget.delay, () { + if (mounted) _controller.forward(); + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: SlideTransition(position: _slide, child: widget.child), + ); + } +} diff --git a/lib/widgets/common/app_pressable.dart b/lib/widgets/common/app_pressable.dart new file mode 100644 index 00000000..e60ce931 --- /dev/null +++ b/lib/widgets/common/app_pressable.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:romrom_fe/utils/common_utils.dart'; + +/// 앱 전역 공통 터치 위젯 — 물리 기반 Spring 인터랙션 +/// +/// 토스 스타일 인터랙션: +/// - 누르는 순간: 즉각 scale down (애니메이션 없음 → 즉각 반응 느낌) +/// - 손 떼는 순간: SpringSimulation으로 탄성 있게 복귀 (살짝 bounce) +/// +/// 사용 예: +/// ```dart +/// AppPressable( +/// onTap: () => doSomething(), +/// child: MyButton(), +/// ) +/// +/// // 카드 +/// AppPressable( +/// onTap: () {}, +/// scaleDown: AppPressable.scaleCard, +/// borderRadius: BorderRadius.circular(12), +/// child: MyCard(), +/// ) +/// +/// // 아이콘 버튼 (작은 터치 영역) +/// AppPressable( +/// onTap: () {}, +/// scaleDown: AppPressable.scaleIcon, +/// enableRipple: false, +/// child: Icon(Icons.close), +/// ) +/// ``` +class AppPressable extends StatefulWidget { + const AppPressable({ + super.key, + required this.onTap, + required this.child, + this.scaleDown = scaleButton, + this.borderRadius, + this.enableRipple = true, + this.rippleColor, + this.onLongPress, + this.enabled = true, + }); + + final VoidCallback? onTap; + final Widget child; + + /// 눌렸을 때 축소 비율 (0.0 ~ 1.0) + final double scaleDown; + + /// Ripple 클리핑에 사용되는 borderRadius + final BorderRadius? borderRadius; + + /// InkWell ripple 효과 활성화 여부 + final bool enableRipple; + + /// 커스텀 ripple 색상 + final Color? rippleColor; + + final VoidCallback? onLongPress; + + /// false 시 터치 반응 없음 (disabled 상태) + final bool enabled; + + // ───────────────────────────────────────────── + // 표준 scaleDown 상수 + // ───────────────────────────────────────────── + + /// 기본 버튼 + static const double scaleButton = 0.97; + + /// 카드 / 리스트 아이템 + static const double scaleCard = 0.985; + + /// 아이콘 버튼 (닫기, 뒤로가기 등 소형 터치 영역) + static const double scaleIcon = 0.93; + + // ───────────────────────────────────────────── + // Spring 파라미터 (토스 스타일) + // ───────────────────────────────────────────── + + /// 버튼/카드용 spring — 빠르고 살짝 bounce (damping ratio ≈ 0.65) + static const _springButton = SpringDescription(mass: 1.0, stiffness: 300.0, damping: 20.0); + + /// 아이콘 버튼용 spring — 더 강하고 안정적 (damping ratio ≈ 0.87) + static const _springIcon = SpringDescription(mass: 1.0, stiffness: 300.0, damping: 30.0); + + @override + State createState() => _AppPressableState(); +} + +class _AppPressableState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + double _currentScale = 1.0; + bool _isPressed = false; + + @override + void initState() { + super.initState(); + // duration/upperBound은 SpringSimulation이 직접 제어하므로 여기선 의미 없음 + _controller = AnimationController(vsync: this, duration: const Duration(seconds: 1)); + _controller.addListener(() { + if (mounted) { + setState(() { + _currentScale = 1.0 - ((1.0 - widget.scaleDown) * _controller.value); + }); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails _) { + if (!widget.enabled || widget.onTap == null) return; + _isPressed = true; + // 즉각 scale down — 애니메이션 없이 바로 적용 (토스 스타일: 누름 순간 즉각 반응) + _controller.stop(); + _controller.value = 1.0; + setState(() => _currentScale = widget.scaleDown); + } + + void _onTapUp(TapUpDetails _) { + if (!_isPressed) return; + _isPressed = false; + _springBack(); + } + + void _onTapCancel() { + if (!_isPressed) return; + _isPressed = false; + _springBack(); + } + + void _springBack() { + _controller.stop(); + // 현재 scale에서 1.0으로 복귀 — spring 물리 적용 + final startValue = (1.0 - _currentScale) / (1.0 - widget.scaleDown); + _controller.value = startValue.clamp(0.0, 1.0); + + final spring = widget.scaleDown <= AppPressable.scaleIcon ? AppPressable._springIcon : AppPressable._springButton; + + _controller.animateWith(SpringSimulation(spring, _controller.value, 0.0, 0.0)); + } + + @override + Widget build(BuildContext context) { + final effectiveRadius = widget.borderRadius ?? BorderRadius.circular(8); + + 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, + ), + ); + } +} diff --git a/lib/widgets/common/app_skeleton.dart b/lib/widgets/common/app_skeleton.dart new file mode 100644 index 00000000..4583c74d --- /dev/null +++ b/lib/widgets/common/app_skeleton.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_motion.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +/// 앱 전역 공통 스켈레톤 로딩 래퍼 +/// +/// - 로딩 중: skeleton 위젯을 Skeletonizer shimmer로 표시 +/// - 로딩 완료: fade 전환으로 실제 child 표시 +/// - shimmer 색상 통일 (opacity10White → opacity30White) +/// +/// 사용 예: +/// ```dart +/// AppSkeleton( +/// isLoading: _isLoading, +/// skeleton: MyScreenSkeleton(), +/// child: MyActualContent(), +/// ) +/// ``` +/// +/// SliverList 화면에서는 skeleton/child 모두 Sliver 위젯으로 전달. +class AppSkeleton extends StatelessWidget { + const AppSkeleton({ + super.key, + required this.isLoading, + required this.skeleton, + required this.child, + this.fadeDuration = AppMotion.fast, + }); + + final bool isLoading; + + /// 로딩 중 표시할 스켈레톤 위젯 + final Widget skeleton; + + /// 로딩 완료 후 표시할 실제 콘텐츠 + final Widget child; + + /// skeleton ↔ child 전환 fade 시간 (기본: 200ms) + final Duration fadeDuration; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: fadeDuration, + switchInCurve: AppMotion.entry, + switchOutCurve: AppMotion.entry, + transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), + child: isLoading + ? Skeletonizer( + key: const ValueKey('skeleton'), + enabled: true, + effect: const ShimmerEffect( + baseColor: AppColors.opacity10White, + highlightColor: AppColors.opacity30White, + ), + textBoneBorderRadius: const TextBoneBorderRadius.fromHeightFactor(.3), + ignoreContainers: true, + child: skeleton, + ) + : KeyedSubtree(key: const ValueKey('content'), child: child), + ); + } +} diff --git a/lib/widgets/common/common_modal.dart b/lib/widgets/common/common_modal.dart index a168b748..83f59cf1 100644 --- a/lib/widgets/common/common_modal.dart +++ b/lib/widgets/common/common_modal.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/icons/app_icons.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/utils/common_utils.dart'; import 'package:romrom_fe/utils/device_type.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; /// 공통 모달 위젯 /// 팩토리 메서드 패턴으로 success, error, confirm 타입 지원 @@ -53,6 +55,15 @@ class CommonModal extends StatelessWidget { }); } + /// 모달 등장 애니메이션: fade + scale (0.92→1.0) + static Widget _buildTransition(Widget child, Animation animation) { + final curvedAnim = CurvedAnimation(parent: animation, curve: AppMotion.decelerate); + return FadeTransition( + opacity: curvedAnim, + child: ScaleTransition(scale: Tween(begin: 0.92, end: 1.0).animate(curvedAnim), child: child), + ); + } + /// 성공 모달 (노란색 체크 아이콘, 1버튼) static Future success({ required BuildContext context, @@ -60,11 +71,14 @@ class CommonModal extends StatelessWidget { String buttonText = '확인', required VoidCallback onConfirm, }) { - return showDialog( + return showGeneralDialog( context: context, barrierDismissible: false, barrierColor: AppColors.dialogBarrier, - builder: (_) => CommonModal._( + barrierLabel: '', + transitionDuration: AppMotion.slow, + transitionBuilder: (context0, animation, secondaryAnimation0, child) => _buildTransition(child, animation), + pageBuilder: (context0, animation0, secondaryAnimation0) => CommonModal._( icon: AppIcons.onboardingProgressCheck, iconColor: AppColors.primaryYellow, iconBackgroundColor: AppColors.primaryYellow.withValues(alpha: 0.2), @@ -84,11 +98,14 @@ class CommonModal extends StatelessWidget { String buttonText = '확인', required VoidCallback onConfirm, }) { - return showDialog( + return showGeneralDialog( context: context, barrierDismissible: false, barrierColor: AppColors.dialogBarrier, - builder: (_) => CommonModal._( + barrierLabel: '', + transitionDuration: AppMotion.slow, + transitionBuilder: (context0, animation, secondaryAnimation0, child) => _buildTransition(child, animation), + pageBuilder: (context0, animation0, secondaryAnimation0) => CommonModal._( icon: AppIcons.warning, iconColor: AppColors.warningRed, iconBackgroundColor: AppColors.warningRed.withValues(alpha: 0.2), @@ -111,11 +128,14 @@ class CommonModal extends StatelessWidget { required VoidCallback onCancel, required VoidCallback onConfirm, }) { - return showDialog( + return showGeneralDialog( context: context, barrierDismissible: false, barrierColor: AppColors.dialogBarrier, - builder: (_) => CommonModal._( + barrierLabel: '', + transitionDuration: AppMotion.slow, + transitionBuilder: (context0, animation, secondaryAnimation0, child) => _buildTransition(child, animation), + pageBuilder: (context0, animation0, secondaryAnimation0) => CommonModal._( icon: AppIcons.warning, iconColor: AppColors.warningRed, iconBackgroundColor: AppColors.warningRed.withValues(alpha: 0.2), @@ -187,20 +207,17 @@ class CommonModal extends StatelessWidget { Widget _buildSingleButton() { return SizedBox( width: 264.w, - height: 44.h, - child: Material( - color: confirmButtonColor, + height: 44, + child: AppPressable( + onTap: onConfirm, borderRadius: BorderRadius.circular(10.r), - child: InkWell( - onTap: onConfirm, - highlightColor: darkenBlend(confirmButtonColor), - splashColor: darkenBlend(confirmButtonColor).withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(10.r), - child: Center( - child: Text( - confirmText, - style: CustomTextStyles.p1.copyWith(color: confirmTextColor, fontWeight: FontWeight.w700), - ), + rippleColor: darkenBlend(confirmButtonColor), + child: Container( + decoration: BoxDecoration(color: confirmButtonColor, borderRadius: BorderRadius.circular(10.r)), + alignment: Alignment.center, + child: Text( + confirmText, + style: CustomTextStyles.p1.copyWith(color: confirmTextColor, fontWeight: FontWeight.w700), ), ), ), @@ -216,17 +233,17 @@ class CommonModal extends StatelessWidget { Expanded( child: SizedBox( height: 44, - child: Material( - color: AppColors.opacity30PrimaryBlack, + child: AppPressable( + onTap: onCancel, borderRadius: BorderRadius.circular(10.r), - child: InkWell( - onTap: onCancel, - highlightColor: AppColors.opacity30PrimaryBlack, - splashColor: darkenBlend(AppColors.opacity30PrimaryBlack).withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(10.r), - child: Center( - child: Text(cancelText!, style: CustomTextStyles.p1, textAlign: TextAlign.center), + rippleColor: darkenBlend(AppColors.opacity30PrimaryBlack), + child: Container( + decoration: BoxDecoration( + color: AppColors.opacity30PrimaryBlack, + borderRadius: BorderRadius.circular(10.r), ), + alignment: Alignment.center, + child: Text(cancelText!, style: CustomTextStyles.p1, textAlign: TextAlign.center), ), ), ), @@ -236,17 +253,14 @@ class CommonModal extends StatelessWidget { Expanded( child: SizedBox( height: 44, - child: Material( - color: confirmButtonColor, + child: AppPressable( + onTap: onConfirm, borderRadius: BorderRadius.circular(10.r), - child: InkWell( - onTap: onConfirm, - highlightColor: darkenBlend(confirmButtonColor), - splashColor: darkenBlend(confirmButtonColor).withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(10.r), - child: Center( - child: Text(confirmText, style: CustomTextStyles.p1, textAlign: TextAlign.center), - ), + rippleColor: darkenBlend(confirmButtonColor), + child: Container( + decoration: BoxDecoration(color: confirmButtonColor, borderRadius: BorderRadius.circular(10.r)), + alignment: Alignment.center, + child: Text(confirmText, style: CustomTextStyles.p1, textAlign: TextAlign.center), ), ), ), diff --git a/lib/widgets/common/common_snack_bar.dart b/lib/widgets/common/common_snack_bar.dart index b1b30bce..2e3b4f19 100644 --- a/lib/widgets/common/common_snack_bar.dart +++ b/lib/widgets/common/common_snack_bar.dart @@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:romrom_fe/enums/snack_bar_type.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'; class CommonSnackBar { @@ -115,15 +116,15 @@ class CommonSnackBar { final tickerProvider = _OverlayTickerProvider(); animationController = AnimationController( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 200), + duration: AppMotion.normal, + reverseDuration: AppMotion.fast, vsync: tickerProvider, ); final fadeAnimation = Tween( begin: 0.0, end: 1.0, - ).animate(CurvedAnimation(parent: animationController, curve: Curves.easeInOut)); + ).animate(CurvedAnimation(parent: animationController, curve: AppMotion.standard)); // 위치를 위한 ValueNotifier final bottomPosition = ValueNotifier(_baseBottom); @@ -138,8 +139,8 @@ class CommonSnackBar { builder: (context) => ValueListenableBuilder( valueListenable: bottomPosition, builder: (context, bottom, child) => AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, + duration: AppMotion.fast, + curve: AppMotion.standard, bottom: bottom.h, left: 24.w, right: 24.w, diff --git a/lib/widgets/common/completed_toggle_switch.dart b/lib/widgets/common/completed_toggle_switch.dart index 08e1c9b2..462fe73f 100644 --- a/lib/widgets/common/completed_toggle_switch.dart +++ b/lib/widgets/common/completed_toggle_switch.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_motion.dart'; /// 거래 완료 토글 스위치 위젯 class CompletedToggleSwitch extends StatelessWidget { @@ -14,7 +15,7 @@ class CompletedToggleSwitch extends StatelessWidget { return GestureDetector( onTap: () => onChanged(!value), child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: AppMotion.fast, width: 40.w, height: 20.h, decoration: BoxDecoration( @@ -24,8 +25,8 @@ class CompletedToggleSwitch extends StatelessWidget { child: Stack( children: [ AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, + duration: AppMotion.fast, + curve: AppMotion.standard, left: value ? 19.w : 2.w, top: 1.h, child: Container( diff --git a/lib/widgets/common/completion_button.dart b/lib/widgets/common/completion_button.dart index b26a0c8e..d87dfe61 100644 --- a/lib/widgets/common/completion_button.dart +++ b/lib/widgets/common/completion_button.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/utils/common_utils.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; class CompletionButton extends StatelessWidget { final bool isEnabled; // 버튼 활성화 @@ -58,29 +59,34 @@ class CompletionButton extends StatelessWidget { final Color splashColor = highlightColor.withValues(alpha: 0.3); return Center( - 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, + 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(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(AppColors.textColorWhite), + ), + ) + : Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: false), + ), ), ), ), diff --git a/lib/widgets/common/custom_floating_button.dart b/lib/widgets/common/custom_floating_button.dart index ead003fb..d00982b0 100644 --- a/lib/widgets/common/custom_floating_button.dart +++ b/lib/widgets/common/custom_floating_button.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/utils/common_utils.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; class CustomFloatingButton extends StatelessWidget { final bool isEnabled; // 버튼 활성화 @@ -53,20 +54,25 @@ class CustomFloatingButton extends StatelessWidget { final Color splashColor = highlightColor.withValues(alpha: 0.3); return Center( - 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, + 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: Center( - child: Text(buttonText, style: buttonTextStyle, textAlign: TextAlign.center, softWrap: false), + 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), + ), ), ), ), diff --git a/lib/widgets/common/glass_header_delegate.dart b/lib/widgets/common/glass_header_delegate.dart index 324fd07c..847e0039 100644 --- a/lib/widgets/common/glass_header_delegate.dart +++ b/lib/widgets/common/glass_header_delegate.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.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'; /// 공통 글래스 헤더 Delegate @@ -203,7 +204,7 @@ class GlassHeaderToggleBuilder { color: Colors.transparent, alignment: Alignment.center, child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 300), + duration: AppMotion.normal, style: CustomTextStyles.p2.copyWith( color: !isRightSelected ? AppColors.textColorWhite : AppColors.opacity50White, ), @@ -220,7 +221,7 @@ class GlassHeaderToggleBuilder { color: Colors.transparent, alignment: Alignment.center, child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 300), + duration: AppMotion.normal, style: CustomTextStyles.p2.copyWith( color: isRightSelected ? AppColors.textColorWhite : AppColors.opacity50White, ), diff --git a/lib/widgets/common/range_slider_widget.dart b/lib/widgets/common/range_slider_widget.dart index 7d699fba..26442b5a 100644 --- a/lib/widgets/common/range_slider_widget.dart +++ b/lib/widgets/common/range_slider_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.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'; /// 탐색 범위 설정을 위한 커스텀 슬라이더 위젯 @@ -41,11 +42,11 @@ class _RangeSliderWidgetState extends State with SingleTicker super.initState(); _currentIndex = widget.selectedIndex; _lastHapticIndex = _currentIndex; - _animationController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this); + _animationController = AnimationController(duration: AppMotion.fast, vsync: this); _animation = Tween( begin: _currentIndex.toDouble(), end: _currentIndex.toDouble(), - ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); + ).animate(CurvedAnimation(parent: _animationController, curve: AppMotion.decelerate)); } @override @@ -66,7 +67,7 @@ class _RangeSliderWidgetState extends State with SingleTicker _animation = Tween( begin: _animation.value, end: index.toDouble(), - ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); + ).animate(CurvedAnimation(parent: _animationController, curve: AppMotion.decelerate)); _animationController.forward(from: 0); _currentIndex = index; _lastHapticIndex = index; diff --git a/lib/widgets/common/romrom_context_menu.dart b/lib/widgets/common/romrom_context_menu.dart index a912aea5..68157d0a 100644 --- a/lib/widgets/common/romrom_context_menu.dart +++ b/lib/widgets/common/romrom_context_menu.dart @@ -7,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:romrom_fe/enums/context_menu_enums.dart'; import 'package:romrom_fe/icons/app_icons.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/utils/common_utils.dart'; @@ -85,7 +86,7 @@ class _RomRomContextMenuState extends State with SingleTicker final curve = CurvedAnimation( parent: _animationController, - curve: Curves.easeOutCubic, + curve: AppMotion.decelerate, reverseCurve: Curves.easeInCubic, ); diff --git a/lib/widgets/common/scrollable_header.dart b/lib/widgets/common/scrollable_header.dart index dce60de7..713af1a7 100644 --- a/lib/widgets/common/scrollable_header.dart +++ b/lib/widgets/common/scrollable_header.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.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'; /// 스크롤에 따라 동적으로 크기가 변하는 헤더 위젯 @@ -38,7 +39,7 @@ class ScrollableHeader extends StatelessWidget { title: Padding( padding: EdgeInsets.only(top: 16.h, bottom: 24.h), child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: AppMotion.fast, opacity: isScrolled ? 1.0 : 0.0, child: Text(title, style: CustomTextStyles.h3.copyWith(fontWeight: FontWeight.w600)), ), @@ -52,7 +53,7 @@ class ScrollableHeader extends StatelessWidget { child: Align( alignment: Alignment.topLeft, child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: AppMotion.fast, opacity: isScrolled ? 0.0 : 1.0, child: Text(title, style: CustomTextStyles.h1), ), diff --git a/lib/widgets/common/toggle_selector.dart b/lib/widgets/common/toggle_selector.dart index 7fc7dd7f..dbfcb989 100644 --- a/lib/widgets/common/toggle_selector.dart +++ b/lib/widgets/common/toggle_selector.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.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'; /// 두 가지 옵션 중 하나를 선택하는 토글 위젯 @@ -43,12 +44,12 @@ class _ToggleSelectorState extends State with SingleTickerProvid super.initState(); // 토글 애니메이션 초기화 - _toggleAnimationController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); + _toggleAnimationController = AnimationController(duration: AppMotion.normal, vsync: this); _toggleAnimation = Tween( begin: 0.0, end: 1.0, - ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: Curves.easeInOut)); + ).animate(CurvedAnimation(parent: _toggleAnimationController, curve: AppMotion.standard)); // 초기 상태 설정 if (widget.isRightSelected) { diff --git a/lib/widgets/common/triple_toggle_switch.dart b/lib/widgets/common/triple_toggle_switch.dart index e0ce26e5..4729f5c5 100644 --- a/lib/widgets/common/triple_toggle_switch.dart +++ b/lib/widgets/common/triple_toggle_switch.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.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'; /// 3개 탭 토글 스위치 위젯 @@ -71,7 +72,7 @@ class TripleToggleSwitch extends StatelessWidget { color: AppColors.transparent, alignment: Alignment.center, child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 300), + duration: AppMotion.normal, style: CustomTextStyles.p2.copyWith( color: selectedIndex == 0 ? AppColors.textColorWhite : AppColors.opacity50White, ), @@ -89,7 +90,7 @@ class TripleToggleSwitch extends StatelessWidget { color: AppColors.transparent, alignment: Alignment.center, child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 300), + duration: AppMotion.normal, style: CustomTextStyles.p2.copyWith( color: selectedIndex == 1 ? AppColors.textColorWhite : AppColors.opacity50White, ), @@ -107,7 +108,7 @@ class TripleToggleSwitch extends StatelessWidget { color: AppColors.transparent, alignment: Alignment.center, child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 300), + duration: AppMotion.normal, style: CustomTextStyles.p2.copyWith( color: selectedIndex == 2 ? AppColors.textColorWhite : AppColors.opacity50White, ), diff --git a/lib/widgets/common_app_bar.dart b/lib/widgets/common_app_bar.dart index 1423be5d..9c7eff5c 100644 --- a/lib/widgets/common_app_bar.dart +++ b/lib/widgets/common_app_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; /// 공통 앱바 위젯 /// 뒤로가기 버튼과 중앙 정렬된 제목을 기본 제공 @@ -61,21 +62,13 @@ class CommonAppBar extends StatelessWidget implements PreferredSizeWidget { toolbarHeight: appBarHeight, scrolledUnderElevation: 0, leadingWidth: 72.w, - leading: Material( - color: Colors.transparent, - child: ClipOval( - child: InkResponse( - customBorder: const CircleBorder(), - onTap: onBackPressed ?? () => Navigator.of(context).pop(), - containedInkWell: true, - radius: 18.w, - highlightColor: AppColors.buttonHighlightColorGray, - splashColor: AppColors.buttonHighlightColorGray.withValues(alpha: 0.3), - child: SizedBox.square( - dimension: 32.w, - child: const Icon(AppIcons.navigateBefore, size: 24, color: AppColors.textColorWhite), - ), - ), + leading: AppPressable( + onTap: onBackPressed ?? () => Navigator.of(context).pop(), + scaleDown: AppPressable.scaleIcon, + enableRipple: false, + child: SizedBox.square( + dimension: 32.w, + child: const Icon(AppIcons.navigateBefore, size: 24, color: AppColors.textColorWhite), ), ), bottom: bottomWidgets, diff --git a/lib/widgets/custom_bottom_navigation_bar.dart b/lib/widgets/custom_bottom_navigation_bar.dart index bb18e657..a614e575 100644 --- a/lib/widgets/custom_bottom_navigation_bar.dart +++ b/lib/widgets/custom_bottom_navigation_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:romrom_fe/enums/navigation_tab_items.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; class CustomBottomNavigationBar extends StatelessWidget { final int selectedIndex; @@ -39,8 +40,10 @@ class CustomBottomNavigationBar extends StatelessWidget { Widget _buildNavItem(BuildContext context, int index, IconData icon, String label) { final bool isSelected = selectedIndex == index; - return InkWell( + return AppPressable( onTap: () => onTap(index), + scaleDown: AppPressable.scaleIcon, + enableRipple: false, child: SizedBox( width: MediaQuery.of(context).size.width / 5, child: Padding( diff --git a/lib/widgets/home_feed_ai_sort_button.dart b/lib/widgets/home_feed_ai_sort_button.dart index b5102845..242ec73f 100644 --- a/lib/widgets/home_feed_ai_sort_button.dart +++ b/lib/widgets/home_feed_ai_sort_button.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:gradient_borders/box_borders/gradient_box_border.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; /// AI 물품 추천 버튼 class HomeFeedAiSortButton extends StatelessWidget { @@ -37,8 +38,10 @@ class HomeFeedAiSortButton extends StatelessWidget { ), ]; - return GestureDetector( + return AppPressable( onTap: onTap, + scaleDown: AppPressable.scaleButton, + enableRipple: false, child: Container( width: 67.w, height: 24.h, diff --git a/lib/widgets/home_feed_item_widget.dart b/lib/widgets/home_feed_item_widget.dart index effd514c..f4cfd50c 100644 --- a/lib/widgets/home_feed_item_widget.dart +++ b/lib/widgets/home_feed_item_widget.dart @@ -24,6 +24,7 @@ import 'package:romrom_fe/widgets/user_profile_circular_avatar.dart'; import 'package:romrom_fe/widgets/common/error_image_placeholder.dart'; import 'package:romrom_fe/widgets/common/cached_image.dart'; import 'package:romrom_fe/services/member_manager_service.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; import 'package:romrom_fe/screens/profile/member_profile_screen.dart'; @@ -162,7 +163,9 @@ class _HomeFeedItemWidgetState extends State { children: [ // 이미지와 그라디언트 Positioned.fill( - child: GestureDetector( + child: AppPressable( + scaleDown: AppPressable.scaleCard, + enableRipple: false, onTap: () async { final result = await Navigator.push( context, @@ -414,7 +417,9 @@ class _HomeFeedItemWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ // 프로필 이미지 - GestureDetector( + AppPressable( + scaleDown: AppPressable.scaleIcon, + enableRipple: false, onTap: widget.showBlur ? null : () { diff --git a/lib/widgets/login_button.dart b/lib/widgets/login_button.dart index eb18a91d..1effb3b9 100644 --- a/lib/widgets/login_button.dart +++ b/lib/widgets/login_button.dart @@ -21,6 +21,7 @@ import 'package:romrom_fe/services/google_auth_service.dart'; import 'package:romrom_fe/services/kakao_auth_service.dart'; import 'package:romrom_fe/utils/common_utils.dart'; import 'package:romrom_fe/widgets/common/common_modal.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -137,18 +138,17 @@ class _LoginButtonState extends State { @override Widget build(BuildContext context) { - return Material( - color: widget.platform.backgroundColor, - borderRadius: BorderRadius.circular(10.r), - child: InkWell( - onTap: _isLoading - ? null - : () async { - await handleLogin(context); - }, - customBorder: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.r)), - highlightColor: darkenBlend(widget.platform.backgroundColor), - splashColor: darkenBlend(widget.platform.backgroundColor).withValues(alpha: 0.3), + return AppPressable( + scaleDown: AppPressable.scaleButton, + enableRipple: false, + onTap: _isLoading + ? null + : () async { + await handleLogin(context); + }, + child: Material( + color: widget.platform.backgroundColor, + borderRadius: BorderRadius.circular(10.r), child: SizedBox( width: double.infinity, height: 56.h, diff --git a/lib/widgets/notification_item_widget.dart b/lib/widgets/notification_item_widget.dart index bfe9d77a..c0a2e016 100644 --- a/lib/widgets/notification_item_widget.dart +++ b/lib/widgets/notification_item_widget.dart @@ -6,6 +6,7 @@ import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/utils/common_utils.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/common/cached_image.dart'; import 'package:romrom_fe/widgets/common/romrom_context_menu.dart'; @@ -51,9 +52,10 @@ class NotificationItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return AppPressable( onTap: onTap, - behavior: HitTestBehavior.opaque, + scaleDown: AppPressable.scaleCard, + enableRipple: false, child: Container( color: data.isRead ? AppColors.primaryBlack : AppColors.notificationUnReadIndicator, // 읽은 알림과 안 읽은 알림 구분 padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 24.w), diff --git a/lib/widgets/onboarding_progress_header.dart b/lib/widgets/onboarding_progress_header.dart index 38d288be..eca473fa 100644 --- a/lib/widgets/onboarding_progress_header.dart +++ b/lib/widgets/onboarding_progress_header.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; /// 온보딩 프로그레스 헤더 위젯 /// @@ -83,8 +84,10 @@ class _OnboardingProgressHeaderState extends State wit children: [ SizedBox(width: 24.w), // 뒤로가기 버튼 - GestureDetector( + AppPressable( onTap: widget.onBackPressed ?? () => Navigator.of(context).pop(), + scaleDown: AppPressable.scaleIcon, + enableRipple: false, child: Icon(AppIcons.navigateBefore, size: 24.h, color: AppColors.textColorWhite), ), SizedBox(width: 81.w), diff --git a/lib/widgets/request_list_item_card_widget.dart b/lib/widgets/request_list_item_card_widget.dart index 39e300bb..3309b2ae 100644 --- a/lib/widgets/request_list_item_card_widget.dart +++ b/lib/widgets/request_list_item_card_widget.dart @@ -12,6 +12,7 @@ import 'package:romrom_fe/widgets/common/trade_status_tag.dart'; import 'package:romrom_fe/widgets/common/error_image_placeholder.dart'; import 'package:romrom_fe/widgets/common/cached_image.dart'; import 'package:romrom_fe/utils/common_utils.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; /// 요청 목록 아이템 카드 위젯 class RequestListItemCardWidget extends StatelessWidget { @@ -23,6 +24,7 @@ class RequestListItemCardWidget extends StatelessWidget { final List tradeOptions; final TradeStatus tradeStatus; final VoidCallback onMenuTap; + final VoidCallback? onTap; const RequestListItemCardWidget({ super.key, @@ -34,6 +36,7 @@ class RequestListItemCardWidget extends StatelessWidget { required this.tradeOptions, required this.tradeStatus, required this.onMenuTap, + this.onTap, }); @override @@ -44,121 +47,128 @@ class RequestListItemCardWidget extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 이미지 - Padding( - padding: EdgeInsets.only(right: 8.w), - child: SizedBox( - width: 70.w, - height: 70.w, - child: Container( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(4.r)), - child: ClipRRect(borderRadius: BorderRadius.circular(4.r), child: _buildImage(imageUrl)), - ), - ), - ), - // 정보 영역 + // 이미지 + 텍스트 영역 (메뉴 제외한 터치 영역) Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 왼쪽 영역 (제목 + 주소 + 시간 + 거래 옵션 태그) - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - // 제목 (7자 제한) - Text( - title.length > 8 ? '${title.substring(0, 8)}...' : title, - style: CustomTextStyles.p1.copyWith(fontWeight: FontWeight.w500), - ), - if (isNew) ...[ - SizedBox(width: 8.w), - SvgPicture.asset('assets/images/redNew.svg', width: 16.w, height: 16.w), - ], - ], + child: AppPressable( + onTap: onTap, + scaleDown: AppPressable.scaleCard, + enableRipple: false, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 이미지 + Padding( + padding: EdgeInsets.only(right: 8.w), + child: SizedBox( + width: 70.w, + height: 70.w, + child: Container( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(4.r)), + child: ClipRRect(borderRadius: BorderRadius.circular(4.r), child: _buildImage(imageUrl)), ), + ), + ), + // 왼쪽 텍스트 영역 + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // 제목 (7자 제한) + Text( + title.length > 8 ? '${title.substring(0, 8)}...' : title, + style: CustomTextStyles.p1.copyWith(fontWeight: FontWeight.w500), + ), + if (isNew) ...[ + SizedBox(width: 8.w), + SvgPicture.asset('assets/images/redNew.svg', width: 16.w, height: 16.w), + ], + ], + ), - const SizedBox(height: 8), + const SizedBox(height: 8), - Row( - children: [ - // 주소 - Text( - address, - style: CustomTextStyles.p3.copyWith( - fontWeight: FontWeight.w500, - color: AppColors.opacity60White, + Row( + children: [ + // 주소 + Text( + address, + style: CustomTextStyles.p3.copyWith( + fontWeight: FontWeight.w500, + color: AppColors.opacity60White, + ), ), - ), - SizedBox(width: 4.w), - // 중간점 - Container( - width: 2.w, - height: 2.w, - decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.opacity60White), - ), - SizedBox(width: 4.w), - // 시간 - Text( - getTimeAgo(createdDate), - style: CustomTextStyles.p3.copyWith( - fontWeight: FontWeight.w500, - color: AppColors.opacity60White, + SizedBox(width: 4.w), + // 중간점 + Container( + width: 2.w, + height: 2.w, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.opacity60White, + ), ), - ), - ], - ), - - const SizedBox(height: 11), - - // 거래 옵션 태그들 (줄바꿈 방지) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: tradeOptions - .map( - (option) => Padding( - padding: EdgeInsets.only(right: 4.w), - child: RequestManagementTradeOptionTag(option: option), - ), - ) - .toList(), + SizedBox(width: 4.w), + // 시간 + Text( + getTimeAgo(createdDate), + style: CustomTextStyles.p3.copyWith( + fontWeight: FontWeight.w500, + color: AppColors.opacity60White, + ), + ), + ], ), - ), - ], - ), - ), - // 오른쪽 메뉴 버튼 - Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // 메뉴 아이콘 - RomRomContextMenu( - items: [ - ContextMenuItem( - id: 'delete', - icon: AppIcons.trash, - iconColor: AppColors.itemOptionsMenuRedIcon, - title: '삭제', - textColor: AppColors.itemOptionsMenuRedText, - onTap: onMenuTap, + const SizedBox(height: 11), + + // 거래 옵션 태그들 (줄바꿈 방지) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: tradeOptions + .map( + (option) => Padding( + padding: EdgeInsets.only(right: 4.w), + child: RequestManagementTradeOptionTag(option: option), + ), + ) + .toList(), + ), ), ], ), - - // 거래 상태 태그 - TradeStatusTagWidget(status: tradeStatus), - ], - ), - ], + ), + ], + ), ), ), + + // 오른쪽 메뉴 버튼 (AppPressable 밖 — 누를 때 카드 전체 안 줄어듦) + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 메뉴 아이콘 + RomRomContextMenu( + items: [ + ContextMenuItem( + id: 'delete', + icon: AppIcons.trash, + iconColor: AppColors.itemOptionsMenuRedIcon, + title: '삭제', + textColor: AppColors.itemOptionsMenuRedText, + onTap: onMenuTap, + ), + ], + ), + + // 거래 상태 태그 + TradeStatusTagWidget(status: tradeStatus), + ], + ), ], ), ), diff --git a/lib/widgets/request_management_item_card_widget.dart b/lib/widgets/request_management_item_card_widget.dart index 496598aa..e1b813a2 100644 --- a/lib/widgets/request_management_item_card_widget.dart +++ b/lib/widgets/request_management_item_card_widget.dart @@ -3,6 +3,7 @@ import 'package:romrom_fe/enums/font_family.dart'; import 'package:romrom_fe/icons/app_icons.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/models/request_management_item_card.dart'; import 'package:romrom_fe/utils/common_utils.dart'; @@ -42,7 +43,7 @@ class RequestManagementItemCardWidget extends StatelessWidget { return AnimatedScale( scale: scale, - duration: const Duration(milliseconds: 300), + duration: AppMotion.normal, child: SizedBox( width: cardWidth, height: cardHeight, diff --git a/lib/widgets/sent_request_item_card.dart b/lib/widgets/sent_request_item_card.dart index 2a405b03..4eae120c 100644 --- a/lib/widgets/sent_request_item_card.dart +++ b/lib/widgets/sent_request_item_card.dart @@ -5,6 +5,7 @@ import 'package:romrom_fe/enums/trade_status.dart'; import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/widgets/common/app_pressable.dart'; import 'package:romrom_fe/widgets/common/request_management_trade_option_tag.dart'; import 'package:romrom_fe/widgets/common/trade_status_tag.dart'; import 'package:romrom_fe/widgets/common/error_image_placeholder.dart'; @@ -23,6 +24,7 @@ class SentRequestItemCard extends StatelessWidget { final TradeStatus? tradeStatus; final VoidCallback? onEditTap; final VoidCallback? onCancelTap; + final VoidCallback? onTap; const SentRequestItemCard({ super.key, @@ -36,18 +38,25 @@ class SentRequestItemCard extends StatelessWidget { this.tradeStatus, this.onEditTap, this.onCancelTap, + this.onTap, }); @override Widget build(BuildContext context) { - return Container( - width: 361.w, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(10.r), color: AppColors.secondaryBlack1), - child: Column( - children: [ - _buildTopImageSection(), // 상단 이미지 영역 - _buildBottomInfoSection(context), // 하단 정보 영역 - ], + return AppPressable( + onTap: onTap, + scaleDown: AppPressable.scaleCard, + enableRipple: false, + borderRadius: BorderRadius.circular(10.r), + child: Container( + width: 361.w, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10.r), color: AppColors.secondaryBlack1), + child: Column( + children: [ + _buildTopImageSection(), // 상단 이미지 영역 + _buildBottomInfoSection(context), // 하단 정보 영역 + ], + ), ), ); } diff --git a/lib/widgets/skeletons/home_tab_skeleton.dart b/lib/widgets/skeletons/home_tab_skeleton.dart new file mode 100644 index 00000000..0298f45a --- /dev/null +++ b/lib/widgets/skeletons/home_tab_skeleton.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:romrom_fe/models/app_colors.dart'; + +/// 홈 탭 피드 로딩 스켈레톤 +/// +/// 단순하게 3개 덩어리: +/// 1. 전체 배경 shimmer +/// 2. 하단 텍스트 블록 (제목/태그/가격 영역) +/// 3. 하단 카드 덱 어두운 영역 +class HomeTabSkeleton extends StatelessWidget { + const HomeTabSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Stack( + children: [ + // 1. 전체 배경 + Skeleton.leaf( + child: Container(width: double.infinity, height: double.infinity, color: AppColors.secondaryBlack1), + ), + + // 2. 하단 텍스트 블록 — 제목/태그/가격 영역 하나의 둥근 사각형 + Positioned( + left: 24.w, + right: 80.w, + bottom: 170.h, + child: Skeleton.leaf( + child: Container( + height: 130.h, + decoration: BoxDecoration(color: AppColors.opacity20White, borderRadius: BorderRadius.circular(12.r)), + ), + ), + ), + + // 3. 하단 카드 덱 영역 — 화면 하단 어두운 블록 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Skeleton.leaf( + child: Container(height: 150.h, color: AppColors.opacity20Black), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/skeletons/notification_skeleton.dart b/lib/widgets/skeletons/notification_skeleton.dart new file mode 100644 index 00000000..6a6553ad --- /dev/null +++ b/lib/widgets/skeletons/notification_skeleton.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_theme.dart'; + +/// 알림 목록 스켈레톤 +class NotificationListSkeleton extends StatelessWidget { + const NotificationListSkeleton({super.key, this.itemCount = 6}); + + final int itemCount; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(itemCount, (index) => _NotificationSkeletonItem(key: ValueKey(index))), + ); + } +} + +class _NotificationSkeletonItem extends StatelessWidget { + const _NotificationSkeletonItem({super.key}); + + @override + Widget build(BuildContext context) { + return Skeletonizer( + enabled: true, + effect: const ShimmerEffect(baseColor: AppColors.opacity10White, highlightColor: AppColors.opacity30White), + textBoneBorderRadius: const TextBoneBorderRadius.fromHeightFactor(.3), + ignoreContainers: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 아이콘 원형 + Skeleton.leaf( + child: Container( + width: 40.w, + height: 40.w, + decoration: const BoxDecoration(color: AppColors.opacity20White, shape: BoxShape.circle), + ), + ), + SizedBox(width: 12.w), + // 텍스트 영역 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Skeleton.leaf( + child: Text( + '알림 제목 텍스트가 여기에 표시됩니다', + style: CustomTextStyles.p2.copyWith(color: AppColors.opacity80White), + ), + ), + SizedBox(height: 4.h), + Skeleton.leaf( + child: Text('2시간 전', style: CustomTextStyles.p3.copyWith(color: AppColors.opacity40White)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 033f4167..a9a25522 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.7" + animations: + dependency: "direct main" + description: + name: animations + sha256: a120785be876b24177e8af387929e786e7761d6574e63cad6c2ca28545b30186 + url: "https://pub.dev" + source: hosted + version: "2.1.2" args: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d33f979..9e7c84d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ dependencies: sdk: flutter # 알파벳 순으로 정렬 animated_toggle_switch: ^0.8.5 + animations: ^2.0.0 crypto: ^3.0.6 cupertino_icons: ^1.0.6 dart_jsonwebtoken: ^2.8.0