849 lines
29 KiB
Dart
849 lines
29 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/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);
|
|
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 <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 }
|