import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; import '../../../domain/entities/restaurant.dart'; import '../../../domain/entities/weather_info.dart'; import '../../providers/ad_provider.dart'; import '../../providers/location_provider.dart'; import '../../providers/recommendation_provider.dart'; import '../../providers/restaurant_provider.dart'; import '../../providers/weather_provider.dart'; import 'widgets/recommendation_result_dialog.dart'; class RandomSelectionScreen extends ConsumerStatefulWidget { const RandomSelectionScreen({super.key}); @override ConsumerState createState() => _RandomSelectionScreenState(); } class _RandomSelectionScreenState extends ConsumerState { double _distanceValue = 500; final List _selectedCategories = []; final List _excludedRestaurantIds = []; bool _isProcessingRecommendation = false; bool _hasUserAdjustedCategories = false; @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground, appBar: AppBar( title: const Text('오늘 뭐 먹Z?'), backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary, foregroundColor: Colors.white, elevation: 0, ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 맛집 리스트 현황 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( children: [ const Icon( Icons.restaurant, size: 48, color: AppColors.lightPrimary, ), const SizedBox(height: 12), Consumer( builder: (context, ref, child) { final restaurantsAsync = ref.watch( restaurantListProvider, ); return restaurantsAsync.when( data: (restaurants) => Text( '${restaurants.length}개', style: AppTypography.heading1( isDark, ).copyWith(color: AppColors.lightPrimary), ), loading: () => const CircularProgressIndicator( color: AppColors.lightPrimary, ), error: (_, __) => Text( '0개', style: AppTypography.heading1( isDark, ).copyWith(color: AppColors.lightPrimary), ), ); }, ), Text('등록된 맛집', style: AppTypography.body2(isDark)), ], ), ), ), const SizedBox(height: 16), // 날씨 정보 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(16), child: Consumer( builder: (context, ref, child) { final weatherAsync = ref.watch(weatherProvider); return weatherAsync.when( data: (weather) => Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildWeatherData('지금', weather.current, isDark), Container( width: 1, height: 50, color: isDark ? AppColors.darkDivider : AppColors.lightDivider, ), _buildWeatherData('1시간 후', weather.nextHour, isDark), ], ), loading: () => const Center( child: CircularProgressIndicator( color: AppColors.lightPrimary, ), ), error: (_, __) => Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildWeatherInfo( '지금', Icons.wb_sunny, '맑음', 20, isDark, ), Container( width: 1, height: 50, color: isDark ? AppColors.darkDivider : AppColors.lightDivider, ), _buildWeatherInfo( '1시간 후', Icons.wb_sunny, '맑음', 22, isDark, ), ], ), ); }, ), ), ), const SizedBox(height: 16), // 카테고리 선택 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('카테고리', style: AppTypography.heading2(isDark)), const SizedBox(height: 12), Consumer( builder: (context, ref, child) { final categoriesAsync = ref.watch(categoriesProvider); return categoriesAsync.when( data: (categories) { _ensureDefaultCategorySelection(categories); final selectedCategories = _effectiveSelectedCategories(categories); return Wrap( spacing: 8, runSpacing: 8, children: categories.isEmpty ? [const Text('카테고리 없음')] : categories .map( (category) => _buildCategoryChip( category, isDark, selectedCategories.contains( category, ), categories, ), ) .toList(), ); }, loading: () => const CircularProgressIndicator(), error: (_, __) => const Text('카테고리를 불러올 수 없습니다'), ); }, ), ], ), ), ), const SizedBox(height: 16), // 거리 설정 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('최대 거리', style: AppTypography.heading2(isDark)), const SizedBox(height: 12), Row( children: [ Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( activeTrackColor: AppColors.lightPrimary, inactiveTrackColor: AppColors.lightPrimary .withValues(alpha: 0.3), thumbColor: AppColors.lightPrimary, trackHeight: 4, ), child: Slider( value: _distanceValue, min: 100, max: 2000, divisions: 19, onChanged: (value) { setState(() => _distanceValue = value); }, ), ), ), const SizedBox(width: 12), Text( '${_distanceValue.toInt()}m', style: AppTypography.body1( isDark, ).copyWith(fontWeight: FontWeight.bold), ), ], ), const SizedBox(height: 8), Consumer( builder: (context, ref, child) { final locationAsync = ref.watch( currentLocationWithFallbackProvider, ); final restaurantsAsync = ref.watch( restaurantListProvider, ); final categoriesAsync = ref.watch(categoriesProvider); final location = locationAsync.maybeWhen( data: (pos) => pos, orElse: () => null, ); final restaurants = restaurantsAsync.maybeWhen( data: (list) => list, orElse: () => null, ); final categories = categoriesAsync.maybeWhen( data: (list) => list, orElse: () => const [], ); if (location != null && restaurants != null) { final count = _getRestaurantCountInRange( restaurants, location, _distanceValue, _effectiveSelectedCategories(categories), ); return Text( '$count개 맛집 포함', style: AppTypography.caption(isDark), ); } return Text( '위치 정보를 가져오는 중...', style: AppTypography.caption(isDark), ); }, ), ], ), ), ), const SizedBox(height: 24), // 추천받기 버튼 ElevatedButton( onPressed: !_isProcessingRecommendation && _canRecommend() ? () => _startRecommendation() : null, style: ElevatedButton.styleFrom( backgroundColor: AppColors.lightPrimary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 20), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), elevation: 3, ), child: _isProcessingRecommendation ? const SizedBox( height: 24, width: 24, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.white, ), ) : const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.play_arrow, size: 28), SizedBox(width: 8), Text( '광고보고 추천받기', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), ], ), ), ], ), ), ); } Widget _buildWeatherData(String label, WeatherData weatherData, bool isDark) { return Column( children: [ Text(label, style: AppTypography.caption(isDark)), const SizedBox(height: 8), Icon( weatherData.isRainy ? Icons.umbrella : Icons.wb_sunny, color: weatherData.isRainy ? Colors.blue : Colors.orange, size: 32, ), const SizedBox(height: 4), Text( '${weatherData.temperature}°C', style: AppTypography.body1( isDark, ).copyWith(fontWeight: FontWeight.bold), ), Text(weatherData.description, style: AppTypography.caption(isDark)), ], ); } Widget _buildWeatherInfo( String label, IconData icon, String description, int temperature, bool isDark, ) { return Column( children: [ Text(label, style: AppTypography.caption(isDark)), const SizedBox(height: 8), Icon(icon, color: Colors.orange, size: 32), const SizedBox(height: 4), Text( '$temperature°C', style: AppTypography.body1( isDark, ).copyWith(fontWeight: FontWeight.bold), ), Text(description, style: AppTypography.caption(isDark)), ], ); } Widget _buildCategoryChip( String category, bool isDark, bool isSelected, List availableCategories, ) { return FilterChip( label: Text(category), selected: isSelected, onSelected: (selected) => _handleCategoryToggle(category, selected, availableCategories), backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground, selectedColor: AppColors.lightPrimary.withValues(alpha: 0.2), checkmarkColor: AppColors.lightPrimary, labelStyle: TextStyle( color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary), ), side: BorderSide( color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkDivider : AppColors.lightDivider), ), ); } void _handleCategoryToggle( String category, bool isSelected, List availableCategories, ) { final currentSelection = _effectiveSelectedCategories(availableCategories); final nextSelection = currentSelection.toSet(); if (isSelected) { nextSelection.add(category); } else { if (nextSelection.length <= 1 && nextSelection.contains(category)) { return; } nextSelection.remove(category); } setState(() { _hasUserAdjustedCategories = true; _selectedCategories ..clear() ..addAll(nextSelection); }); } int _getRestaurantCountInRange( List restaurants, Position location, double maxDistance, List selectedCategories, ) { return restaurants.where((restaurant) { final distance = Geolocator.distanceBetween( location.latitude, location.longitude, restaurant.latitude, restaurant.longitude, ); final isWithinDistance = distance <= maxDistance; final matchesCategory = selectedCategories.isEmpty ? true : selectedCategories.contains(restaurant.category); return isWithinDistance && matchesCategory; }).length; } bool _canRecommend() { final locationAsync = ref.read(currentLocationWithFallbackProvider); final restaurantsAsync = ref.read(restaurantListProvider); final categories = ref .read(categoriesProvider) .maybeWhen(data: (list) => list, orElse: () => const []); final location = locationAsync.maybeWhen( data: (pos) => pos, orElse: () => null, ); final restaurants = restaurantsAsync.maybeWhen( data: (list) => list, orElse: () => null, ); if (location == null || restaurants == null || restaurants.isEmpty) { return false; } final count = _getRestaurantCountInRange( restaurants, location, _distanceValue, _effectiveSelectedCategories(categories), ); return count > 0; } Future _startRecommendation({ bool skipAd = false, bool isReroll = false, }) async { if (_isProcessingRecommendation) return; if (!isReroll) { _excludedRestaurantIds.clear(); } setState(() { _isProcessingRecommendation = true; }); try { final candidate = await _generateRecommendationCandidate( excludedRestaurantIds: _excludedRestaurantIds, ); if (candidate == null) { return; } final recommendedAt = DateTime.now(); if (!skipAd) { final adService = ref.read(adServiceProvider); // Ad dialog 자체가 비동기 동작을 포함하므로 사용 후 mounted 체크를 수행한다. // ignore: use_build_context_synchronously final adWatched = await adService.showInterstitialAd(context); if (!mounted) return; if (!adWatched) { _showSnack('광고를 끝까지 시청해야 추천을 받을 수 있어요.', type: _SnackType.error); return; } } if (!mounted) return; await _showRecommendationDialog(candidate, recommendedAt: recommendedAt); } catch (_) { _showSnack('추천을 준비하는 중 문제가 발생했습니다.', type: _SnackType.error); } finally { if (mounted) { setState(() { _isProcessingRecommendation = false; }); } } } Future _generateRecommendationCandidate({ List excludedRestaurantIds = const [], }) async { final notifier = ref.read(recommendationNotifierProvider.notifier); final categories = ref .read(categoriesProvider) .maybeWhen(data: (list) => list, orElse: () => const []); final recommendation = await notifier.getRandomRecommendation( maxDistance: _distanceValue, selectedCategories: _effectiveSelectedCategories(categories), excludedRestaurantIds: excludedRestaurantIds, shouldSaveRecord: false, ); final result = ref.read(recommendationNotifierProvider); if (result.hasError) { final message = result.error?.toString() ?? '알 수 없는 오류'; _showSnack('추천 중 오류가 발생했습니다: $message', type: _SnackType.error); return null; } if (recommendation == null) { _showSnack( '조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.', type: _SnackType.warning, ); } return recommendation; } Future _showRecommendationDialog( Restaurant restaurant, { DateTime? recommendedAt, }) async { final location = ref .read(currentLocationWithFallbackProvider) .maybeWhen(data: (pos) => pos, orElse: () => null); final result = await showDialog( context: context, barrierDismissible: false, builder: (dialogContext) => RecommendationResultDialog( restaurant: restaurant, currentLatitude: location?.latitude, currentLongitude: location?.longitude, ), ); if (!mounted) return; switch (result) { case RecommendationDialogResult.reroll: setState(() { _excludedRestaurantIds.add(restaurant.id); }); await _startRecommendation(skipAd: false, isReroll: true); break; case RecommendationDialogResult.confirm: case RecommendationDialogResult.autoConfirm: default: await _handleRecommendationAccepted( restaurant, recommendedAt ?? DateTime.now(), ); break; } } Future _handleRecommendationAccepted( Restaurant restaurant, DateTime recommendationTime, ) async { try { final recommendationNotifier = ref.read( recommendationNotifierProvider.notifier, ); await recommendationNotifier.saveRecommendationRecord( restaurant, recommendationTime: recommendationTime, visited: false, ); _showSnack('맛있게 드세요! 🍴', type: _SnackType.success); if (mounted) { setState(() { _excludedRestaurantIds.clear(); }); } } catch (_) { _showSnack('방문 기록 또는 알림 예약에 실패했습니다.', type: _SnackType.error); } if (!mounted) return; final ts = DateTime.now().millisecondsSinceEpoch; context.go('/home?tab=calendar&ts=$ts'); } void _showSnack(String message, {_SnackType type = _SnackType.info}) { if (!mounted) return; final bgColor = switch (type) { _SnackType.success => Colors.teal.shade600, _SnackType.warning => Colors.orange.shade600, _SnackType.error => AppColors.lightError, _SnackType.info => Colors.blueGrey.shade600, }; final topInset = MediaQuery.of(context).viewPadding.top; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text(message), backgroundColor: bgColor, behavior: SnackBarBehavior.floating, margin: EdgeInsets.fromLTRB( 16, (topInset > 0 ? topInset : 16) + 8, 16, 0, ), duration: const Duration(seconds: 3), ), ); } void _ensureDefaultCategorySelection(List categories) { if (_hasUserAdjustedCategories || categories.isEmpty) return; final current = _selectedCategories.toSet(); final incoming = categories.toSet(); if (current.length == incoming.length && current.containsAll(incoming)) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() { _selectedCategories ..clear() ..addAll(categories); }); }); } List _effectiveSelectedCategories(List availableCategories) { if (_selectedCategories.isEmpty && !_hasUserAdjustedCategories) { return availableCategories; } final availableSet = availableCategories.toSet(); final filtered = _selectedCategories .where((category) => availableSet.contains(category)) .toList(); if (filtered.isEmpty) { return availableCategories; } return filtered; } } enum _SnackType { info, warning, error, success }