feat(app): add manual entry and sharing flows

This commit is contained in:
JiWoong Sul
2025-11-19 16:36:39 +09:00
parent 5ade584370
commit 947fe59486
110 changed files with 5937 additions and 3781 deletions

View File

@@ -1,36 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_typography.dart';
import '../../../domain/entities/weather_info.dart';
import '../../../domain/entities/restaurant.dart';
import '../../providers/restaurant_provider.dart';
import '../../providers/weather_provider.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';
class RandomSelectionScreen extends ConsumerStatefulWidget {
const RandomSelectionScreen({super.key});
@override
ConsumerState<RandomSelectionScreen> createState() => _RandomSelectionScreenState();
ConsumerState<RandomSelectionScreen> createState() =>
_RandomSelectionScreenState();
}
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
double _distanceValue = 500;
final List<String> _selectedCategories = [];
bool _isProcessingRecommendation = false;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
backgroundColor: isDark
? AppColors.darkBackground
: AppColors.lightBackground,
appBar: AppBar(
title: const Text('오늘 뭐 먹Z?'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
backgroundColor: isDark
? AppColors.darkPrimary
: AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
@@ -58,37 +70,36 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
const SizedBox(height: 12),
Consumer(
builder: (context, ref, child) {
final restaurantsAsync = ref.watch(restaurantListProvider);
final restaurantsAsync = ref.watch(
restaurantListProvider,
);
return restaurantsAsync.when(
data: (restaurants) => Text(
'${restaurants.length}',
style: AppTypography.heading1(isDark).copyWith(
color: AppColors.lightPrimary,
),
style: AppTypography.heading1(
isDark,
).copyWith(color: AppColors.lightPrimary),
),
loading: () => const CircularProgressIndicator(
color: AppColors.lightPrimary,
),
error: (_, __) => Text(
'0개',
style: AppTypography.heading1(isDark).copyWith(
color: AppColors.lightPrimary,
),
style: AppTypography.heading1(
isDark,
).copyWith(color: AppColors.lightPrimary),
),
);
},
),
Text(
'등록된 맛집',
style: AppTypography.body2(isDark),
),
Text('등록된 맛집', style: AppTypography.body2(isDark)),
],
),
),
),
const SizedBox(height: 16),
// 날씨 정보 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@@ -109,7 +120,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
Container(
width: 1,
height: 50,
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
_buildWeatherData('1시간 후', weather.nextHour, isDark),
],
@@ -122,13 +135,27 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
error: (_, __) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildWeatherInfo('지금', Icons.wb_sunny, '맑음', 20, isDark),
_buildWeatherInfo(
'지금',
Icons.wb_sunny,
'맑음',
20,
isDark,
),
Container(
width: 1,
height: 50,
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
_buildWeatherInfo(
'1시간 후',
Icons.wb_sunny,
'맑음',
22,
isDark,
),
_buildWeatherInfo('1시간 후', Icons.wb_sunny, '맑음', 22, isDark),
],
),
);
@@ -136,9 +163,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
),
),
const SizedBox(height: 16),
// 거리 설정 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@@ -151,10 +178,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'최대 거리',
style: AppTypography.heading2(isDark),
),
Text('최대 거리', style: AppTypography.heading2(isDark)),
const SizedBox(height: 12),
Row(
children: [
@@ -162,7 +186,8 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: AppColors.lightPrimary,
inactiveTrackColor: AppColors.lightPrimary.withValues(alpha: 0.3),
inactiveTrackColor: AppColors.lightPrimary
.withValues(alpha: 0.3),
thumbColor: AppColors.lightPrimary,
trackHeight: 4,
),
@@ -180,22 +205,27 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
const SizedBox(width: 12),
Text(
'${_distanceValue.toInt()}m',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.bold,
),
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
Consumer(
builder: (context, ref, child) {
final locationAsync = ref.watch(currentLocationProvider);
final restaurantsAsync = ref.watch(restaurantListProvider);
if (locationAsync.hasValue && restaurantsAsync.hasValue) {
final locationAsync = ref.watch(
currentLocationProvider,
);
final restaurantsAsync = ref.watch(
restaurantListProvider,
);
if (locationAsync.hasValue &&
restaurantsAsync.hasValue) {
final location = locationAsync.value;
final restaurants = restaurantsAsync.value;
if (location != null && restaurants != null) {
final count = _getRestaurantCountInRange(
restaurants,
@@ -208,7 +238,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
);
}
}
return Text(
'위치 정보를 가져오는 중...',
style: AppTypography.caption(isDark),
@@ -219,9 +249,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
),
),
const SizedBox(height: 16),
// 카테고리 선택 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@@ -234,22 +264,26 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'카테고리',
style: AppTypography.heading2(isDark),
),
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(),
: categories
.map(
(category) => _buildCategoryChip(
category,
isDark,
),
)
.toList(),
),
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
@@ -260,12 +294,14 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
),
),
const SizedBox(height: 24),
// 추천받기 버튼
ElevatedButton(
onPressed: _canRecommend() ? _startRecommendation : null,
onPressed: !_isProcessingRecommendation && _canRecommend()
? () => _startRecommendation()
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
@@ -275,27 +311,36 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
elevation: 3,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.play_arrow, size: 28),
SizedBox(width: 8),
Text(
'광고보고 추천받기',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
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,
),
),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildWeatherData(String label, WeatherData weatherData, bool isDark) {
return Column(
children: [
@@ -309,47 +354,42 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
const SizedBox(height: 4),
Text(
'${weatherData.temperature}°C',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
weatherData.description,
style: AppTypography.caption(isDark),
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
Text(weatherData.description, style: AppTypography.caption(isDark)),
],
);
}
Widget _buildWeatherInfo(String label, IconData icon, String description, int temperature, bool isDark) {
Widget _buildWeatherInfo(
String label,
IconData icon,
String description,
int temperature,
bool isDark,
) {
return Column(
children: [
Text(label, style: AppTypography.caption(isDark)),
const SizedBox(height: 8),
Icon(
icon,
color: Colors.orange,
size: 32,
),
Icon(icon, color: Colors.orange, size: 32),
const SizedBox(height: 4),
Text(
'$temperature°C',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
description,
style: AppTypography.caption(isDark),
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
Text(description, style: AppTypography.caption(isDark)),
],
);
}
Widget _buildCategoryChip(String category, bool isDark) {
final isSelected = _selectedCategories.contains(category);
return FilterChip(
label: Text(category),
selected: isSelected,
@@ -362,18 +402,24 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
}
});
},
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground,
backgroundColor: isDark
? AppColors.darkSurface
: AppColors.lightBackground,
selectedColor: AppColors.lightPrimary.withValues(alpha: 0.2),
checkmarkColor: AppColors.lightPrimary,
labelStyle: TextStyle(
color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary),
color: isSelected
? AppColors.lightPrimary
: (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary),
),
side: BorderSide(
color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkDivider : AppColors.lightDivider),
color: isSelected
? AppColors.lightPrimary
: (isDark ? AppColors.darkDivider : AppColors.lightDivider),
),
);
}
int _getRestaurantCountInRange(
List<Restaurant> restaurants,
Position location,
@@ -389,62 +435,163 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
return distance <= maxDistance;
}).length;
}
bool _canRecommend() {
final locationAsync = ref.read(currentLocationProvider);
final restaurantsAsync = ref.read(restaurantListProvider);
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) return false;
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) {
return false;
}
final location = locationAsync.value;
final restaurants = restaurantsAsync.value;
if (location == null || restaurants == null || restaurants.isEmpty) return false;
final count = _getRestaurantCountInRange(restaurants, location, _distanceValue);
if (location == null || restaurants == null || restaurants.isEmpty) {
return false;
}
final count = _getRestaurantCountInRange(
restaurants,
location,
_distanceValue,
);
return count > 0;
}
Future<void> _startRecommendation() async {
Future<void> _startRecommendation({bool skipAd = false}) async {
if (_isProcessingRecommendation) return;
setState(() {
_isProcessingRecommendation = true;
});
try {
final candidate = await _generateRecommendationCandidate();
if (candidate == null) {
return;
}
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(
'광고를 끝까지 시청해야 추천을 받을 수 있어요.',
backgroundColor: AppColors.lightError,
);
return;
}
}
if (!mounted) return;
_showRecommendationDialog(candidate);
} catch (_) {
_showSnack(
'추천을 준비하는 중 문제가 발생했습니다.',
backgroundColor: AppColors.lightError,
);
} finally {
if (mounted) {
setState(() {
_isProcessingRecommendation = false;
});
}
}
}
Future<Restaurant?> _generateRecommendationCandidate() async {
final notifier = ref.read(recommendationNotifierProvider.notifier);
await notifier.getRandomRecommendation(
maxDistance: _distanceValue,
selectedCategories: _selectedCategories,
);
final result = ref.read(recommendationNotifierProvider);
result.whenData((restaurant) {
if (restaurant != null && mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => RecommendationResultDialog(
restaurant: restaurant,
onReroll: () {
Navigator.pop(context);
_startRecommendation();
},
onConfirmVisit: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('맛있게 드세요! 🍴'),
backgroundColor: AppColors.lightPrimary,
),
);
},
),
if (result.hasError) {
final message = result.error?.toString() ?? '알 수 없는 오류';
_showSnack(
'추천 중 오류가 발생했습니다: $message',
backgroundColor: AppColors.lightError,
);
return null;
}
final restaurant = result.asData?.value;
if (restaurant == null) {
_showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError);
}
return restaurant;
}
void _showRecommendationDialog(Restaurant restaurant) {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => RecommendationResultDialog(
restaurant: restaurant,
onReroll: () async {
Navigator.pop(dialogContext);
await _startRecommendation(skipAd: true);
},
onClose: () async {
Navigator.pop(dialogContext);
await _handleRecommendationAccepted(restaurant);
},
),
);
}
Future<void> _handleRecommendationAccepted(Restaurant restaurant) async {
final recommendationTime = DateTime.now();
try {
final notificationEnabled = await ref.read(
notificationEnabledProvider.future,
);
if (notificationEnabled) {
final delayMinutes = await ref.read(
notificationDelayMinutesProvider.future,
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('조건에 맞는 맛집이 없습니다'),
backgroundColor: AppColors.lightError,
),
final notificationService = ref.read(notificationServiceProvider);
await notificationService.scheduleVisitReminder(
restaurantId: restaurant.id,
restaurantName: restaurant.name,
recommendationTime: recommendationTime,
delayMinutes: delayMinutes,
);
}
});
await ref
.read(visitNotifierProvider.notifier)
.createVisitFromRecommendation(
restaurantId: restaurant.id,
recommendationTime: recommendationTime,
);
_showSnack('맛있게 드세요! 🍴');
} catch (_) {
_showSnack(
'방문 기록 또는 알림 예약에 실패했습니다.',
backgroundColor: AppColors.lightError,
);
}
}
}
void _showSnack(
String message, {
Color backgroundColor = AppColors.lightPrimary,
}) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), backgroundColor: backgroundColor),
);
}
}

