feat(app): stabilize recommendation flow

This commit is contained in:
JiWoong Sul
2025-12-01 17:22:21 +09:00
parent d05e378569
commit c1aa16c521
12 changed files with 422 additions and 260 deletions

View File

@@ -39,6 +39,17 @@ class _MainScreenState extends ConsumerState<MainScreen> {
});
}
@override
void didUpdateWidget(covariant MainScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialTab != widget.initialTab &&
_selectedIndex != widget.initialTab) {
setState(() {
_selectedIndex = widget.initialTab;
});
}
}
@override
void dispose() {
NotificationService.onNotificationTap = null;

View File

@@ -1,7 +1,7 @@
import 'package:flutter/foundation.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';
@@ -9,12 +9,8 @@ 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';
@@ -31,6 +27,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
final List<String> _selectedCategories = [];
final List<String> _excludedRestaurantIds = [];
bool _isProcessingRecommendation = false;
bool _hasUserAdjustedCategories = false;
@override
Widget build(BuildContext context) {
@@ -168,6 +165,60 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
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) {
_ensureDefaultCategorySelection(categories);
final selectedCategories =
_effectiveSelectedCategories(categories);
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.isEmpty
? [const Text('카테고리 없음')]
: categories
.map(
(category) => _buildCategoryChip(
category,
isDark,
selectedCategories.contains(
category,
),
categories,
),
)
.toList(),
);
},
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
);
},
),
],
),
),
),
const SizedBox(height: 16),
// 거리 설정 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@@ -222,6 +273,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
final restaurantsAsync = ref.watch(
restaurantListProvider,
);
final categoriesAsync = ref.watch(categoriesProvider);
final location = locationAsync.maybeWhen(
data: (pos) => pos,
@@ -231,12 +283,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
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개 맛집 포함',
@@ -255,51 +312,6 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
),
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),
// 추천받기 버튼
@@ -392,21 +404,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
);
}
Widget _buildCategoryChip(String category, bool isDark) {
final isSelected = _selectedCategories.contains(category);
Widget _buildCategoryChip(
String category,
bool isDark,
bool isSelected,
List<String> availableCategories,
) {
return FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedCategories.add(category);
} else {
_selectedCategories.remove(category);
}
});
},
onSelected: (selected) =>
_handleCategoryToggle(category, selected, availableCategories),
backgroundColor: isDark
? AppColors.darkSurface
: AppColors.lightBackground,
@@ -425,10 +433,36 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
);
}
void _handleCategoryToggle(
String category,
bool isSelected,
List<String> availableCategories,
) {
final currentSelection = _effectiveSelectedCategories(availableCategories);
final nextSelection = currentSelection.toSet();
if (isSelected) {
nextSelection.add(category);
} else {
if (nextSelection.length <= 1 && nextSelection.contains(category)) {
return;
}
nextSelection.remove(category);
}
setState(() {
_hasUserAdjustedCategories = true;
_selectedCategories
..clear()
..addAll(nextSelection);
});
}
int _getRestaurantCountInRange(
List<Restaurant> restaurants,
Position location,
double maxDistance,
List<String> selectedCategories,
) {
return restaurants.where((restaurant) {
final distance = Geolocator.distanceBetween(
@@ -437,13 +471,20 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
restaurant.latitude,
restaurant.longitude,
);
return distance <= maxDistance;
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,
@@ -462,6 +503,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
restaurants,
location,
_distanceValue,
_effectiveSelectedCategories(categories),
);
return count > 0;
}
@@ -518,10 +560,13 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
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: _selectedCategories,
selectedCategories: _effectiveSelectedCategories(categories),
excludedRestaurantIds: excludedRestaurantIds,
shouldSaveRecord: false,
);
@@ -547,11 +592,18 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
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),
builder: (dialogContext) => RecommendationResultDialog(
restaurant: restaurant,
currentLatitude: location?.latitude,
currentLongitude: location?.longitude,
),
);
if (!mounted) return;
@@ -561,7 +613,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
setState(() {
_excludedRestaurantIds.add(restaurant.id);
});
await _startRecommendation(skipAd: true, isReroll: true);
await _startRecommendation(skipAd: false, isReroll: true);
break;
case RecommendationDialogResult.confirm:
case RecommendationDialogResult.autoConfirm:
@@ -585,48 +637,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
await recommendationNotifier.saveRecommendationRecord(
restaurant,
recommendationTime: recommendationTime,
visited: false,
);
final notificationEnabled = await ref.read(
notificationEnabledProvider.future,
);
bool notificationScheduled = false;
if (notificationEnabled && !kIsWeb) {
final notificationService = ref.read(notificationServiceProvider);
final notificationReady = await notificationService.ensureInitialized(
requestPermission: true,
);
if (notificationReady) {
final delayMinutes = await ref.read(
notificationDelayMinutesProvider.future,
);
await notificationService.scheduleVisitReminder(
restaurantId: restaurant.id,
restaurantName: restaurant.name,
recommendationTime: recommendationTime,
delayMinutes: delayMinutes,
);
notificationScheduled = await notificationService
.hasVisitReminderScheduled();
}
}
await ref
.read(visitNotifierProvider.notifier)
.createVisitFromRecommendation(
restaurantId: restaurant.id,
recommendationTime: recommendationTime,
);
if (notificationEnabled && !notificationScheduled && !kIsWeb) {
_showSnack(
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
type: _SnackType.warning,
);
} else {
_showSnack('맛있게 드세요! 🍴', type: _SnackType.success);
}
_showSnack('맛있게 드세요! 🍴', type: _SnackType.success);
if (mounted) {
setState(() {
_excludedRestaurantIds.clear();
@@ -635,6 +648,10 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
} 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}) {
@@ -663,6 +680,42 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
);
}
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) {
if (_selectedCategories.isEmpty && !_hasUserAdjustedCategories) {
return availableCategories;
}
final availableSet = availableCategories.toSet();
final filtered = _selectedCategories
.where((category) => availableSet.contains(category))
.toList();
if (filtered.isEmpty) {
return availableCategories;
}
return filtered;
}
}
enum _SnackType { info, warning, error, success }

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lunchpick/core/constants/app_colors.dart';
import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/core/utils/distance_calculator.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
enum RecommendationDialogResult { confirm, reroll, autoConfirm }
@@ -10,11 +11,15 @@ enum RecommendationDialogResult { confirm, reroll, autoConfirm }
class RecommendationResultDialog extends StatefulWidget {
final Restaurant restaurant;
final Duration autoConfirmDuration;
final double? currentLatitude;
final double? currentLongitude;
const RecommendationResultDialog({
super.key,
required this.restaurant,
this.autoConfirmDuration = const Duration(seconds: 12),
this.currentLatitude,
this.currentLongitude,
});
@override
@@ -26,10 +31,12 @@ class _RecommendationResultDialogState
extends State<RecommendationResultDialog> {
Timer? _autoConfirmTimer;
bool _didComplete = false;
double? _distanceKm;
@override
void initState() {
super.initState();
_calculateDistance();
_startAutoConfirmTimer();
}
@@ -47,6 +54,27 @@ class _RecommendationResultDialogState
});
}
void _calculateDistance() {
final lat = widget.currentLatitude;
final lon = widget.currentLongitude;
if (lat == null || lon == null) return;
_distanceKm = DistanceCalculator.calculateDistance(
lat1: lat,
lon1: lon,
lat2: widget.restaurant.latitude,
lon2: widget.restaurant.longitude,
);
}
String _formatDistance(double distanceKm) {
final meters = distanceKm * 1000;
if (meters >= 1000) {
return '${distanceKm.toStringAsFixed(1)} km';
}
return '${meters.round()} m';
}
Future<void> _handleResult(RecommendationDialogResult result) async {
if (_didComplete) return;
_didComplete = true;
@@ -177,6 +205,26 @@ class _RecommendationResultDialogState
),
],
),
if (_distanceKm != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.place,
size: 20,
color: AppColors.lightPrimary,
),
const SizedBox(width: 8),
Text(
_formatDistance(_distanceKm!),
style: AppTypography.body2(isDark).copyWith(
color: AppColors.lightPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
],
if (widget.restaurant.phoneNumber != null) ...[
const SizedBox(height: 8),
Row(
@@ -237,14 +285,14 @@ class _RecommendationResultDialogState
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('닫기'),
child: const Text('오늘의 선택!'),
),
),
],
),
const SizedBox(height: 8),
Text(
'조용히 두면 자동으로 방문 처리되고 알림이 예약됩니다.',
'앱을 종료하면 자동으로 선택이 확정됩니다.',
style: AppTypography.caption(isDark),
textAlign: TextAlign.center,
),

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_typography.dart';
import '../../../core/utils/app_logger.dart';
import '../../providers/restaurant_provider.dart';
import '../../widgets/category_selector.dart';
import 'manual_restaurant_input_screen.dart';
@@ -33,11 +34,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
final searchQuery = ref.watch(searchQueryProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
final restaurantsAsync = ref.watch(
isFiltered
? filteredRestaurantsProvider
: sortedRestaurantsByDistanceProvider,
);
final restaurantsAsync = isFiltered
? ref.watch(filteredRestaurantsProvider)
: ref.watch(sortedRestaurantsByDistanceProvider);
return Scaffold(
backgroundColor: isDark
@@ -106,6 +105,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
Expanded(
child: restaurantsAsync.when(
data: (restaurantsData) {
AppLogger.debug(
'[restaurant_list_ui] data received, filtered=$isFiltered',
);
final items = isFiltered
? (restaurantsData as List<Restaurant>)
.map(
@@ -132,9 +134,14 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.lightPrimary),
),
loading: () {
AppLogger.debug('[restaurant_list_ui] loading...');
return const Center(
child: CircularProgressIndicator(
color: AppColors.lightPrimary,
),
);
},
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -72,49 +72,52 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: ListTile(
title: const Text('중복 방문 제외 기간'),
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: _daysToExclude > 1
? () async {
setState(() => _daysToExclude--);
await ref
.read(settingsNotifierProvider.notifier)
.setDaysToExclude(_daysToExclude);
}
: null,
color: AppColors.lightPrimary,
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
trailing: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: _daysToExclude > 1
? () async {
setState(() => _daysToExclude--);
await ref
.read(settingsNotifierProvider.notifier)
.setDaysToExclude(_daysToExclude);
}
: null,
color: AppColors.lightPrimary,
),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_daysToExclude일',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.lightPrimary,
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_daysToExclude일',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.lightPrimary,
),
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () async {
setState(() => _daysToExclude++);
await ref
.read(settingsNotifierProvider.notifier)
.setDaysToExclude(_daysToExclude);
},
color: AppColors.lightPrimary,
),
],
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () async {
setState(() => _daysToExclude++);
await ref
.read(settingsNotifierProvider.notifier)
.setDaysToExclude(_daysToExclude);
},
color: AppColors.lightPrimary,
),
],
),
),
),
),
@@ -204,59 +207,62 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
enabled: _notificationEnabled,
title: const Text('방문 확인 알림 시간'),
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed:
_notificationEnabled && _notificationMinutes > 60
? () async {
setState(() => _notificationMinutes -= 30);
await ref
.read(settingsNotifierProvider.notifier)
.setNotificationDelayMinutes(
_notificationMinutes,
);
}
: null,
color: AppColors.lightPrimary,
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
trailing: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed:
_notificationEnabled && _notificationMinutes > 60
? () async {
setState(() => _notificationMinutes -= 30);
await ref
.read(settingsNotifierProvider.notifier)
.setNotificationDelayMinutes(
_notificationMinutes,
);
}
: null,
color: AppColors.lightPrimary,
),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _notificationEnabled
? AppColors.lightPrimary
: Colors.grey,
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _notificationEnabled
? AppColors.lightPrimary
: Colors.grey,
),
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed:
_notificationEnabled && _notificationMinutes < 360
? () async {
setState(() => _notificationMinutes += 30);
await ref
.read(settingsNotifierProvider.notifier)
.setNotificationDelayMinutes(
_notificationMinutes,
);
}
: null,
color: AppColors.lightPrimary,
),
],
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed:
_notificationEnabled && _notificationMinutes < 360
? () async {
setState(() => _notificationMinutes += 30);
await ref
.read(settingsNotifierProvider.notifier)
.setNotificationDelayMinutes(
_notificationMinutes,
);
}
: null,
color: AppColors.lightPrimary,
),
],
),
),
),
),
@@ -310,30 +316,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
title: Text('버전'),
subtitle: Text('1.0.0'),
),
const Divider(height: 1),
const ListTile(
leading: Icon(
Icons.person_outline,
color: AppColors.lightPrimary,
),
title: Text('개발자'),
subtitle: Text('NatureBridgeAI'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(
Icons.description_outlined,
color: AppColors.lightPrimary,
),
title: const Text('오픈소스 라이센스'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () => showLicensePage(
context: context,
applicationName: '오늘 뭐 먹Z?',
applicationVersion: '1.0.0',
applicationLegalese: '© 2025 NatureBridgeAI',
),
),
],
),
),