feat(app): stabilize recommendation flow
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user