From 21941443ee74eac673ffa2f0fa22110996dc0ae0 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 12 Jan 2026 15:15:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20=EA=B3=B5=ED=86=B5=20UI=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 새 파일 ### AppDimensions (app_dimensions.dart) - UI 관련 상수 중앙 집중화 - 패딩, 마진, 보더 레디우스, 아이콘 크기 등 정의 - 하드코딩된 값을 상수로 대체하여 일관성 확보 ### InfoRow (info_row.dart) - 레이블-값 쌍을 표시하는 공통 위젯 - 수평/수직 배치 지원 ### SkeletonLoader (skeleton_loader.dart) - Shimmer 효과를 가진 스켈레톤 로더 - RestaurantCardSkeleton, RestaurantListSkeleton 포함 - 로딩 상태 UX 개선 --- lib/core/constants/app_dimensions.dart | 55 +++++++++ lib/core/widgets/info_row.dart | 58 ++++++++++ lib/core/widgets/skeleton_loader.dart | 147 +++++++++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 lib/core/constants/app_dimensions.dart create mode 100644 lib/core/widgets/info_row.dart create mode 100644 lib/core/widgets/skeleton_loader.dart diff --git a/lib/core/constants/app_dimensions.dart b/lib/core/constants/app_dimensions.dart new file mode 100644 index 0000000..4a9958c --- /dev/null +++ b/lib/core/constants/app_dimensions.dart @@ -0,0 +1,55 @@ +/// UI 관련 상수 정의 +/// 하드코딩된 패딩, 마진, 크기 값들을 중앙 집중화 +class AppDimensions { + AppDimensions._(); + + // Padding & Margin + static const double paddingXs = 4.0; + static const double paddingSm = 8.0; + static const double paddingMd = 12.0; + static const double paddingDefault = 16.0; + static const double paddingLg = 20.0; + static const double paddingXl = 24.0; + + // Border Radius + static const double radiusSm = 8.0; + static const double radiusMd = 12.0; + static const double radiusLg = 16.0; + static const double radiusXl = 20.0; + static const double radiusRound = 999.0; + + // Icon Sizes + static const double iconSm = 16.0; + static const double iconMd = 24.0; + static const double iconLg = 32.0; + static const double iconXl = 48.0; + static const double iconXxl = 64.0; + static const double iconHuge = 80.0; + + // Card Sizes + static const double cardIconSize = 48.0; + static const double cardMinHeight = 80.0; + + // Ad Settings + static const int adInterval = 6; // 5리스트 후 1광고 + static const int adOffset = 5; // 광고 시작 위치 + static const double adHeightSmall = 100.0; + static const double adHeightMedium = 320.0; + + // Distance Settings + static const double maxSearchDistance = 2000.0; // meters + static const int distanceSliderDivisions = 19; + + // List Settings + static const double listItemSpacing = 8.0; + static const double sectionSpacing = 16.0; + + // Bottom Sheet + static const double bottomSheetHandleWidth = 40.0; + static const double bottomSheetHandleHeight = 4.0; + + // Avatar/Profile + static const double avatarSm = 32.0; + static const double avatarMd = 48.0; + static const double avatarLg = 64.0; +} diff --git a/lib/core/widgets/info_row.dart b/lib/core/widgets/info_row.dart new file mode 100644 index 0000000..c56ed78 --- /dev/null +++ b/lib/core/widgets/info_row.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import '../constants/app_dimensions.dart'; +import '../constants/app_typography.dart'; + +/// 상세 정보를 표시하는 공통 행 위젯 +/// [label]과 [value]를 수직 또는 수평으로 배치 +class InfoRow extends StatelessWidget { + final String label; + final String value; + final bool isDark; + + /// true: 수평 배치 (레이블 | 값), false: 수직 배치 (레이블 위, 값 아래) + final bool horizontal; + + /// 수평 배치 시 레이블 영역 너비 + final double? labelWidth; + + const InfoRow({ + super.key, + required this.label, + required this.value, + required this.isDark, + this.horizontal = false, + this.labelWidth = 80, + }); + + @override + Widget build(BuildContext context) { + if (horizontal) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingXs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: labelWidth, + child: Text(label, style: AppTypography.caption(isDark)), + ), + const SizedBox(width: AppDimensions.paddingSm), + Expanded(child: Text(value, style: AppTypography.body2(isDark))), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingXs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: AppTypography.caption(isDark)), + const SizedBox(height: 2), + Text(value, style: AppTypography.body2(isDark)), + ], + ), + ); + } +} diff --git a/lib/core/widgets/skeleton_loader.dart b/lib/core/widgets/skeleton_loader.dart new file mode 100644 index 0000000..3b56033 --- /dev/null +++ b/lib/core/widgets/skeleton_loader.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import '../constants/app_colors.dart'; +import '../constants/app_dimensions.dart'; + +/// Shimmer 효과를 가진 스켈톤 로더 +class SkeletonLoader extends StatefulWidget { + final double width; + final double height; + final double borderRadius; + + const SkeletonLoader({ + super.key, + this.width = double.infinity, + required this.height, + this.borderRadius = AppDimensions.radiusSm, + }); + + @override + State createState() => _SkeletonLoaderState(); +} + +class _SkeletonLoaderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + + _animation = Tween(begin: -1.0, end: 2.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final baseColor = isDark + ? AppColors.darkSurface.withValues(alpha: 0.6) + : Colors.grey.shade300; + final highlightColor = isDark + ? AppColors.darkSurface.withValues(alpha: 0.9) + : Colors.grey.shade100; + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [baseColor, highlightColor, baseColor], + stops: [ + (_animation.value - 1).clamp(0.0, 1.0), + _animation.value.clamp(0.0, 1.0), + (_animation.value + 1).clamp(0.0, 1.0), + ], + ), + ), + ); + }, + ); + } +} + +/// 맛집 카드 스켈톤 +class RestaurantCardSkeleton extends StatelessWidget { + const RestaurantCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppDimensions.paddingDefault, + vertical: AppDimensions.paddingSm, + ), + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, + child: Padding( + padding: const EdgeInsets.all(AppDimensions.paddingDefault), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // 카테고리 아이콘 영역 + const SkeletonLoader( + width: AppDimensions.cardIconSize, + height: AppDimensions.cardIconSize, + ), + const SizedBox(width: AppDimensions.paddingMd), + // 가게 정보 영역 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SkeletonLoader(height: 20, width: 150), + const SizedBox(height: AppDimensions.paddingXs), + const SkeletonLoader(height: 14, width: 100), + ], + ), + ), + // 거리 배지 + const SkeletonLoader(width: 60, height: 28, borderRadius: 14), + ], + ), + const SizedBox(height: AppDimensions.paddingMd), + // 주소 + const SkeletonLoader(height: 14), + ], + ), + ), + ); + } +} + +/// 맛집 리스트 스켈톤 +class RestaurantListSkeleton extends StatelessWidget { + final int itemCount; + + const RestaurantListSkeleton({super.key, this.itemCount = 5}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + itemBuilder: (context, index) => const RestaurantCardSkeleton(), + ); + } +}