## 성능 최적화 ### main.dart - 앱 초기화 병렬 처리 (Future.wait 활용) - 광고 SDK, Hive 초기화 동시 실행 - Hive Box 오픈 병렬 처리 - 코드 구조화 (_initializeHive, _initializeNotifications) ### visit_provider.dart - allLastVisitDatesProvider 추가 - 리스트 화면에서 N+1 쿼리 방지 - 모든 맛집의 마지막 방문일 일괄 조회 ## UI 개선 ### 각 화면 리팩토링 - AppDimensions 상수 적용 - 스켈레톤 로더 적용 - 코드 정리 및 일관성 개선
969 lines
33 KiB
Dart
969 lines
33 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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_dimensions.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/settings_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;
|
|
final locationAsync = ref.watch(currentLocationWithFallbackProvider);
|
|
final restaurantsAsync = ref.watch(restaurantListProvider);
|
|
final categoriesAsync = ref.watch(categoriesProvider);
|
|
final screenshotModeEnabled = ref
|
|
.watch(screenshotModeEnabledProvider)
|
|
.maybeWhen(data: (value) => value, orElse: () => false);
|
|
final readiness = _evaluateRecommendationReadiness(
|
|
locationAsync: locationAsync,
|
|
restaurantsAsync: restaurantsAsync,
|
|
categoriesAsync: categoriesAsync,
|
|
screenshotModeEnabled: screenshotModeEnabled,
|
|
);
|
|
|
|
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: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.cloud_off,
|
|
color: AppColors.lightWarning,
|
|
size: 28,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'날씨 정보를 불러오지 못했습니다.',
|
|
style: AppTypography.body2(isDark),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'네트워크/위치 권한을 확인한 뒤 다시 시도해 주세요.',
|
|
style: AppTypography.caption(isDark),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextButton.icon(
|
|
onPressed: () => ref.refresh(weatherProvider),
|
|
icon: const Icon(Icons.refresh),
|
|
label: 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),
|
|
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: AppDimensions.maxSearchDistance,
|
|
divisions: AppDimensions.distanceSliderDivisions,
|
|
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 && readiness.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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (readiness.message != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
readiness.icon,
|
|
size: 18,
|
|
color: _readinessColor(readiness, isDark),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
readiness.message!,
|
|
style: AppTypography.caption(
|
|
isDark,
|
|
).copyWith(color: _readinessColor(readiness, isDark)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
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 _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;
|
|
}
|
|
|
|
_RecommendationReadiness _evaluateRecommendationReadiness({
|
|
required AsyncValue<Position> locationAsync,
|
|
required AsyncValue<List<Restaurant>> restaurantsAsync,
|
|
required AsyncValue<List<String>> categoriesAsync,
|
|
bool screenshotModeEnabled = false,
|
|
}) {
|
|
if (screenshotModeEnabled) {
|
|
return const _RecommendationReadiness(canRecommend: true);
|
|
}
|
|
|
|
if (locationAsync.isLoading) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '위치 정보를 불러오는 중입니다. 잠시만 기다려 주세요.',
|
|
icon: Icons.location_searching,
|
|
isWarning: true,
|
|
);
|
|
}
|
|
|
|
if (locationAsync.hasError) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '위치 권한 또는 설정을 확인해 주세요.',
|
|
icon: Icons.gps_off,
|
|
isError: true,
|
|
);
|
|
}
|
|
|
|
final location = locationAsync.value;
|
|
if (location == null) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '위치 정보를 확인할 수 없습니다.',
|
|
icon: Icons.location_disabled,
|
|
isError: true,
|
|
);
|
|
}
|
|
|
|
if (restaurantsAsync.isLoading) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '맛집 목록을 불러오는 중입니다.',
|
|
icon: Icons.restaurant_menu,
|
|
isWarning: true,
|
|
);
|
|
}
|
|
|
|
final restaurants = restaurantsAsync.value;
|
|
if (restaurants == null || restaurants.isEmpty) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '등록된 맛집이 없습니다. 맛집을 추가해 주세요.',
|
|
icon: Icons.playlist_remove,
|
|
isError: true,
|
|
);
|
|
}
|
|
|
|
if (categoriesAsync.isLoading) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '카테고리를 불러오는 중입니다.',
|
|
icon: Icons.category_outlined,
|
|
isWarning: true,
|
|
);
|
|
}
|
|
|
|
final categories = categoriesAsync.value ?? const <String>[];
|
|
final count = _getRestaurantCountInRange(
|
|
restaurants,
|
|
location,
|
|
_distanceValue,
|
|
_effectiveSelectedCategories(categories),
|
|
);
|
|
if (count == 0) {
|
|
return const _RecommendationReadiness(
|
|
canRecommend: false,
|
|
message: '선택한 거리/카테고리에 해당하는 맛집이 없습니다. 범위를 넓혀 보세요.',
|
|
icon: Icons.travel_explore,
|
|
isWarning: true,
|
|
);
|
|
}
|
|
|
|
final usesFallback = _isFallbackPosition(location);
|
|
return _RecommendationReadiness(
|
|
canRecommend: true,
|
|
message: usesFallback
|
|
? '기본 위치(서울 시청) 기준으로 추천 중입니다. 위치 권한을 허용하면 더 정확해요.'
|
|
: null,
|
|
icon: usesFallback ? Icons.gps_not_fixed : Icons.check_circle,
|
|
isWarning: usesFallback,
|
|
);
|
|
}
|
|
|
|
bool _isFallbackPosition(Position position) {
|
|
final fallback = defaultPosition();
|
|
return position.latitude == fallback.latitude &&
|
|
position.longitude == fallback.longitude;
|
|
}
|
|
|
|
Color _readinessColor(_RecommendationReadiness readiness, bool isDark) {
|
|
if (readiness.isError) {
|
|
return isDark ? AppColors.darkError : AppColors.lightError;
|
|
}
|
|
if (readiness.isWarning) {
|
|
return isDark ? AppColors.darkWarning : AppColors.lightWarning;
|
|
}
|
|
return isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary;
|
|
}
|
|
|
|
Future<void> _startRecommendation({
|
|
bool skipAd = false,
|
|
bool isReroll = false,
|
|
}) async {
|
|
if (_isProcessingRecommendation) return;
|
|
|
|
// 버튼 터치 햅틱 피드백
|
|
HapticFeedback.mediumImpact();
|
|
|
|
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;
|
|
|
|
// 추천 결과 햅틱 피드백
|
|
HapticFeedback.heavyImpact();
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
class _RecommendationReadiness {
|
|
final bool canRecommend;
|
|
final String? message;
|
|
final IconData icon;
|
|
final bool isWarning;
|
|
final bool isError;
|
|
|
|
const _RecommendationReadiness({
|
|
required this.canRecommend,
|
|
this.message,
|
|
this.icon = Icons.info_outline,
|
|
this.isWarning = false,
|
|
this.isError = false,
|
|
});
|
|
}
|
|
|
|
enum _SnackType { info, warning, error, success }
|