Files
lunchpick/doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md
2026-01-21 17:03:37 +09:00

2913 lines
92 KiB
Markdown

# "오늘 뭐 먹Z?" - 완전한 개발 가이드
## 프로젝트 개요
### 서비스 요약
직장인들의 점심 메뉴 선택 고민을 해결하는 Flutter 기반 모바일 앱. 사용자가 네이버 지도에서 가게를 수집하고, 조건 기반 랜덤 추천을 받으며, 방문 기록을 관리한다. 광고 시청을 통한 수익화 모델 적용.
### 핵심 가치
1. **의사결정 시간 단축**: 점심 선택 시간을 10-15분에서 1분으로 단축
2. **메뉴 다양성 보장**: 중복 방문 방지 알고리즘으로 새로운 경험 제공
3. **자연스러운 수익화**: 추천 시마다 광고 노출로 지속가능한 BM 구축
### 페르소나 및 사용자 시나리오
| 페르소나 | 특성 | 핵심 Pain-Point | 우리 앱이 주는 가치 |
| ----------------- | --------------- | ------------- | --------------------------- |
| **김직장(29, 개발자)** | 점심 1시간, 선택은 귀찮음 | "맨날 같은 집이야" | n일 내 재방문 금지 알고리즘 |
| **박사원(32, 인턴)** | 업무 스트레스, 저가 선호 | "검색하다 늦어" | 1-Tap 랜덤 + 500m 반경 필터(우천 시) |
| **이팀장(41, 팀 리더)** | 팀원 6명, 빠른 결정 필요 | "회의 길어져" | 추천 즉시 공유·투표 |
**주요 여정 (점심 1시간)**
① 식당 모으기 → ② 조건 선택 → ③ '랜덤 추천' 클릭→ ④ 광고 시청→ ⑤ 결과 확인→ ⑥ 1.5~2h 뒤 "다녀왔음?" 알림→ ⑦ 방문 기록 자동 저장
## 기술 스택 및 아키텍처
### 기술 스택
```yaml
Frontend:
- Framework: Flutter 3.x
- State Management: Riverpod 2.x
- Local DB: Hive
- DI: get_it
- Navigation: go_router
- Bluetooth: flutter_blue_plus
- Permissions: permission_handler
- Notifications: flutter_local_notifications
- Ads: google_mobile_ads
- Theme: adaptive_theme
- Calendar: table_calendar
- HTTP: dio
- JSON: json_serializable
- UUID: uuid
- Location: geolocator
- Share: share_plus
- Clipboard: flutter/services
API & Services:
- 네이버 지도 API (Place Search, Details)
- 기상청 Open API (날씨 정보)
- Google AdMob SDK (광고)
Architecture:
- Clean Architecture (Presentation → Domain → Data)
- Repository Pattern
- MVVM Pattern
```
### 프로젝트 구조
```
lib/
├── core/
│ ├── constants/
│ │ ├── app_colors.dart
│ │ ├── app_typography.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── utils/
│ │ ├── distance_calculator.dart
│ │ ├── date_formatter.dart
│ │ └── permission_handler.dart
│ └── widgets/
│ ├── loading_indicator.dart
│ └── error_widget.dart
├── data/
│ ├── datasources/
│ │ ├── local/
│ │ │ ├── hive_datasource.dart
│ │ │ └── shared_preferences_datasource.dart
│ │ └── remote/
│ │ ├── naver_map_datasource.dart
│ │ └── weather_datasource.dart
│ ├── models/
│ │ ├── restaurant_model.dart
│ │ ├── visit_record_model.dart
│ │ └── weather_model.dart
│ └── repositories/
│ ├── restaurant_repository_impl.dart
│ ├── visit_repository_impl.dart
│ └── weather_repository_impl.dart
├── domain/
│ ├── entities/
│ │ ├── restaurant.dart
│ │ ├── visit_record.dart
│ │ ├── recommendation_record.dart
│ │ ├── weather_info.dart
│ │ └── share_device.dart
│ ├── repositories/
│ │ ├── restaurant_repository.dart
│ │ ├── visit_repository.dart
│ │ └── weather_repository.dart
│ └── usecases/
│ ├── get_random_recommendation.dart
│ ├── add_restaurant.dart
│ ├── share_restaurant_list.dart
│ └── get_weather_info.dart
├── presentation/
│ ├── pages/
│ │ ├── splash/
│ │ │ └── splash_screen.dart
│ │ ├── main/
│ │ │ └── main_screen.dart
│ │ ├── random_selection/
│ │ │ ├── random_selection_screen.dart
│ │ │ └── widgets/
│ │ │ ├── weather_card.dart
│ │ │ ├── distance_slider.dart
│ │ │ └── category_chips.dart
│ │ ├── restaurant_list/
│ │ │ ├── restaurant_list_screen.dart
│ │ │ └── widgets/
│ │ │ ├── restaurant_card.dart
│ │ │ └── add_restaurant_dialog.dart
│ │ ├── share/
│ │ │ ├── share_screen.dart
│ │ │ └── widgets/
│ │ │ ├── share_code_display.dart
│ │ │ └── scan_devices_list.dart
│ │ ├── calendar/
│ │ │ ├── calendar_screen.dart
│ │ │ └── widgets/
│ │ │ ├── calendar_widget.dart
│ │ │ └── visit_record_card.dart
│ │ └── settings/
│ │ ├── settings_screen.dart
│ │ └── widgets/
│ │ ├── permission_tile.dart
│ │ └── notification_settings.dart
│ ├── providers/
│ │ ├── restaurant_provider.dart
│ │ ├── recommendation_provider.dart
│ │ ├── weather_provider.dart
│ │ ├── location_provider.dart
│ │ ├── theme_provider.dart
│ │ └── settings_provider.dart
│ └── widgets/
│ ├── recommendation_dialog.dart
│ └── custom_navigation_bar.dart
└── main.dart
```
## 데이터 모델
### Restaurant Entity
```dart
@HiveType(typeId: 0)
class Restaurant extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String category;
@HiveField(3)
final String subCategory;
@HiveField(4)
final String? description;
@HiveField(5)
final String? phoneNumber;
@HiveField(6)
final String roadAddress;
@HiveField(7)
final String jibunAddress;
@HiveField(8)
final double latitude;
@HiveField(9)
final double longitude;
@HiveField(10)
final DateTime? lastVisitDate;
@HiveField(11)
final DataSource source; // NAVER, USER_INPUT
@HiveField(12)
final DateTime createdAt;
@HiveField(13)
final DateTime updatedAt;
Restaurant({
required this.id,
required this.name,
required this.category,
required this.subCategory,
this.description,
this.phoneNumber,
required this.roadAddress,
required this.jibunAddress,
required this.latitude,
required this.longitude,
this.lastVisitDate,
required this.source,
required this.createdAt,
required this.updatedAt,
});
}
@HiveType(typeId: 1)
enum DataSource {
@HiveField(0)
NAVER,
@HiveField(1)
USER_INPUT
}
```
### Visit Record Entity
```dart
@HiveType(typeId: 2)
class VisitRecord extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String restaurantId;
@HiveField(2)
final DateTime visitDate;
@HiveField(3)
final bool isConfirmed;
@HiveField(4)
final DateTime createdAt;
VisitRecord({
required this.id,
required this.restaurantId,
required this.visitDate,
required this.isConfirmed,
required this.createdAt,
});
}
```
### Recommendation Record Entity
```dart
@HiveType(typeId: 3)
class RecommendationRecord extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String restaurantId;
@HiveField(2)
final DateTime recommendationDate;
@HiveField(3)
final bool visited;
@HiveField(4)
final DateTime createdAt;
RecommendationRecord({
required this.id,
required this.restaurantId,
required this.recommendationDate,
required this.visited,
required this.createdAt,
});
}
```
### Recommendation Settings
```dart
class RecommendationSettings {
final int daysToExclude; // n일 이내 방문 금지
final int maxDistanceRainy; // 우천시 최대 거리 (미터)
final int maxDistanceNormal; // 평상시 최대 거리 (미터)
final List<String> selectedCategories; // 선택된 카테고리
final PriceRange? priceRange;
RecommendationSettings({
required this.daysToExclude,
required this.maxDistanceRainy,
required this.maxDistanceNormal,
required this.selectedCategories,
this.priceRange,
});
}
```
### Weather Info
```dart
class WeatherInfo {
final WeatherData current;
final WeatherData nextHour;
WeatherInfo({
required this.current,
required this.nextHour,
});
}
class WeatherData {
final int temperature;
final bool isRainy;
final String description;
WeatherData({
required this.temperature,
required this.isRainy,
required this.description,
});
}
```
### Share Device
```dart
class ShareDevice {
final String code;
final String deviceId;
final DateTime discoveredAt;
ShareDevice({
required this.code,
required this.deviceId,
required this.discoveredAt,
});
}
```
## UI/UX 디자인 시스템
### 1. 네이버 스타일 컬러 시스템
```dart
class AppColors {
// Light Theme Colors
static const lightPrimary = Color(0xFF03C75A); // 네이버 그린
static const lightSecondary = Color(0xFF00BF63);
static const lightBackground = Color(0xFFF5F5F5);
static const lightSurface = Colors.white;
static const lightTextPrimary = Color(0xFF222222);
static const lightTextSecondary = Color(0xFF767676);
static const lightDivider = Color(0xFFE5E5E5);
static const lightError = Color(0xFFFF5252);
// Dark Theme Colors
static const darkPrimary = Color(0xFF03C75A);
static const darkSecondary = Color(0xFF00BF63);
static const darkBackground = Color(0xFF121212);
static const darkSurface = Color(0xFF1E1E1E);
static const darkTextPrimary = Color(0xFFFFFFFF);
static const darkTextSecondary = Color(0xFFB3B3B3);
static const darkDivider = Color(0xFF2C2C2C);
static const darkError = Color(0xFFFF5252);
}
```
### 2. 타이포그래피 시스템
```dart
class AppTypography {
static TextStyle heading1(bool isDark) => TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
);
static TextStyle heading2(bool isDark) => TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
);
static TextStyle body1(bool isDark) => TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
color: isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary,
);
static TextStyle body2(bool isDark) => TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
);
static TextStyle caption(bool isDark) => TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
);
}
```
## 화면 구현 상세
### 1. 스플래시 화면
```dart
class SplashScreen extends StatefulWidget {
@override
_SplashScreenState createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
late List<AnimationController> _foodControllers;
late AnimationController _questionMarkController;
late AnimationController _centerIconController;
final List<IconData> foodIcons = [
Icons.rice_bowl, Icons.ramen_dining, Icons.lunch_dining,
Icons.fastfood, Icons.local_pizza, Icons.cake,
Icons.coffee, Icons.icecream, Icons.bakery_dining,
];
@override
void initState() {
super.initState();
_initializeAnimations();
_navigateToHome();
}
void _initializeAnimations() {
// 음식 아이콘 애니메이션 (여러 개)
_foodControllers = List.generate(
8,
(index) => AnimationController(
duration: Duration(seconds: 2 + index % 3),
vsync: this,
)..repeat(reverse: true),
);
// 물음표 애니메이션
_questionMarkController = AnimationController(
duration: Duration(milliseconds: 500),
vsync: this,
)..repeat();
// 중앙 아이콘 애니메이션
_centerIconController = AnimationController(
duration: Duration(seconds: 1),
vsync: this,
)..repeat(reverse: true);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
body: Stack(
children: [
// 랜덤 위치 음식 아이콘들
..._buildFoodIcons(),
// 중앙 컨텐츠
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 선택 아이콘
ScaleTransition(
scale: Tween(begin: 0.8, end: 1.2).animate(
CurvedAnimation(
parent: _centerIconController,
curve: Curves.easeInOut,
),
),
child: Icon(
Icons.restaurant_menu,
size: 80,
color: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
),
),
SizedBox(height: 20),
// 앱 타이틀
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'오늘 뭐 먹Z',
style: AppTypography.heading1(isDark),
),
AnimatedBuilder(
animation: _questionMarkController,
builder: (context, child) {
final questionMarks = '?' * (((_questionMarkController.value * 3).floor() % 3) + 1);
return Text(
questionMarks,
style: AppTypography.heading1(isDark),
);
},
),
],
),
],
),
),
// 하단 카피라이트
Positioned(
bottom: 30,
left: 0,
right: 0,
child: Text(
'© 2025. NatureBridgeAI & cclabs. All rights reserved.',
style: AppTypography.caption(isDark).copyWith(
color: (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary)
.withOpacity(0.5),
),
textAlign: TextAlign.center,
),
),
],
),
);
}
List<Widget> _buildFoodIcons() {
return List.generate(foodIcons.length, (index) {
final random = Random();
final left = random.nextDouble() * 0.8 + 0.1;
final top = random.nextDouble() * 0.7 + 0.1;
return Positioned(
left: MediaQuery.of(context).size.width * left,
top: MediaQuery.of(context).size.height * top,
child: FadeTransition(
opacity: Tween(begin: 0.2, end: 0.8).animate(
CurvedAnimation(
parent: _foodControllers[index],
curve: Curves.easeInOut,
),
),
child: ScaleTransition(
scale: Tween(begin: 0.5, end: 1.5).animate(
CurvedAnimation(
parent: _foodControllers[index],
curve: Curves.easeInOut,
),
),
child: Icon(
foodIcons[index],
size: 40,
color: AppColors.lightPrimary.withOpacity(0.3),
),
),
),
);
});
}
void _navigateToHome() {
Future.delayed(Duration(seconds: 3), () {
context.go('/home');
});
}
@override
void dispose() {
for (final controller in _foodControllers) {
controller.dispose();
}
_questionMarkController.dispose();
_centerIconController.dispose();
super.dispose();
}
}
```
### 2. 메인 화면 (Bottom Navigation)
```dart
class MainScreen extends ConsumerStatefulWidget {
@override
_MainScreenState createState() => _MainScreenState();
}
class _MainScreenState extends ConsumerState<MainScreen> {
int _selectedIndex = 2; // 홈(랜덤선택)이 기본
final List<({IconData icon, String label})> _navItems = [
(icon: Icons.share, label: '공유'),
(icon: Icons.restaurant, label: '맛집'),
(icon: Icons.casino, label: '뽑기'),
(icon: Icons.calendar_month, label: '기록'),
(icon: Icons.settings, label: '설정'),
];
final List<Widget> _screens = [
ShareScreen(),
RestaurantListScreen(),
RandomSelectionScreen(),
CalendarScreen(),
SettingsScreen(),
];
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _screens,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
destinations: _navItems.map((item) => NavigationDestination(
icon: Icon(item.icon),
label: item.label,
)).toList(),
indicatorColor: AppColors.lightPrimary.withOpacity(0.2),
),
);
}
}
```
### 3. 랜덤 선택 화면 (홈)
```dart
class RandomSelectionScreen extends ConsumerStatefulWidget {
@override
_RandomSelectionScreenState createState() => _RandomSelectionScreenState();
}
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
double _distanceValue = 500;
List<String> _selectedCategories = [];
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final restaurants = ref.watch(restaurantListProvider);
final weather = ref.watch(weatherProvider);
final location = ref.watch(locationProvider);
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
appBar: AppBar(
title: Text('오늘 뭐 먹Z?'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: 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: EdgeInsets.all(20),
child: Column(
children: [
Icon(
Icons.restaurant,
size: 48,
color: AppColors.lightPrimary,
),
SizedBox(height: 12),
Text(
'${restaurants.length}',
style: AppTypography.heading1(isDark).copyWith(
color: AppColors.lightPrimary,
),
),
Text(
'등록된 맛집',
style: AppTypography.body2(isDark),
),
],
),
),
),
SizedBox(height: 16),
// 날씨 정보 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: EdgeInsets.all(16),
child: weather.when(
data: (weatherInfo) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildWeatherInfo('지금', weatherInfo.current, isDark),
Container(
width: 1,
height: 50,
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
),
_buildWeatherInfo('1시간 후', weatherInfo.nextHour, isDark),
],
),
loading: () => Center(
child: CircularProgressIndicator(
color: AppColors.lightPrimary,
),
),
error: (_, __) => Center(
child: Text(
'날씨 정보를 불러올 수 없습니다',
style: AppTypography.caption(isDark),
),
),
),
),
),
SizedBox(height: 16),
// 거리 설정 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'최대 거리',
style: AppTypography.heading2(isDark),
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
thumbShape: _CustomThumbShape(
restaurantCount: _getRestaurantCountInRange(),
isDark: isDark,
),
activeTrackColor: AppColors.lightPrimary,
inactiveTrackColor: AppColors.lightPrimary.withOpacity(0.3),
trackHeight: 4,
),
child: Slider(
value: _distanceValue,
min: 100,
max: 2000,
divisions: 19,
onChanged: (value) {
setState(() => _distanceValue = value);
},
),
),
),
SizedBox(width: 12),
Text(
'${_distanceValue.toInt()}m',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
SizedBox(height: 8),
Text(
'${_getRestaurantCountInRange()}개 맛집 포함',
style: AppTypography.caption(isDark),
),
],
),
),
),
SizedBox(height: 16),
// 카테고리 선택 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'카테고리',
style: AppTypography.heading2(isDark),
),
SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _buildCategoryChips(isDark),
),
],
),
),
),
SizedBox(height: 24),
// 추천받기 버튼
ElevatedButton(
onPressed: _canRecommend() ? _startRecommendation : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.play_arrow, size: 28),
SizedBox(width: 8),
Text(
'광고보고 추천받기',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
);
}
Widget _buildWeatherInfo(String label, WeatherData weather, bool isDark) {
return Column(
children: [
Text(label, style: AppTypography.caption(isDark)),
SizedBox(height: 8),
Icon(
weather.isRainy ? Icons.umbrella : Icons.wb_sunny,
color: weather.isRainy ? Colors.blue : Colors.orange,
size: 32,
),
SizedBox(height: 4),
Text(
'${weather.temperature}°C',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
weather.description,
style: AppTypography.caption(isDark),
),
],
);
}
List<Widget> _buildCategoryChips(bool isDark) {
final categories = ref.watch(categoriesProvider);
return categories.map((category) {
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.withOpacity(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),
),
);
}).toList();
}
int _getRestaurantCountInRange() {
final currentLocation = ref.read(locationProvider).valueOrNull;
if (currentLocation == null) return 0;
return ref.read(restaurantListProvider)
.where((r) => _calculateDistance(
currentLocation.latitude,
currentLocation.longitude,
r.latitude,
r.longitude,
) <= _distanceValue)
.length;
}
bool _canRecommend() {
return _getRestaurantCountInRange() > 0;
}
Future<void> _startRecommendation() async {
// 광고 표시
final adShown = await ref.read(adServiceProvider).showInterstitialAd();
if (adShown) {
// 추천 로직 실행
final currentLocation = ref.read(locationProvider).valueOrNull;
final weather = ref.read(weatherProvider).valueOrNull;
if (currentLocation != null && weather != null) {
final recommendation = await ref.read(recommendationEngineProvider)
.getRandomRecommendation(
restaurants: ref.read(restaurantListProvider),
settings: RecommendationSettings(
daysToExclude: ref.read(daysToExcludeProvider),
maxDistanceRainy: weather.current.isRainy ? 500 : _distanceValue.toInt(),
maxDistanceNormal: _distanceValue.toInt(),
selectedCategories: _selectedCategories,
),
currentPosition: currentLocation,
weather: weather,
);
if (recommendation != null) {
_showRecommendationDialog(recommendation);
// 추천 기록 저장
await ref.read(recommendationHistoryProvider.notifier)
.addRecommendation(recommendation);
// 알림 스케줄링
final notificationTime = ref.read(notificationTimeProvider);
await ref.read(visitNotificationServiceProvider)
.scheduleVisitCheckNotification(
restaurant: recommendation,
recommendationTime: DateTime.now(),
notificationDelay: notificationTime,
);
} else {
_showNoResultDialog();
}
}
}
}
void _showRecommendationDialog(Restaurant restaurant) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => RecommendationResultDialog(
restaurant: restaurant,
onConfirmVisit: () async {
await ref.read(visitRecordProvider.notifier).confirmVisit(restaurant);
Navigator.pop(context);
},
onReroll: () {
Navigator.pop(context);
_startRecommendation();
},
),
);
}
void _showNoResultDialog() {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
title: Text(
'추천 결과 없음',
style: AppTypography.heading2(isDark),
),
content: Text(
'설정하신 조건에 맞는 맛집이 없습니다.\n조건을 변경해보세요.',
style: AppTypography.body2(isDark),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'확인',
style: TextStyle(color: AppColors.lightPrimary),
),
),
],
),
);
}
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
// 거리 계산 로직 (이전과 동일)
const double earthRadius = 6371000; // 미터 단위
final dLat = _toRadians(lat2 - lat1);
final dLon = _toRadians(lon2 - lon1);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_toRadians(lat1)) * cos(_toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
double _toRadians(double degree) {
return degree * pi / 180;
}
}
// 커스텀 슬라이더 썸
class _CustomThumbShape extends SliderComponentShape {
final int restaurantCount;
final bool isDark;
_CustomThumbShape({required this.restaurantCount, required this.isDark});
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) {
return Size(60, 40);
}
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
final Canvas canvas = context.canvas;
// 배경
final paint = Paint()
..color = AppColors.lightPrimary
..style = PaintingStyle.fill;
final rect = RRect.fromRectAndRadius(
Rect.fromCenter(center: center, width: 50, height: 30),
Radius.circular(15),
);
canvas.drawRRect(rect, paint);
// 텍스트
final textSpan = TextSpan(
text: '$restaurantCount',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(
canvas,
center - Offset(textPainter.width / 2, textPainter.height / 2),
);
}
}
```
### 4. 추천 결과 Dialog
```dart
class RecommendationResultDialog extends StatelessWidget {
final Restaurant restaurant;
final VoidCallback onConfirmVisit;
final VoidCallback onReroll;
const RecommendationResultDialog({
Key? key,
required this.restaurant,
required this.onConfirmVisit,
required this.onReroll,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Dialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.restaurant,
size: 60,
color: AppColors.lightPrimary,
),
SizedBox(height: 20),
Text(
'오늘은 \'${restaurant.name}\' 어때요?',
style: AppTypography.heading1(isDark),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
restaurant.category,
style: AppTypography.caption(isDark).copyWith(
color: AppColors.lightPrimary,
),
),
),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildInfoChip(
Icons.location_on,
'${_calculateDistanceFromCurrent(restaurant).toInt()}m',
isDark,
),
SizedBox(width: 16),
_buildInfoChip(
Icons.calendar_today,
restaurant.lastVisitDate != null
? '${_daysSinceLastVisit(restaurant)}일 만'
: '첫 방문',
isDark,
),
],
),
if (restaurant.phoneNumber != null) ...[
SizedBox(height: 12),
_buildInfoChip(
Icons.phone,
restaurant.phoneNumber!,
isDark,
),
],
SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: onConfirmVisit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text('다녀왔음'),
),
),
SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: onReroll,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.lightPrimary,
side: BorderSide(color: AppColors.lightPrimary),
padding: EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text('다시 뽑기'),
),
),
],
),
],
),
),
);
}
Widget _buildInfoChip(IconData icon, String text, bool isDark) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary),
SizedBox(width: 4),
Text(text, style: AppTypography.body2(isDark)),
],
);
}
double _calculateDistanceFromCurrent(Restaurant restaurant) {
// 실제 구현시 현재 위치 기반 계산
return 350; // 임시값
}
int _daysSinceLastVisit(Restaurant restaurant) {
if (restaurant.lastVisitDate == null) return 0;
return DateTime.now().difference(restaurant.lastVisitDate!).inDays;
}
}
```
### 5. 맛집 리스트 화면
```dart
class RestaurantListScreen extends ConsumerStatefulWidget {
@override
_RestaurantListScreenState createState() => _RestaurantListScreenState();
}
class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final restaurants = ref.watch(restaurantListProvider);
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
appBar: AppBar(
title: Text('내 맛집 리스트'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: Icon(Icons.search),
onPressed: () {
showSearch(
context: context,
delegate: RestaurantSearchDelegate(ref: ref, isDark: isDark),
);
},
),
],
),
body: restaurants.isEmpty
? _buildEmptyState(isDark)
: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: restaurants.length,
itemBuilder: (context, index) {
return _buildRestaurantCard(restaurants[index], isDark);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddOptions,
backgroundColor: AppColors.lightPrimary,
child: Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildEmptyState(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.restaurant_menu,
size: 80,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
),
SizedBox(height: 16),
Text(
'아직 등록된 맛집이 없어요',
style: AppTypography.heading2(isDark),
),
SizedBox(height: 8),
Text(
'+ 버튼을 눌러 맛집을 추가해보세요',
style: AppTypography.body2(isDark),
),
],
),
);
}
Widget _buildRestaurantCard(Restaurant restaurant, bool isDark) {
return Card(
margin: EdgeInsets.only(bottom: 12),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: EdgeInsets.all(16),
leading: CircleAvatar(
backgroundColor: AppColors.lightPrimary.withOpacity(0.1),
radius: 24,
child: Icon(
restaurant.source == DataSource.NAVER
? Icons.location_on
: Icons.edit,
color: AppColors.lightPrimary,
size: 24,
),
),
title: Text(
restaurant.name,
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4),
Row(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
restaurant.category,
style: AppTypography.caption(isDark).copyWith(
color: AppColors.lightPrimary,
),
),
),
],
),
SizedBox(height: 4),
Text(
restaurant.roadAddress,
style: AppTypography.caption(isDark),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (restaurant.lastVisitDate != null) ...[
SizedBox(height: 4),
Row(
children: [
Icon(
Icons.schedule,
size: 12,
color: AppColors.lightPrimary,
),
SizedBox(width: 4),
Text(
'마지막 방문: ${_formatDate(restaurant.lastVisitDate!)}',
style: AppTypography.caption(isDark).copyWith(
color: AppColors.lightPrimary,
),
),
],
),
],
],
),
trailing: PopupMenuButton(
icon: Icon(
Icons.more_vert,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
),
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 20),
SizedBox(width: 8),
Text('수정'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 20, color: AppColors.lightError),
SizedBox(width: 8),
Text('삭제', style: TextStyle(color: AppColors.lightError)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
_editRestaurant(restaurant);
} else if (value == 'delete') {
_confirmDelete(restaurant);
}
},
),
onTap: () => _showRestaurantDetail(restaurant),
),
);
}
void _showAddOptions() {
final isDark = Theme.of(context).brightness == Brightness.dark;
showModalBottomSheet(
context: context,
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
borderRadius: BorderRadius.circular(2),
),
),
ListTile(
leading: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.link, color: AppColors.lightPrimary),
),
title: Text('네이버 지도 링크로 추가'),
subtitle: Text('네이버 지도앱에서 공유한 링크 붙여넣기'),
onTap: () {
Navigator.pop(context);
_addByNaverLink();
},
),
ListTile(
leading: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.search, color: AppColors.lightPrimary),
),
title: Text('상호명으로 검색'),
subtitle: Text('가게 이름으로 검색하여 추가'),
onTap: () {
Navigator.pop(context);
_addBySearch();
},
),
ListTile(
leading: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.edit, color: AppColors.lightPrimary),
),
title: Text('직접 입력'),
subtitle: Text('가게 정보를 직접 입력하여 추가'),
onTap: () {
Navigator.pop(context);
_addManually();
},
),
SizedBox(height: 8),
],
),
);
},
);
}
Future<void> _addByNaverLink() async {
final link = await _showLinkInputDialog();
if (link != null && link.isNotEmpty) {
try {
_showLoadingDialog();
final restaurant = await ref.read(naverMapServiceProvider)
.parseNaverMapLink(link);
Navigator.pop(context); // 로딩 다이얼로그 닫기
if (restaurant != null) {
await ref.read(restaurantListProvider.notifier)
.addRestaurant(restaurant);
_showSuccessSnackBar('맛집이 추가되었습니다!');
} else {
_showErrorSnackBar('올바른 네이버 지도 링크가 아닙니다.');
}
} catch (e) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
_showErrorSnackBar('오류가 발생했습니다.');
}
}
}
Future<void> _addBySearch() async {
final query = await _showSearchInputDialog();
if (query != null && query.isNotEmpty) {
try {
_showLoadingDialog();
final currentLocation = ref.read(locationProvider).valueOrNull;
if (currentLocation == null) {
Navigator.pop(context);
_showErrorSnackBar('위치 정보를 가져올 수 없습니다.');
return;
}
final results = await ref.read(naverMapServiceProvider)
.searchRestaurants(
query,
latitude: currentLocation.latitude,
longitude: currentLocation.longitude,
);
Navigator.pop(context); // 로딩 다이얼로그 닫기
if (results.isNotEmpty) {
final selected = await _showSearchResultsDialog(results);
if (selected != null) {
await ref.read(restaurantListProvider.notifier)
.addRestaurant(selected);
_showSuccessSnackBar('맛집이 추가되었습니다!');
}
} else {
_showErrorSnackBar('검색 결과가 없습니다.');
}
} catch (e) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
_showErrorSnackBar('검색 중 오류가 발생했습니다.');
}
}
}
Future<void> _addManually() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ManualRestaurantInputScreen(),
),
);
if (result != null && result is Restaurant) {
await ref.read(restaurantListProvider.notifier)
.addRestaurant(result);
_showSuccessSnackBar('맛집이 추가되었습니다!');
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) return '오늘';
if (difference == 1) return '어제';
if (difference < 7) return '$difference일';
if (difference < 30) return '${(difference / 7).floor()}주 전';
if (difference < 365) return '${(difference / 30).floor()}개월 전';
return '${(difference / 365).floor()}년 전';
}
}
```
### 6. 공유 화면
```dart
class ShareScreen extends ConsumerStatefulWidget {
@override
_ShareScreenState createState() => _ShareScreenState();
}
class _ShareScreenState extends ConsumerState<ShareScreen> {
String? _shareCode;
bool _isScanning = false;
List<ShareDevice>? _nearbyDevices;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
appBar: AppBar(
title: Text('리스트 공유'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
children: [
// 공유받기 섹션
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.download_rounded,
size: 48,
color: AppColors.lightPrimary,
),
),
SizedBox(height: 16),
Text(
'리스트 공유받기',
style: AppTypography.heading2(isDark),
),
SizedBox(height: 8),
Text(
'다른 사람의 맛집 리스트를 받아보세요',
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
SizedBox(height: 20),
if (_shareCode != null) ...[
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.lightPrimary.withOpacity(0.3),
width: 2,
),
),
child: Text(
_shareCode!,
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 6,
color: AppColors.lightPrimary,
),
),
),
SizedBox(height: 12),
Text(
'이 코드를 상대방에게 알려주세요',
style: AppTypography.caption(isDark),
),
SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_shareCode = null;
});
// Bluetooth 리스닝 중지
ref.read(bluetoothServiceProvider).stopListening();
},
icon: Icon(Icons.close),
label: Text('취소'),
style: TextButton.styleFrom(
foregroundColor: AppColors.lightError,
),
),
] else
ElevatedButton.icon(
onPressed: _generateShareCode,
icon: Icon(Icons.qr_code),
label: Text('공유 코드 생성'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
),
),
SizedBox(height: 16),
// 공유하기 섹션
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightSecondary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.upload_rounded,
size: 48,
color: AppColors.lightSecondary,
),
),
SizedBox(height: 16),
Text(
'내 리스트 공유하기',
style: AppTypography.heading2(isDark),
),
SizedBox(height: 8),
Text(
'내 맛집 리스트를 다른 사람과 공유하세요',
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
SizedBox(height: 20),
if (_isScanning && _nearbyDevices != null) ...[
Container(
constraints: BoxConstraints(maxHeight: 200),
child: _nearbyDevices!.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: AppColors.lightSecondary,
),
SizedBox(height: 16),
Text(
'주변 기기를 검색 중...',
style: AppTypography.caption(isDark),
),
],
),
)
: ListView.builder(
shrinkWrap: true,
itemCount: _nearbyDevices!.length,
itemBuilder: (context, index) {
final device = _nearbyDevices![index];
return Card(
margin: EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
Icons.phone_android,
color: AppColors.lightSecondary,
),
title: Text(
device.code,
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.bold,
),
),
subtitle: Text('탭하여 전송'),
trailing: Icon(
Icons.send,
color: AppColors.lightSecondary,
),
onTap: () => _sendList(device.code),
),
);
},
),
),
SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_isScanning = false;
_nearbyDevices = null;
});
},
icon: Icon(Icons.stop),
label: Text('스캔 중지'),
style: TextButton.styleFrom(
foregroundColor: AppColors.lightError,
),
),
] else
ElevatedButton.icon(
onPressed: _scanDevices,
icon: Icon(Icons.radar),
label: Text('주변 기기 스캔'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightSecondary,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
),
),
],
),
),
);
}
Future<void> _generateShareCode() async {
// 광고 표시
final adShown = await ref.read(adServiceProvider).showInterstitialAd();
if (adShown) {
// 6자리 랜덤 코드 생성
final random = Random();
final code = List.generate(6, (_) => random.nextInt(10)).join();
setState(() {
_shareCode = code;
});
// Bluetooth 수신 대기 시작
ref.read(bluetoothServiceProvider).startListening(code);
// 리스트 수신 대기
ref.read(bluetoothServiceProvider).onDataReceived.listen((data) async {
// 수신된 데이터 파싱
final receivedRestaurants = _parseReceivedData(data);
// 광고 표시
final adShown = await ref.read(adServiceProvider).showInterstitialAd();
if (adShown) {
// 리스트 병합
await _mergeRestaurantList(receivedRestaurants);
}
});
}
}
Future<void> _scanDevices() async {
// 블루투스 권한 확인
final hasPermission = await PermissionService.checkAndRequestBluetoothPermission();
if (!hasPermission) {
_showErrorSnackBar('블루투스 권한이 필요합니다.');
return;
}
setState(() {
_isScanning = true;
_nearbyDevices = [];
});
try {
// Bluetooth 스캔
final devices = await ref.read(bluetoothServiceProvider).scanNearbyDevices();
setState(() {
_nearbyDevices = devices;
});
} catch (e) {
setState(() {
_isScanning = false;
});
_showErrorSnackBar('스캔 중 오류가 발생했습니다.');
}
}
Future<void> _sendList(String targetCode) async {
try {
_showLoadingDialog('리스트 전송 중...');
await ref.read(bluetoothServiceProvider).sendRestaurantList(
targetCode,
ref.read(restaurantListProvider),
);
Navigator.pop(context); // 로딩 다이얼로그 닫기
_showSuccessSnackBar('리스트 전송 완료!');
setState(() {
_isScanning = false;
_nearbyDevices = null;
});
} catch (e) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
_showErrorSnackBar('전송 실패: $e');
}
}
List<Restaurant> _parseReceivedData(String data) {
// JSON 파싱 및 Restaurant 객체 리스트 변환
final jsonList = jsonDecode(data) as List;
return jsonList.map((json) => Restaurant.fromJson(json)).toList();
}
Future<void> _mergeRestaurantList(List<Restaurant> receivedList) async {
final currentList = ref.read(restaurantListProvider);
final mergedList = <Restaurant>[];
for (final restaurant in receivedList) {
// 중복 검사 (이름 + 주소 조합)
final isDuplicate = currentList.any((existing) =>
existing.name == restaurant.name &&
existing.roadAddress == restaurant.roadAddress
);
if (!isDuplicate) {
mergedList.add(restaurant);
}
}
// 새로운 항목만 추가
for (final restaurant in mergedList) {
await ref.read(restaurantListProvider.notifier).addRestaurant(restaurant);
}
_showSuccessSnackBar('${mergedList.length}개의 새로운 맛집이 추가되었습니다!');
setState(() {
_shareCode = null;
});
}
void _showLoadingDialog(String message) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => Dialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
child: Padding(
padding: EdgeInsets.all(20),
child: Row(
children: [
CircularProgressIndicator(color: AppColors.lightPrimary),
SizedBox(width: 20),
Text(message),
],
),
),
),
);
}
void _showSuccessSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppColors.lightPrimary,
),
);
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppColors.lightError,
),
);
}
}
```
### 7. 캘린더(히스토리) 화면
```dart
class CalendarScreen extends ConsumerStatefulWidget {
@override
_CalendarScreenState createState() => _CalendarScreenState();
}
class _CalendarScreenState extends ConsumerState<CalendarScreen> {
late DateTime _selectedDay;
late DateTime _focusedDay;
CalendarFormat _calendarFormat = CalendarFormat.month;
@override
void initState() {
super.initState();
_selectedDay = DateTime.now();
_focusedDay = DateTime.now();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final visitRecords = ref.watch(visitRecordsProvider);
final recommendations = ref.watch(recommendationHistoryProvider);
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
appBar: AppBar(
title: Text('방문 기록'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: Column(
children: [
// 캘린더
Card(
margin: EdgeInsets.all(16),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: TableCalendar(
firstDay: DateTime.utc(2025, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() {
_calendarFormat = format;
});
},
calendarStyle: CalendarStyle(
outsideDaysVisible: false,
selectedDecoration: BoxDecoration(
color: AppColors.lightPrimary,
shape: BoxShape.circle,
),
todayDecoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.5),
shape: BoxShape.circle,
),
markersMaxCount: 2,
markerDecoration: BoxDecoration(
color: AppColors.lightSecondary,
shape: BoxShape.circle,
),
weekendTextStyle: TextStyle(
color: AppColors.lightError,
),
),
headerStyle: HeaderStyle(
formatButtonVisible: true,
titleCentered: true,
formatButtonShowsNext: false,
formatButtonDecoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
formatButtonTextStyle: TextStyle(
color: AppColors.lightPrimary,
),
),
eventLoader: (day) {
return _getEventsForDay(day, visitRecords, recommendations);
},
calendarBuilders: CalendarBuilders(
markerBuilder: (context, day, events) {
if (events.isEmpty) return null;
return Positioned(
bottom: 1,
child: Row(
mainAxisSize: MainAxisSize.min,
children: events.take(2).map((event) {
if (event is VisitRecord && event.isConfirmed) {
// 방문 완료
return Container(
width: 7,
height: 7,
margin: EdgeInsets.symmetric(horizontal: 1.5),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
);
} else if (event is RecommendationRecord && !event.visited) {
// 추천만 받음
return Container(
width: 7,
height: 7,
margin: EdgeInsets.symmetric(horizontal: 1.5),
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
);
}
return SizedBox.shrink();
}).toList(),
),
);
},
),
),
),
// 범례
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend('추천받음', Colors.orange, isDark),
SizedBox(width: 24),
_buildLegend('방문완료', Colors.green, isDark),
],
),
),
SizedBox(height: 16),
// 선택된 날짜의 기록
Expanded(
child: _buildDayRecords(_selectedDay, isDark),
),
],
),
);
}
Widget _buildLegend(String label, Color color, bool isDark) {
return Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
SizedBox(width: 6),
Text(label, style: AppTypography.body2(isDark)),
],
);
}
Widget _buildDayRecords(DateTime day, bool isDark) {
final records = _getEventsForDay(
day,
ref.watch(visitRecordsProvider),
ref.watch(recommendationHistoryProvider),
);
if (records.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event_available,
size: 48,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
),
SizedBox(height: 16),
Text(
'이날의 기록이 없습니다',
style: AppTypography.body2(isDark),
),
],
),
);
}
return ListView.builder(
padding: EdgeInsets.all(16),
itemCount: records.length,
itemBuilder: (context, index) {
final record = records[index];
if (record is VisitRecord) {
return _buildVisitCard(record, isDark);
} else if (record is RecommendationRecord) {
return _buildRecommendationCard(record, isDark);
}
return SizedBox.shrink();
},
);
}
Widget _buildVisitCard(VisitRecord record, bool isDark) {
final restaurant = ref.watch(restaurantProvider(record.restaurantId));
return Card(
margin: EdgeInsets.only(bottom: 12),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: EdgeInsets.all(16),
leading: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.check_circle, color: Colors.green, size: 24),
),
title: Text(
restaurant?.name ?? '알 수 없는 식당',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4),
Text(
'방문 완료 • ${_formatTime(record.visitDate)}',
style: AppTypography.caption(isDark),
),
if (restaurant != null) ...[
SizedBox(height: 2),
Text(
restaurant.category,
style: AppTypography.caption(isDark).copyWith(
color: AppColors.lightPrimary,
),
),
],
],
),
),
);
}
Widget _buildRecommendationCard(RecommendationRecord record, bool isDark) {
final restaurant = ref.watch(restaurantProvider(record.restaurantId));
return Card(
margin: EdgeInsets.only(bottom: 12),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: EdgeInsets.all(16),
leading: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.restaurant, color: Colors.orange, size: 24),
),
title: Text(
restaurant?.name ?? '알 수 없는 식당',
style: AppTypography.body1(isDark).copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4),
Text(
'추천받음 • ${_formatTime(record.recommendationDate)}',
style: AppTypography.caption(isDark),
),
if (restaurant != null) ...[
SizedBox(height: 2),
Text(
restaurant.category,
style: AppTypography.caption(isDark).copyWith(
color: AppColors.lightPrimary,
),
),
],
],
),
trailing: !record.visited
? TextButton(
onPressed: () => _confirmVisit(record, restaurant),
child: Text('방문'),
style: TextButton.styleFrom(
foregroundColor: AppColors.lightPrimary,
),
)
: null,
),
);
}
Future<void> _confirmVisit(RecommendationRecord record, Restaurant? restaurant) async {
if (restaurant != null) {
await ref.read(visitRecordProvider.notifier).confirmVisit(restaurant);
await ref.read(recommendationHistoryProvider.notifier)
.markAsVisited(record.id);
}
}
List<dynamic> _getEventsForDay(
DateTime day,
List<VisitRecord> visitRecords,
List<RecommendationRecord> recommendations,
) {
final events = <dynamic>[];
// 방문 기록
events.addAll(
visitRecords.where((record) => isSameDay(record.visitDate, day)),
);
// 추천 기록
events.addAll(
recommendations.where((record) => isSameDay(record.recommendationDate, day)),
);
return events;
}
String _formatTime(DateTime dateTime) {
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
```
### 8. 설정 화면
```dart
class SettingsScreen extends ConsumerStatefulWidget {
@override
_SettingsScreenState createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final daysToExclude = ref.watch(daysToExcludeProvider);
final notificationTime = ref.watch(notificationTimeProvider);
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
appBar: AppBar(
title: Text('설정'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: ListView(
children: [
// 추천 설정
_buildSection(
'추천 설정',
[
Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
title: Text('중복 방문 제외 기간'),
subtitle: Text('$daysToExclude일 이내 방문한 곳은 추천에서 제외'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.remove_circle_outline),
onPressed: daysToExclude > 1
? () => ref.read(daysToExcludeProvider.notifier).state--
: null,
color: AppColors.lightPrimary,
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$daysToExclude일',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.lightPrimary,
),
),
),
IconButton(
icon: Icon(Icons.add_circle_outline),
onPressed: () => ref.read(daysToExcludeProvider.notifier).state++,
color: AppColors.lightPrimary,
),
],
),
),
),
],
isDark,
),
// 권한 설정
_buildSection(
'권한 관리',
[
FutureBuilder<PermissionStatus>(
future: Permission.location.status,
builder: (context, snapshot) {
final status = snapshot.data;
final isGranted = status?.isGranted ?? false;
return _buildPermissionTile(
icon: Icons.location_on,
title: '위치 권한',
subtitle: '주변 맛집 거리 계산에 필요',
isGranted: isGranted,
onRequest: _requestLocationPermission,
isDark: isDark,
);
},
),
FutureBuilder<PermissionStatus>(
future: Permission.bluetooth.status,
builder: (context, snapshot) {
final status = snapshot.data;
final isGranted = status?.isGranted ?? false;
return _buildPermissionTile(
icon: Icons.bluetooth,
title: '블루투스 권한',
subtitle: '맛집 리스트 공유에 필요',
isGranted: isGranted,
onRequest: _requestBluetoothPermission,
isDark: isDark,
);
},
),
FutureBuilder<PermissionStatus>(
future: Permission.notification.status,
builder: (context, snapshot) {
final status = snapshot.data;
final isGranted = status?.isGranted ?? false;
return _buildPermissionTile(
icon: Icons.notifications,
title: '알림 권한',
subtitle: '방문 확인 알림에 필요',
isGranted: isGranted,
onRequest: _requestNotificationPermission,
isDark: isDark,
);
},
),
],
isDark,
),
// 알림 설정
_buildSection(
'알림 설정',
[
Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
title: Text('방문 확인 알림 시간'),
subtitle: Text('추천 후 ${notificationTime.inMinutes}분 뒤 알림'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.remove_circle_outline),
onPressed: notificationTime.inMinutes > 60
? () => ref.read(notificationTimeProvider.notifier).state =
Duration(minutes: notificationTime.inMinutes - 1)
: null,
color: AppColors.lightPrimary,
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_formatDuration(notificationTime),
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.lightPrimary,
),
),
),
IconButton(
icon: Icon(Icons.add_circle_outline),
onPressed: notificationTime.inMinutes < 360
? () => ref.read(notificationTimeProvider.notifier).state =
Duration(minutes: notificationTime.inMinutes + 1)
: null,
color: AppColors.lightPrimary,
),
],
),
),
),
],
isDark,
),
// 테마 설정
_buildSection(
'테마',
[
Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Icon(
isDark ? Icons.dark_mode : Icons.light_mode,
color: AppColors.lightPrimary,
),
title: Text('테마 설정'),
subtitle: Text(isDark ? '다크 모드' : '라이트 모드'),
trailing: Switch(
value: isDark,
onChanged: (value) {
ref.read(themeProvider.notifier).toggleTheme();
},
activeColor: AppColors.lightPrimary,
),
),
),
],
isDark,
),
// 앱 정보
_buildSection(
'앱 정보',
[
Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
ListTile(
leading: Icon(Icons.info_outline, color: AppColors.lightPrimary),
title: Text('버전'),
subtitle: Text('1.0.0'),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.person_outline, color: AppColors.lightPrimary),
title: Text('개발자'),
subtitle: Text('NatureBridgeAI'),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.description_outlined, color: AppColors.lightPrimary),
title: Text('오픈소스 라이센스'),
trailing: Icon(Icons.arrow_forward_ios, size: 16),
onTap: () => showLicensePage(
context: context,
applicationName: '오늘 뭐 먹Z?',
applicationVersion: '1.0.0',
applicationLegalese: '© 2025 NatureBridgeAI',
),
),
],
),
),
],
isDark,
),
SizedBox(height: 24),
],
),
);
}
Widget _buildSection(String title, List<Widget> children, bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text(
title,
style: AppTypography.body2(isDark).copyWith(
color: AppColors.lightPrimary,
fontWeight: FontWeight.w600,
),
),
),
...children,
],
);
}
Widget _buildPermissionTile({
required IconData icon,
required String title,
required String subtitle,
required bool isGranted,
required VoidCallback onRequest,
required bool isDark,
}) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Icon(icon, color: isGranted ? Colors.green : Colors.grey),
title: Text(title),
subtitle: Text(subtitle),
trailing: isGranted
? Icon(Icons.check_circle, color: Colors.green)
: ElevatedButton(
onPressed: onRequest,
child: Text('허용'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
enabled: !isGranted,
),
);
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
if (hours == 0) {
return '$minutes분';
} else if (minutes == 0) {
return '$hours시간';
} else {
return '$hours시간 $minutes분';
}
}
Future<void> _requestLocationPermission() async {
final status = await Permission.location.request();
if (status.isGranted) {
setState(() {});
} else if (status.isPermanentlyDenied) {
_showPermissionDialog('위치');
}
}
Future<void> _requestBluetoothPermission() async {
final status = await Permission.bluetooth.request();
if (status.isGranted) {
setState(() {});
} else if (status.isPermanentlyDenied) {
_showPermissionDialog('블루투스');
}
}
Future<void> _requestNotificationPermission() async {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() {});
} else if (status.isPermanentlyDenied) {
_showPermissionDialog('알림');
}
}
void _showPermissionDialog(String permissionName) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
title: Text('권한 설정 필요'),
content: Text('$permissionName 권한이 거부되었습니다. 설정에서 직접 권한을 허용해주세요.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('취소'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: Text('설정으로 이동'),
style: TextButton.styleFrom(
foregroundColor: AppColors.lightPrimary,
),
),
],
),
);
}
}
```
## 핵심 기능 구현
### 1. 네이버 지도 연동
이전 문서와 동일한 구현 내용 유지
### 2. P2P 리스트 공유 기능
이전 문서와 동일한 구현 내용 유지
### 3. 랜덤 추천 엔진
이전 문서와 동일한 구현 내용 유지
### 4. 날씨 정보 연동
이전 문서와 동일한 구현 내용 유지
### 5. 방문 기록 관리
이전 문서와 동일한 구현 내용 유지
### 6. 광고 통합
이전 문서와 동일한 구현 내용 유지
## 개발 로드맵 상세
### Phase 1: UI/UX 구현 (1-2주)
1. **디자인 시스템 구축**
- 네이버 스타일 컬러 팔레트
- 타이포그래피 시스템
- 라이트/다크 테마
2. **기본 화면 구현**
- 스플래시 화면 (애니메이션 포함)
- 메인 네비게이션 구조
- 5개 주요 화면 레이아웃
3. **컴포넌트 구현**
- 카드 컴포넌트
- 다이얼로그
- 바텀시트
- 커스텀 위젯
### Phase 2: 핵심 기능 (3-4주)
1. **데이터 모델 및 로컬 DB**
- Hive 설정
- 데이터 모델 구현
- Repository 패턴
2. **네이버 지도 연동**
- API 키 설정
- URL 파싱
- 검색 기능
3. **랜덤 추천 시스템**
- 필터링 로직
- 거리 계산
- 카테고리 관리
4. **방문 기록 관리**
- 알림 스케줄링
- 히스토리 저장
### Phase 3: 고급 기능 (5-6주)
1. **Bluetooth P2P 공유**
- 권한 처리
- 데이터 전송
- 병합 로직
2. **날씨 연동**
- 기상청 API
- 위치 기반 날씨
3. **광고 시스템**
- AdMob 통합
- 인터스티셜 광고
### Phase 4: 최적화 및 출시 (7-8주)
1. **성능 최적화**
2. **테스트 및 버그 수정**
3. **스토어 출시 준비**
## 테스트 전략
이전 문서와 동일한 테스트 전략 유지
## 보안 및 개인정보 보호
이전 문서와 동일한 보안 전략 유지
## 성능 최적화
이전 문서와 동일한 최적화 전략 유지
## 배포 준비
이전 문서와 동일한 배포 설정 유지
## 수익화 전략
### 광고 배치
1. **추천 시**: 필수 인터스티셜 광고
2. **공유 코드 생성 시**: 15초 전면 광고
3. **리스트 병합 시**: 15초 전면 광고
### 예상 수익
- MAU: 300,000명
- 일 평균 추천: 2.5회
- 월 예상 수익: ₩67,500,000
## 차후 구현 사항
1. **Firebase 통합**
- Crashlytics
- Analytics
- Performance Monitoring
2. **프리미엄 기능**
- 광고 제거
- CSV 내보내기
- 무제한 저장
3. **추가 비즈니스 모델**
- B2B API
- 리워드 시스템
- AI 추천
이 문서는 "오늘 뭐 먹Z?" 앱의 완전한 개발 가이드로, UI/UX를 우선으로 한 구현 순서와 모든 화면 설계를 포함하고 있습니다.