Files
lunchpick/lib/presentation/pages/splash/splash_screen.dart
2025-12-03 18:55:49 +09:00

280 lines
8.1 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 TickerProviderStateMixin {
late List<AnimationController> _foodControllers;
late AnimationController _questionMarkController;
late AnimationController _centerIconController;
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() {
// 음식 아이콘 애니메이션 (여러 개)
_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<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.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<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];
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() {
Future.wait([
_ensurePermissions(),
Future.delayed(AppConstants.splashAnimationDuration),
]).then((_) {
if (mounted) {
context.go('/home');
}
});
}
Future<void> _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();
}
}