## 새 파일 ### AppDimensions (app_dimensions.dart) - UI 관련 상수 중앙 집중화 - 패딩, 마진, 보더 레디우스, 아이콘 크기 등 정의 - 하드코딩된 값을 상수로 대체하여 일관성 확보 ### InfoRow (info_row.dart) - 레이블-값 쌍을 표시하는 공통 위젯 - 수평/수직 배치 지원 ### SkeletonLoader (skeleton_loader.dart) - Shimmer 효과를 가진 스켈레톤 로더 - RestaurantCardSkeleton, RestaurantListSkeleton 포함 - 로딩 상태 UX 개선
148 lines
4.3 KiB
Dart
148 lines
4.3 KiB
Dart
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(),
|
|
);
|
|
}
|
|
}
|