## 성능 최적화 ### main.dart - 앱 초기화 병렬 처리 (Future.wait 활용) - 광고 SDK, Hive 초기화 동시 실행 - Hive Box 오픈 병렬 처리 - 코드 구조화 (_initializeHive, _initializeNotifications) ### visit_provider.dart - allLastVisitDatesProvider 추가 - 리스트 화면에서 N+1 쿼리 방지 - 모든 맛집의 마지막 방문일 일괄 조회 ## UI 개선 ### 각 화면 리팩토링 - AppDimensions 상수 적용 - 스켈레톤 로더 적용 - 코드 정리 및 일관성 개선
265 lines
7.9 KiB
Dart
265 lines
7.9 KiB
Dart
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import '../../../core/constants/app_colors.dart';
|
|
import '../../../core/constants/app_typography.dart';
|
|
import '../../../core/constants/app_constants.dart';
|
|
import '../../../core/services/permission_service.dart';
|
|
|
|
class SplashScreen extends StatefulWidget {
|
|
const SplashScreen({super.key});
|
|
|
|
@override
|
|
State<SplashScreen> createState() => _SplashScreenState();
|
|
}
|
|
|
|
class _SplashScreenState extends State<SplashScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
List<Offset>? _iconPositions;
|
|
Size? _lastScreenSize;
|
|
|
|
final List<IconData> foodIcons = [
|
|
Icons.rice_bowl,
|
|
Icons.ramen_dining,
|
|
Icons.lunch_dining,
|
|
Icons.fastfood,
|
|
Icons.local_pizza,
|
|
Icons.cake,
|
|
Icons.coffee,
|
|
Icons.icecream,
|
|
Icons.bakery_dining,
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeAnimations();
|
|
_navigateToHome();
|
|
}
|
|
|
|
void _initializeAnimations() {
|
|
// 단일 컨트롤러로 모든 애니메이션 제어 (메모리 최적화)
|
|
_animationController = AnimationController(
|
|
duration: const Duration(seconds: 2),
|
|
vsync: this,
|
|
)..repeat(reverse: true);
|
|
}
|
|
|
|
List<Offset> _generateIconPositions(Size screenSize) {
|
|
final random = math.Random();
|
|
const iconSize = 40.0;
|
|
const maxScale = 1.5;
|
|
const margin = iconSize * maxScale;
|
|
const overlapThreshold = 0.3;
|
|
final effectiveSize = iconSize * maxScale;
|
|
final center = Offset(screenSize.width / 2, screenSize.height / 2);
|
|
final centerSafeRadius =
|
|
math.min(screenSize.width, screenSize.height) * 0.18;
|
|
|
|
Offset randomPosition() {
|
|
final x =
|
|
margin +
|
|
random.nextDouble() * (screenSize.width - margin * 2).clamp(1, 9999);
|
|
final y =
|
|
margin +
|
|
random.nextDouble() * (screenSize.height - margin * 2).clamp(1, 9999);
|
|
return Offset(x, y);
|
|
}
|
|
|
|
final positions = <Offset>[];
|
|
var attempts = 0;
|
|
const maxAttempts = 500;
|
|
|
|
while (positions.length < foodIcons.length && attempts < maxAttempts) {
|
|
attempts++;
|
|
final candidate = randomPosition();
|
|
if ((candidate - center).distance < centerSafeRadius) {
|
|
continue;
|
|
}
|
|
final hasHeavyOverlap = positions.any(
|
|
(p) => _isOverlapTooHigh(p, candidate, effectiveSize, overlapThreshold),
|
|
);
|
|
if (hasHeavyOverlap) {
|
|
continue;
|
|
}
|
|
positions.add(candidate);
|
|
}
|
|
|
|
while (positions.length < foodIcons.length) {
|
|
positions.add(randomPosition());
|
|
}
|
|
|
|
return positions;
|
|
}
|
|
|
|
bool _isOverlapTooHigh(Offset a, Offset b, double size, double maxRatio) {
|
|
final dx = (a.dx - b.dx).abs();
|
|
final dy = (a.dy - b.dy).abs();
|
|
final overlapX = math.max(0.0, size - dx);
|
|
final overlapY = math.max(0.0, size - dy);
|
|
final overlapArea = overlapX * overlapY;
|
|
final maxArea = size * size;
|
|
if (maxArea == 0) return false;
|
|
return overlapArea / maxArea > maxRatio;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Scaffold(
|
|
backgroundColor: isDark
|
|
? AppColors.darkBackground
|
|
: AppColors.lightBackground,
|
|
body: Stack(
|
|
children: [
|
|
// 랜덤 위치 음식 아이콘들
|
|
..._buildFoodIcons(),
|
|
|
|
// 중앙 컨텐츠
|
|
Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// 선택 아이콘
|
|
ScaleTransition(
|
|
scale: Tween(begin: 0.9, end: 1.1).animate(
|
|
CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.easeInOut,
|
|
),
|
|
),
|
|
child: Icon(
|
|
Icons.restaurant_menu,
|
|
size: 80,
|
|
color: isDark
|
|
? AppColors.darkPrimary
|
|
: AppColors.lightPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// 앱 타이틀
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text('오늘 뭐 먹Z', style: AppTypography.heading1(isDark)),
|
|
AnimatedBuilder(
|
|
animation: _animationController,
|
|
builder: (context, child) {
|
|
final questionMarks =
|
|
'?' *
|
|
(((_animationController.value * 3).floor() % 3) +
|
|
1);
|
|
return Text(
|
|
questionMarks,
|
|
style: AppTypography.heading1(isDark),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 하단 카피라이트
|
|
Positioned(
|
|
bottom: 30,
|
|
left: 0,
|
|
right: 0,
|
|
child: Text(
|
|
AppConstants.appCopyright,
|
|
style: AppTypography.caption(isDark).copyWith(
|
|
color:
|
|
(isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.lightTextSecondary)
|
|
.withOpacity(0.5),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildFoodIcons() {
|
|
final screenSize = MediaQuery.of(context).size;
|
|
final sameSize =
|
|
_lastScreenSize != null &&
|
|
(_lastScreenSize!.width - screenSize.width).abs() < 1 &&
|
|
(_lastScreenSize!.height - screenSize.height).abs() < 1;
|
|
if (_iconPositions == null || !sameSize) {
|
|
_iconPositions = _generateIconPositions(screenSize);
|
|
_lastScreenSize = screenSize;
|
|
}
|
|
|
|
return List.generate(foodIcons.length, (index) {
|
|
final position = _iconPositions![index];
|
|
// 각 아이콘마다 위상(phase)을 다르게 적용
|
|
final phase = index / foodIcons.length;
|
|
|
|
return Positioned(
|
|
left: position.dx,
|
|
top: position.dy,
|
|
child: AnimatedBuilder(
|
|
animation: _animationController,
|
|
builder: (context, child) {
|
|
// 위상 차이로 각 아이콘이 다른 타이밍에 애니메이션
|
|
final value =
|
|
((_animationController.value + phase) % 1.0 - 0.5).abs() * 2;
|
|
return Opacity(
|
|
opacity: 0.2 + value * 0.4,
|
|
child: Transform.scale(
|
|
scale: 0.7 + value * 0.5,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: Icon(
|
|
foodIcons[index],
|
|
size: 40,
|
|
color: AppColors.lightPrimary.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
void _navigateToHome() {
|
|
// 권한 요청이 지연되어도 스플래시(Splash) 화면이 멈추지 않도록 최대 3초만 대기한다.
|
|
final permissionFuture = _ensurePermissions().timeout(
|
|
const Duration(seconds: 3),
|
|
onTimeout: () {},
|
|
);
|
|
|
|
Future.wait([
|
|
permissionFuture,
|
|
Future.delayed(AppConstants.splashAnimationDuration),
|
|
]).whenComplete(() {
|
|
if (!mounted) return;
|
|
context.go('/home');
|
|
});
|
|
}
|
|
|
|
Future<void> _ensurePermissions() async {
|
|
try {
|
|
await Permission.notification.request();
|
|
await Permission.location.request();
|
|
await PermissionService.checkAndRequestBluetoothPermission();
|
|
} catch (_) {
|
|
// 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다.
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|