Files
lunchpick/lib/presentation/pages/random_selection/random_selection_screen.dart
2025-11-19 16:36:39 +09:00

598 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.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/notification_provider.dart';
import '../../providers/recommendation_provider.dart';
import '../../providers/restaurant_provider.dart';
import '../../providers/settings_provider.dart'
show notificationDelayMinutesProvider, notificationEnabledProvider;
import '../../providers/visit_provider.dart';
import '../../providers/weather_provider.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 = [];
bool _isProcessingRecommendation = 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),
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(
currentLocationProvider,
);
final restaurantsAsync = ref.watch(
restaurantListProvider,
);
if (locationAsync.hasValue &&
restaurantsAsync.hasValue) {
final location = locationAsync.value;
final restaurants = restaurantsAsync.value;
if (location != null && restaurants != null) {
final count = _getRestaurantCountInRange(
restaurants,
location,
_distanceValue,
);
return Text(
'$count개 맛집 포함',
style: AppTypography.caption(isDark),
);
}
}
return Text(
'위치 정보를 가져오는 중...',
style: AppTypography.caption(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) => Wrap(
spacing: 8,
runSpacing: 8,
children: categories.isEmpty
? [const Text('카테고리 없음')]
: categories
.map(
(category) => _buildCategoryChip(
category,
isDark,
),
)
.toList(),
),
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
);
},
),
],
),
),
),
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) {
final isSelected = _selectedCategories.contains(category);
return FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedCategories.add(category);
} else {
_selectedCategories.remove(category);
}
});
},
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),
),
);
}
int _getRestaurantCountInRange(
List<Restaurant> restaurants,
Position location,
double maxDistance,
) {
return restaurants.where((restaurant) {
final distance = Geolocator.distanceBetween(
location.latitude,
location.longitude,
restaurant.latitude,
restaurant.longitude,
);
return distance <= maxDistance;
}).length;
}
bool _canRecommend() {
final locationAsync = ref.read(currentLocationProvider);
final restaurantsAsync = ref.read(restaurantListProvider);
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) {
return false;
}
final location = locationAsync.value;
final restaurants = restaurantsAsync.value;
if (location == null || restaurants == null || restaurants.isEmpty) {
return false;
}
final count = _getRestaurantCountInRange(
restaurants,
location,
_distanceValue,
);
return count > 0;
}
Future<void> _startRecommendation({bool skipAd = false}) async {
if (_isProcessingRecommendation) return;
setState(() {
_isProcessingRecommendation = true;
});
try {
final candidate = await _generateRecommendationCandidate();
if (candidate == null) {
return;
}
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(
'광고를 끝까지 시청해야 추천을 받을 수 있어요.',
backgroundColor: AppColors.lightError,
);
return;
}
}
if (!mounted) return;
_showRecommendationDialog(candidate);
} catch (_) {
_showSnack(
'추천을 준비하는 중 문제가 발생했습니다.',
backgroundColor: AppColors.lightError,
);
} finally {
if (mounted) {
setState(() {
_isProcessingRecommendation = false;
});
}
}
}
Future<Restaurant?> _generateRecommendationCandidate() async {
final notifier = ref.read(recommendationNotifierProvider.notifier);
await notifier.getRandomRecommendation(
maxDistance: _distanceValue,
selectedCategories: _selectedCategories,
);
final result = ref.read(recommendationNotifierProvider);
if (result.hasError) {
final message = result.error?.toString() ?? '알 수 없는 오류';
_showSnack(
'추천 중 오류가 발생했습니다: $message',
backgroundColor: AppColors.lightError,
);
return null;
}
final restaurant = result.asData?.value;
if (restaurant == null) {
_showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError);
}
return restaurant;
}
void _showRecommendationDialog(Restaurant restaurant) {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => RecommendationResultDialog(
restaurant: restaurant,
onReroll: () async {
Navigator.pop(dialogContext);
await _startRecommendation(skipAd: true);
},
onClose: () async {
Navigator.pop(dialogContext);
await _handleRecommendationAccepted(restaurant);
},
),
);
}
Future<void> _handleRecommendationAccepted(Restaurant restaurant) async {
final recommendationTime = DateTime.now();
try {
final notificationEnabled = await ref.read(
notificationEnabledProvider.future,
);
if (notificationEnabled) {
final delayMinutes = await ref.read(
notificationDelayMinutesProvider.future,
);
final notificationService = ref.read(notificationServiceProvider);
await notificationService.scheduleVisitReminder(
restaurantId: restaurant.id,
restaurantName: restaurant.name,
recommendationTime: recommendationTime,
delayMinutes: delayMinutes,
);
}
await ref
.read(visitNotifierProvider.notifier)
.createVisitFromRecommendation(
restaurantId: restaurant.id,
recommendationTime: recommendationTime,
);
_showSnack('맛있게 드세요! 🍴');
} catch (_) {
_showSnack(
'방문 기록 또는 알림 예약에 실패했습니다.',
backgroundColor: AppColors.lightError,
);
}
}
void _showSnack(
String message, {
Color backgroundColor = AppColors.lightPrimary,
}) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), backgroundColor: backgroundColor),
);
}
}