2913 lines
92 KiB
Markdown
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를 우선으로 한 구현 순서와 모든 화면 설계를 포함하고 있습니다. |