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 createState() => _SplashScreenState(); } class _SplashScreenState extends State with TickerProviderStateMixin { late List _foodControllers; late AnimationController _questionMarkController; late AnimationController _centerIconController; List? _iconPositions; Size? _lastScreenSize; final List 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() { // 음식 아이콘 애니메이션 (여러 개) _foodControllers = List.generate( foodIcons.length, (index) => AnimationController( duration: Duration(seconds: 2 + index % 3), vsync: this, )..repeat(reverse: true), ); // 물음표 애니메이션 _questionMarkController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, )..repeat(); // 중앙 아이콘 애니메이션 _centerIconController = AnimationController( duration: const Duration(seconds: 1), vsync: this, )..repeat(reverse: true); } List _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 = []; 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.8, end: 1.2).animate( CurvedAnimation( parent: _centerIconController, 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: _questionMarkController, builder: (context, child) { final questionMarks = '?' * (((_questionMarkController.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 _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]; return Positioned( left: position.dx, top: position.dy, child: FadeTransition( opacity: Tween(begin: 0.2, end: 0.8).animate( CurvedAnimation( parent: _foodControllers[index], curve: Curves.easeInOut, ), ), child: ScaleTransition( scale: Tween(begin: 0.5, end: 1.5).animate( CurvedAnimation( parent: _foodControllers[index], curve: Curves.easeInOut, ), ), child: Icon( foodIcons[index], size: 40, color: AppColors.lightPrimary.withOpacity(0.3), ), ), ), ); }); } void _navigateToHome() { // 권한 요청이 지연되어도 스플래시(Splash) 화면이 멈추지 않도록 최대 5초만 대기한다. final permissionFuture = _ensurePermissions().timeout( const Duration(seconds: 5), onTimeout: () {}, ); Future.wait([ permissionFuture, Future.delayed(AppConstants.splashAnimationDuration), ]).whenComplete(() { if (!mounted) return; context.go('/home'); }); } Future _ensurePermissions() async { try { await Permission.notification.request(); await Permission.location.request(); await PermissionService.checkAndRequestBluetoothPermission(); } catch (_) { // 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다. } } @override void dispose() { for (final controller in _foodControllers) { controller.dispose(); } _questionMarkController.dispose(); _centerIconController.dispose(); super.dispose(); } }