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 '../../../core/utils/category_mapper.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: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: AppColors.lightPrimary.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), child: const Icon( Icons.restaurant, size: 20, color: AppColors.lightPrimary, ), ), const SizedBox(width: 10), Expanded( child: Consumer( builder: (context, ref, child) { final restaurantsAsync = ref.watch( restaurantListProvider, ); return restaurantsAsync.when( data: (restaurants) => Text( '등록된 맛집 ${restaurants.length}개', style: AppTypography.heading2( isDark, ).copyWith(fontSize: 18), ), loading: () => const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.lightPrimary, ), ), error: (_, __) => Text( '등록된 맛집 0개', style: AppTypography.heading2( isDark, ).copyWith(fontSize: 18), ), ); }, ), ), ], ), ), ), const SizedBox(height: 12), // 날씨 정보 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 14, ), child: Consumer( builder: (context, ref, child) { final weatherAsync = ref.watch(weatherProvider); const double sectionHeight = 112; return weatherAsync.when( data: (weather) => SizedBox( height: sectionHeight, child: 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 SizedBox( height: sectionHeight, child: Center( child: CircularProgressIndicator( color: AppColors.lightPrimary, ), ), ), error: (_, __) => SizedBox( height: sectionHeight, child: 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: 12), // 카테고리 선택 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 14, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('카테고리', style: AppTypography.heading2(isDark)), const SizedBox(height: 10), Consumer( builder: (context, ref, child) { final categoriesAsync = ref.watch(categoriesProvider); return categoriesAsync.when( data: (categories) { _ensureDefaultCategorySelection(categories); final selectedCategories = _effectiveSelectedCategories(categories); final isAllSelected = _isAllCategoriesSelected( categories, ); final categoryChips = categories .map( (category) => _buildCategoryChip( category, isDark, selectedCategories.contains(category), categories, ), ) .toList(); return Wrap( spacing: 8, runSpacing: 10, children: categories.isEmpty ? [const Text('카테고리 없음')] : [ _buildAllCategoryChip( isDark, isAllSelected, categories, ), ...categoryChips, ], ); }, loading: () => const CircularProgressIndicator(), error: (_, __) => const Text('카테고리를 불러올 수 없습니다'), ); }, ), ], ), ), ), const SizedBox(height: 12), // 거리 설정 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 14, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('최대 거리', style: AppTypography.heading2(isDark)), const SizedBox(height: 10), 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: 6), 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: 16), // 추천받기 버튼 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, ), ), ], ), ), const SizedBox(height: 16), // const NativeAdPlaceholder( // margin: EdgeInsets.symmetric(vertical: 8), // ), ], ), ), ); } 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 _buildAllCategoryChip( bool isDark, bool isSelected, List availableCategories, ) { const icon = Icons.restaurant_menu; return FilterChip( label: const Text('전체'), selected: isSelected, onSelected: (_) => _handleToggleAllCategories(availableCategories), avatar: _buildChipIcon( isSelected: isSelected, icon: icon, color: AppColors.lightPrimary, ), backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground, selectedColor: AppColors.lightPrimary.withValues(alpha: 0.2), checkmarkColor: AppColors.lightPrimary, showCheckmark: false, labelStyle: TextStyle( color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary), fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, ), side: BorderSide( color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkDivider : AppColors.lightDivider), ), ); } Widget _buildCategoryChip( String category, bool isDark, bool isSelected, List availableCategories, ) { final icon = CategoryMapper.getIcon(category); final color = CategoryMapper.getColor(category); final activeColor = AppColors.lightPrimary; final accentColor = isSelected ? activeColor : color; final displayName = CategoryMapper.getDisplayName(category); return FilterChip( label: Text(displayName), selected: isSelected, onSelected: (selected) => _handleCategoryToggle(category, selected, availableCategories), avatar: _buildChipIcon( isSelected: isSelected, icon: icon, color: accentColor, ), backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground, selectedColor: activeColor.withValues(alpha: 0.2), checkmarkColor: accentColor, showCheckmark: false, labelStyle: TextStyle( color: isSelected ? accentColor : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary), fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, ), side: BorderSide( color: isSelected ? accentColor : (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 { nextSelection.remove(category); } setState(() { _hasUserAdjustedCategories = true; _selectedCategories ..clear() ..addAll(nextSelection); }); } void _handleToggleAllCategories(List availableCategories) { if (availableCategories.isEmpty) return; final shouldSelectAll = !_isAllCategoriesSelected(availableCategories); setState(() { _hasUserAdjustedCategories = true; _selectedCategories ..clear() ..addAll(shouldSelectAll ? availableCategories : []); }); } bool _isAllCategoriesSelected(List availableCategories) { if (availableCategories.isEmpty) return false; if (!_hasUserAdjustedCategories) return true; final availableSet = availableCategories.toSet(); final selectedSet = _selectedCategories.toSet(); return selectedSet.isNotEmpty && availableSet.difference(selectedSet).isEmpty; } Widget _buildChipIcon({ required bool isSelected, required IconData icon, required Color color, }) { final displayedIcon = isSelected ? Icons.check : icon; return SizedBox( width: 20, height: 20, child: Icon(displayedIcon, size: 18, color: color), ); } 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) { final availableSet = availableCategories.toSet(); final filtered = _selectedCategories .where((category) => availableSet.contains(category)) .toList(); if (!_hasUserAdjustedCategories) { return availableCategories; } return filtered; } } enum _SnackType { info, warning, error, success }