LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
450 lines
16 KiB
Dart
450 lines
16 KiB
Dart
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 '../../providers/location_provider.dart';
|
|
import '../../providers/recommendation_provider.dart';
|
|
import 'widgets/recommendation_result_dialog.dart';
|
|
|
|
class RandomSelectionScreen extends ConsumerStatefulWidget {
|
|
const RandomSelectionScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<RandomSelectionScreen> createState() => _RandomSelectionScreenState();
|
|
}
|
|
|
|
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|
double _distanceValue = 500;
|
|
final List<String> _selectedCategories = [];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Scaffold(
|
|
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
|
|
appBar: AppBar(
|
|
title: const Text('오늘 뭐 먹Z?'),
|
|
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 맛집 리스트 현황 카드
|
|
Card(
|
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
children: [
|
|
const Icon(
|
|
Icons.restaurant,
|
|
size: 48,
|
|
color: AppColors.lightPrimary,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Consumer(
|
|
builder: (context, ref, child) {
|
|
final restaurantsAsync = ref.watch(restaurantListProvider);
|
|
return restaurantsAsync.when(
|
|
data: (restaurants) => Text(
|
|
'${restaurants.length}개',
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Text(
|
|
'등록된 맛집',
|
|
style: AppTypography.body2(isDark),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
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: Consumer(
|
|
builder: (context, ref, child) {
|
|
final weatherAsync = ref.watch(weatherProvider);
|
|
return weatherAsync.when(
|
|
data: (weather) => Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_buildWeatherData('지금', weather.current, isDark),
|
|
Container(
|
|
width: 1,
|
|
height: 50,
|
|
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
|
),
|
|
_buildWeatherData('1시간 후', weather.nextHour, isDark),
|
|
],
|
|
),
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.lightPrimary,
|
|
),
|
|
),
|
|
error: (_, __) => Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_buildWeatherInfo('지금', Icons.wb_sunny, '맑음', 20, isDark),
|
|
Container(
|
|
width: 1,
|
|
height: 50,
|
|
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
|
),
|
|
_buildWeatherInfo('1시간 후', Icons.wb_sunny, '맑음', 22, isDark),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
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),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
activeTrackColor: AppColors.lightPrimary,
|
|
inactiveTrackColor: AppColors.lightPrimary.withValues(alpha: 0.3),
|
|
thumbColor: AppColors.lightPrimary,
|
|
trackHeight: 4,
|
|
),
|
|
child: Slider(
|
|
value: _distanceValue,
|
|
min: 100,
|
|
max: 2000,
|
|
divisions: 19,
|
|
onChanged: (value) {
|
|
setState(() => _distanceValue = value);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
'${_distanceValue.toInt()}m',
|
|
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 location = locationAsync.value;
|
|
final restaurants = restaurantsAsync.value;
|
|
|
|
if (location != null && restaurants != null) {
|
|
final count = _getRestaurantCountInRange(
|
|
restaurants,
|
|
location,
|
|
_distanceValue,
|
|
);
|
|
return Text(
|
|
'$count개 맛집 포함',
|
|
style: AppTypography.caption(isDark),
|
|
);
|
|
}
|
|
}
|
|
|
|
return Text(
|
|
'위치 정보를 가져오는 중...',
|
|
style: AppTypography.caption(isDark),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
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),
|
|
|
|
// 추천받기 버튼
|
|
ElevatedButton(
|
|
onPressed: _canRecommend() ? _startRecommendation : null,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.lightPrimary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
elevation: 3,
|
|
),
|
|
child: 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: [
|
|
Text(label, style: AppTypography.caption(isDark)),
|
|
const SizedBox(height: 8),
|
|
Icon(
|
|
weatherData.isRainy ? Icons.umbrella : Icons.wb_sunny,
|
|
color: weatherData.isRainy ? Colors.blue : Colors.orange,
|
|
size: 32,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${weatherData.temperature}°C',
|
|
style: AppTypography.body1(isDark).copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
weatherData.description,
|
|
style: AppTypography.caption(isDark),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _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,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'$temperature°C',
|
|
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,
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
if (selected) {
|
|
_selectedCategories.add(category);
|
|
} else {
|
|
_selectedCategories.remove(category);
|
|
}
|
|
});
|
|
},
|
|
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),
|
|
),
|
|
side: BorderSide(
|
|
color: isSelected ? AppColors.lightPrimary : (isDark ? AppColors.darkDivider : AppColors.lightDivider),
|
|
),
|
|
);
|
|
}
|
|
|
|
int _getRestaurantCountInRange(
|
|
List<Restaurant> restaurants,
|
|
Position location,
|
|
double maxDistance,
|
|
) {
|
|
return restaurants.where((restaurant) {
|
|
final distance = Geolocator.distanceBetween(
|
|
location.latitude,
|
|
location.longitude,
|
|
restaurant.latitude,
|
|
restaurant.longitude,
|
|
);
|
|
return distance <= maxDistance;
|
|
}).length;
|
|
}
|
|
|
|
bool _canRecommend() {
|
|
final locationAsync = ref.read(currentLocationProvider);
|
|
final restaurantsAsync = ref.read(restaurantListProvider);
|
|
|
|
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);
|
|
return count > 0;
|
|
}
|
|
|
|
Future<void> _startRecommendation() 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,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
} else if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('조건에 맞는 맛집이 없습니다'),
|
|
backgroundColor: AppColors.lightError,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
} |