From c1aa16c5212d923c3227eed8c601b55d2d5ad73f Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 1 Dec 2025 17:22:21 +0900 Subject: [PATCH] feat(app): stabilize recommendation flow --- lib/core/constants/app_constants.dart | 2 +- .../restaurant_repository_impl.dart | 13 +- .../settings_repository_impl.dart | 2 +- lib/presentation/pages/main/main_screen.dart | 11 + .../random_selection_screen.dart | 269 +++++++++++------- .../widgets/recommendation_result_dialog.dart | 52 +++- .../restaurant_list_screen.dart | 23 +- .../pages/settings/settings_screen.dart | 210 +++++++------- .../providers/location_provider.dart | 63 ++-- .../providers/recommendation_provider.dart | 7 +- .../providers/restaurant_provider.dart | 27 +- .../providers/visit_provider.dart | 3 +- 12 files changed, 422 insertions(+), 260 deletions(-) diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index bb3a2f2..52372e7 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -30,7 +30,7 @@ class AppConstants { static const String storeSeedMetaAsset = 'assets/data/store_seed.meta.json'; // Default Settings - static const int defaultDaysToExclude = 7; + static const int defaultDaysToExclude = 14; static const int defaultNotificationMinutes = 90; static const int defaultMaxDistanceNormal = 1000; // meters static const int defaultMaxDistanceRainy = 500; // meters diff --git a/lib/data/repositories/restaurant_repository_impl.dart b/lib/data/repositories/restaurant_repository_impl.dart index b14506f..ccccd96 100644 --- a/lib/data/repositories/restaurant_repository_impl.dart +++ b/lib/data/repositories/restaurant_repository_impl.dart @@ -64,8 +64,17 @@ class RestaurantRepositoryImpl implements RestaurantRepository { @override Stream> watchRestaurants() async* { final box = await _box; - yield box.values.toList(); - yield* box.watch().map((_) => box.values.toList()); + final initial = box.values.toList(); + AppLogger.debug('[restaurant_repo] initial load count: ${initial.length}'); + yield initial; + yield* box.watch().map((event) { + final values = box.values.toList(); + AppLogger.debug( + '[restaurant_repo] box watch event -> count: ${values.length} ' + '(key=${event.key}, deleted=${event.deleted})', + ); + return values; + }); } @override diff --git a/lib/data/repositories/settings_repository_impl.dart b/lib/data/repositories/settings_repository_impl.dart index 79c0ce4..1d60af8 100644 --- a/lib/data/repositories/settings_repository_impl.dart +++ b/lib/data/repositories/settings_repository_impl.dart @@ -17,7 +17,7 @@ class SettingsRepositoryImpl implements SettingsRepository { static const String _keyCategoryWeights = 'category_weights'; // Default values - static const int _defaultDaysToExclude = 7; + static const int _defaultDaysToExclude = 14; static const int _defaultMaxDistanceRainy = 500; static const int _defaultMaxDistanceNormal = 1000; static const int _defaultNotificationDelayMinutes = 90; diff --git a/lib/presentation/pages/main/main_screen.dart b/lib/presentation/pages/main/main_screen.dart index 6395ccb..6e19b4f 100644 --- a/lib/presentation/pages/main/main_screen.dart +++ b/lib/presentation/pages/main/main_screen.dart @@ -39,6 +39,17 @@ class _MainScreenState extends ConsumerState { }); } + @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; diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index 118d2d6..f04d537 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -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 { final List _selectedCategories = []; final List _excludedRestaurantIds = []; bool _isProcessingRecommendation = false; + bool _hasUserAdjustedCategories = false; @override Widget build(BuildContext context) { @@ -168,6 +165,60 @@ class _RandomSelectionScreenState extends ConsumerState { 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 { 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 { data: (list) => list, orElse: () => null, ); + final categories = categoriesAsync.maybeWhen( + data: (list) => list, + orElse: () => const [], + ); if (location != null && restaurants != null) { final count = _getRestaurantCountInRange( restaurants, location, _distanceValue, + _effectiveSelectedCategories(categories), ); return Text( '$count개 맛집 포함', @@ -255,51 +312,6 @@ class _RandomSelectionScreenState extends ConsumerState { ), ), - 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 { ); } - Widget _buildCategoryChip(String category, bool isDark) { - final isSelected = _selectedCategories.contains(category); - + Widget _buildCategoryChip( + String category, + bool isDark, + bool isSelected, + List 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 { ); } + void _handleCategoryToggle( + String category, + bool isSelected, + List 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 restaurants, Position location, double maxDistance, + List selectedCategories, ) { return restaurants.where((restaurant) { final distance = Geolocator.distanceBetween( @@ -437,13 +471,20 @@ class _RandomSelectionScreenState extends ConsumerState { 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 []); final location = locationAsync.maybeWhen( data: (pos) => pos, @@ -462,6 +503,7 @@ class _RandomSelectionScreenState extends ConsumerState { restaurants, location, _distanceValue, + _effectiveSelectedCategories(categories), ); return count > 0; } @@ -518,10 +560,13 @@ class _RandomSelectionScreenState extends ConsumerState { List excludedRestaurantIds = const [], }) async { final notifier = ref.read(recommendationNotifierProvider.notifier); + final categories = ref + .read(categoriesProvider) + .maybeWhen(data: (list) => list, orElse: () => const []); final recommendation = await notifier.getRandomRecommendation( maxDistance: _distanceValue, - selectedCategories: _selectedCategories, + selectedCategories: _effectiveSelectedCategories(categories), excludedRestaurantIds: excludedRestaurantIds, shouldSaveRecord: false, ); @@ -547,11 +592,18 @@ class _RandomSelectionScreenState extends ConsumerState { Restaurant restaurant, { DateTime? recommendedAt, }) async { + final location = ref + .read(currentLocationWithFallbackProvider) + .maybeWhen(data: (pos) => pos, orElse: () => null); + final result = await showDialog( 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 { 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 { 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 { } 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 { ), ); } + + void _ensureDefaultCategorySelection(List 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 _effectiveSelectedCategories(List 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 } diff --git a/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart b/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart index c96251e..ee07dfc 100644 --- a/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart +++ b/lib/presentation/pages/random_selection/widgets/recommendation_result_dialog.dart @@ -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 { 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 _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, ), diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart index d981a69..9abecf6 100644 --- a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart +++ b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart @@ -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 { 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 { Expanded( child: restaurantsAsync.when( data: (restaurantsData) { + AppLogger.debug( + '[restaurant_list_ui] data received, filtered=$isFiltered', + ); final items = isFiltered ? (restaurantsData as List) .map( @@ -132,9 +134,14 @@ class _RestaurantListScreenState extends ConsumerState { }, ); }, - 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, diff --git a/lib/presentation/pages/settings/settings_screen.dart b/lib/presentation/pages/settings/settings_screen.dart index 8d7a43c..fc34aab 100644 --- a/lib/presentation/pages/settings/settings_screen.dart +++ b/lib/presentation/pages/settings/settings_screen.dart @@ -72,49 +72,52 @@ class _SettingsScreenState extends ConsumerState { 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 { 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 { 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', - ), - ), ], ), ), diff --git a/lib/presentation/providers/location_provider.dart b/lib/presentation/providers/location_provider.dart index d288885..6937824 100644 --- a/lib/presentation/providers/location_provider.dart +++ b/lib/presentation/providers/location_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:lunchpick/core/utils/app_logger.dart'; @@ -69,13 +70,31 @@ final currentLocationProvider = FutureProvider((ref) async { }); /// 위치 스트림 Provider -final locationStreamProvider = StreamProvider((ref) { - return Geolocator.getPositionStream( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.high, - distanceFilter: 10, // 10미터 이상 이동 시 업데이트 - ), - ); +final locationStreamProvider = StreamProvider((ref) async* { + if (kIsWeb) { + AppLogger.debug('[location] web detected, emit fallback immediately'); + yield defaultPosition(); + return; + } + + final status = await Permission.location.status; + if (!status.isGranted) { + AppLogger.debug('[location] permission not granted, emit fallback'); + yield defaultPosition(); + return; + } + + try { + yield* Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 10, // 10미터 이상 이동 시 업데이트 + ), + ); + } catch (_) { + AppLogger.error('[location] position stream failed, emit fallback'); + yield defaultPosition(); + } }); /// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고, @@ -83,20 +102,30 @@ final locationStreamProvider = StreamProvider((ref) { final currentLocationWithFallbackProvider = StreamProvider(( ref, ) async* { - final initial = await Future.any([ - ref - .watch(currentLocationProvider.future) - .then((pos) => pos ?? defaultPosition()), - Future.delayed( - const Duration(seconds: 3), - () => defaultPosition(), - ), - ]).catchError((_) => defaultPosition()); + AppLogger.debug('[location] emit fallback immediately (safe start)'); + // 웹/권한 거부 상황에서는 즉시 기본 좌표를 먼저 흘려보내 리스트 로딩을 막는다. + final fallback = defaultPosition(); + yield fallback; - yield initial; + final initial = await Future.any([ + ref.watch(currentLocationProvider.future).then((pos) => pos ?? fallback), + Future.delayed(const Duration(seconds: 3), () => fallback), + ]).catchError((_) => fallback); + + if (initial.latitude != fallback.latitude || + initial.longitude != fallback.longitude) { + AppLogger.debug( + '[location] resolved initial position: ' + '${initial.latitude}, ${initial.longitude}', + ); + yield initial; + } else { + AppLogger.debug('[location] initial resolved to fallback'); + } yield* ref.watch(locationStreamProvider.stream).handleError((_) { // 스트림 오류는 무시하고 마지막 위치를 유지 + AppLogger.error('[location] stream error, keeping last position'); }); }); diff --git a/lib/presentation/providers/recommendation_provider.dart b/lib/presentation/providers/recommendation_provider.dart index be260a3..5ed153a 100644 --- a/lib/presentation/providers/recommendation_provider.dart +++ b/lib/presentation/providers/recommendation_provider.dart @@ -140,6 +140,7 @@ class RecommendationNotifier extends StateNotifier> { Future saveRecommendationRecord( Restaurant restaurant, { DateTime? recommendationTime, + bool visited = false, }) async { final now = DateTime.now(); @@ -147,7 +148,7 @@ class RecommendationNotifier extends StateNotifier> { id: const Uuid().v4(), restaurantId: restaurant.id, recommendationDate: recommendationTime ?? now, - visited: false, + visited: visited, createdAt: now, ); @@ -172,7 +173,11 @@ class RecommendationNotifier extends StateNotifier> { await visitNotifier.createVisitFromRecommendation( restaurantId: recommendation.restaurantId, recommendationTime: recommendation.recommendationDate, + isConfirmed: true, ); + + // 방문 기록을 만들었으므로 추천 기록은 숨김 처리 + await _repository.deleteRecommendationRecord(recommendationId); } catch (e, stack) { state = AsyncValue.error(e, stack); } diff --git a/lib/presentation/providers/restaurant_provider.dart b/lib/presentation/providers/restaurant_provider.dart index 00c7cfe..e265e8a 100644 --- a/lib/presentation/providers/restaurant_provider.dart +++ b/lib/presentation/providers/restaurant_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lunchpick/core/utils/category_mapper.dart'; +import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/repositories/restaurant_repository.dart'; import 'package:lunchpick/presentation/providers/di_providers.dart'; @@ -14,16 +15,28 @@ final restaurantListProvider = StreamProvider>((ref) { }); /// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반) +/// StreamProvider 의존으로 초기 이벤트를 놓치는 문제를 피하기 위해 +/// 기존 리스트 스트림의 AsyncValue를 그대로 전달하며 정렬만 적용한다. final sortedRestaurantsByDistanceProvider = - StreamProvider>((ref) { - final restaurantsStream = ref.watch(restaurantListProvider.stream); - final positionAsync = ref.watch(currentLocationProvider); + Provider>>(( + ref, + ) { + final restaurantsAsync = ref.watch(restaurantListProvider); + final positionAsync = ref.watch(currentLocationWithFallbackProvider); final position = positionAsync.maybeWhen( - data: (pos) => pos ?? defaultPosition(), + data: (pos) => pos, orElse: () => defaultPosition(), ); - return restaurantsStream.map((restaurants) { + AppLogger.debug( + '[restaurant_list] position ready for sorting: ' + '${position.latitude}, ${position.longitude}', + ); + + return restaurantsAsync.whenData((restaurants) { + AppLogger.debug( + '[restaurant_list] incoming restaurants: ${restaurants.length}', + ); final sorted = restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) { final distanceKm = DistanceCalculator.calculateDistance( @@ -38,6 +51,10 @@ final sortedRestaurantsByDistanceProvider = b.distanceKm ?? double.infinity, ), ); + AppLogger.debug( + '[restaurant_list] sorted list emitted, first distanceKm: ' + '${sorted.isNotEmpty ? sorted.first.distanceKm?.toStringAsFixed(3) : 'none'}', + ); return sorted; }); }); diff --git a/lib/presentation/providers/visit_provider.dart b/lib/presentation/providers/visit_provider.dart index 33cff9e..ea4a109 100644 --- a/lib/presentation/providers/visit_provider.dart +++ b/lib/presentation/providers/visit_provider.dart @@ -100,6 +100,7 @@ class VisitNotifier extends StateNotifier> { Future createVisitFromRecommendation({ required String restaurantId, required DateTime recommendationTime, + bool isConfirmed = false, }) async { // 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정 final visitTime = recommendationTime.add(const Duration(minutes: 90)); @@ -107,7 +108,7 @@ class VisitNotifier extends StateNotifier> { await addVisitRecord( restaurantId: restaurantId, visitDate: visitTime, - isConfirmed: false, // 나중에 확인 필요 + isConfirmed: isConfirmed, ); } }