View File

@@ -1,28 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/core/constants/app_colors.dart';
import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/core/services/notification_service.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/presentation/providers/settings_provider.dart';
import 'package:lunchpick/presentation/providers/visit_provider.dart';
class RecommendationResultDialog extends ConsumerWidget {
class RecommendationResultDialog extends StatelessWidget {
final Restaurant restaurant;
final VoidCallback onReroll;
final VoidCallback onConfirmVisit;
final Future<void> Function() onReroll;
final Future<void> Function() onClose;
const RecommendationResultDialog({
super.key,
required this.restaurant,
required this.onReroll,
required this.onConfirmVisit,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
@@ -56,9 +52,9 @@ class RecommendationResultDialog extends ConsumerWidget {
const SizedBox(height: 8),
Text(
'오늘의 추천!',
style: AppTypography.heading2(false).copyWith(
color: Colors.white,
),
style: AppTypography.heading2(
false,
).copyWith(color: Colors.white),
),
],
),
@@ -68,13 +64,15 @@ class RecommendationResultDialog extends ConsumerWidget {
right: 8,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
onPressed: () async {
await onClose();
},
),
),
],
),
),
// 맛집 정보
Padding(
padding: const EdgeInsets.all(24),
@@ -90,24 +88,27 @@ class RecommendationResultDialog extends ConsumerWidget {
),
),
const SizedBox(height: 8),
// 카테고리
Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${restaurant.category} > ${restaurant.subCategory}',
style: AppTypography.body2(isDark).copyWith(
color: AppColors.lightPrimary,
),
style: AppTypography.body2(
isDark,
).copyWith(color: AppColors.lightPrimary),
),
),
),
if (restaurant.description != null) ...[
const SizedBox(height: 16),
Text(
@@ -116,18 +117,20 @@ class RecommendationResultDialog extends ConsumerWidget {
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// 주소
Row(
children: [
Icon(
Icons.location_on,
size: 20,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 8),
Expanded(
@@ -138,7 +141,7 @@ class RecommendationResultDialog extends ConsumerWidget {
),
],
),
if (restaurant.phoneNumber != null) ...[
const SizedBox(height: 8),
Row(
@@ -146,7 +149,9 @@ class RecommendationResultDialog extends ConsumerWidget {
Icon(
Icons.phone,
size: 20,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 8),
Text(
@@ -156,18 +161,22 @@ class RecommendationResultDialog extends ConsumerWidget {
],
),
],
const SizedBox(height: 24),
// 버튼들
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onReroll,
onPressed: () async {
await onReroll();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: const BorderSide(color: AppColors.lightPrimary),
side: const BorderSide(
color: AppColors.lightPrimary,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
@@ -182,29 +191,7 @@ class RecommendationResultDialog extends ConsumerWidget {
Expanded(
child: ElevatedButton(
onPressed: () async {
final recommendationTime = DateTime.now();
// 알림 설정 확인
final notificationEnabled = await ref.read(notificationEnabledProvider.future);
if (notificationEnabled) {
// 알림 예약
final notificationService = NotificationService();
await notificationService.scheduleVisitReminder(
restaurantId: restaurant.id,
restaurantName: restaurant.name,
recommendationTime: recommendationTime,
);
}
// 방문 기록 자동 생성 (미확인 상태로)
await ref.read(visitNotifierProvider.notifier).createVisitFromRecommendation(
restaurantId: restaurant.id,
recommendationTime: recommendationTime,
);
// 기존 콜백 실행
onConfirmVisit();
await onClose();
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
@@ -214,7 +201,7 @@ class RecommendationResultDialog extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('여기로 갈게요!'),
child: const Text('닫기'),
),
),
],
@@ -227,4 +214,4 @@ class RecommendationResultDialog extends ConsumerWidget {
),
);
}
}
}