feat(core): 공통 UI 컴포넌트 및 상수 추가
## 새 파일 ### AppDimensions (app_dimensions.dart) - UI 관련 상수 중앙 집중화 - 패딩, 마진, 보더 레디우스, 아이콘 크기 등 정의 - 하드코딩된 값을 상수로 대체하여 일관성 확보 ### InfoRow (info_row.dart) - 레이블-값 쌍을 표시하는 공통 위젯 - 수평/수직 배치 지원 ### SkeletonLoader (skeleton_loader.dart) - Shimmer 효과를 가진 스켈레톤 로더 - RestaurantCardSkeleton, RestaurantListSkeleton 포함 - 로딩 상태 UX 개선
This commit is contained in:
55
lib/core/constants/app_dimensions.dart
Normal file
55
lib/core/constants/app_dimensions.dart
Normal file
@@ -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;
|
||||
}
|
||||
58
lib/core/widgets/info_row.dart
Normal file
58
lib/core/widgets/info_row.dart
Normal file
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/core/widgets/skeleton_loader.dart
Normal file
147
lib/core/widgets/skeleton_loader.dart
Normal file
@@ -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<SkeletonLoader> createState() => _SkeletonLoaderState();
|
||||
}
|
||||
|
||||
class _SkeletonLoaderState extends State<SkeletonLoader>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
|
||||
_animation = Tween<double>(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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user