Files
lunchpick/lib/presentation/pages/random_selection/random_selection_screen.dart

836 lines
28 KiB
Dart

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/native_ad_placeholder.dart';
import 'widgets/recommendation_result_dialog.dart';
class RandomSelectionScreen extends ConsumerStatefulWidget {
const RandomSelectionScreen({super.key});
@override
ConsumerState<RandomSelectionScreen> createState() =>
_RandomSelectionScreenState();
}
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
double _distanceValue = 500;
final List<String> _selectedCategories = [];
final List<String> _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);
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: 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 <String>[],
);
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<String> 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<String> 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<String> 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<String> availableCategories) {
if (availableCategories.isEmpty) return;
final shouldSelectAll = !_isAllCategoriesSelected(availableCategories);
setState(() {
_hasUserAdjustedCategories = true;
_selectedCategories
..clear()
..addAll(shouldSelectAll ? availableCategories : <String>[]);
});
}
bool _isAllCategoriesSelected(List<String> 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<Restaurant> restaurants,
Position location,
double maxDistance,
List<String> 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 <String>[]);
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<void> _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<Restaurant?> _generateRecommendationCandidate({
List<String> excludedRestaurantIds = const [],
}) async {
final notifier = ref.read(recommendationNotifierProvider.notifier);
final categories = ref
.read(categoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
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<void> _showRecommendationDialog(
Restaurant restaurant, {
DateTime? recommendedAt,
}) async {
final location = ref
.read(currentLocationWithFallbackProvider)
.maybeWhen(data: (pos) => pos, orElse: () => null);
final result = await showDialog<RecommendationDialogResult>(
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<void> _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<String> 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<String> _effectiveSelectedCategories(List<String> 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 }