feat(app): add vworld geocoding and native ads placeholders

This commit is contained in:
JiWoong Sul
2025-12-03 14:30:20 +09:00
parent d101f7d0dc
commit 3ff9e5f837
23 changed files with 1108 additions and 540 deletions

View File

@@ -25,7 +25,7 @@ android {
applicationId = "com.naturebridgeai.lunchpick" applicationId = "com.naturebridgeai.lunchpick"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 23 minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName

View File

@@ -7,6 +7,16 @@
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- 부팅 시 실행 권한 (예약된 알림 유지) --> <!-- 부팅 시 실행 권한 (예약된 알림 유지) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 위치 권한 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 블루투스 권한 (Android 12+) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- 블루투스 권한 (Android 11 이하 호환) -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<application <application
android:label="lunchpick" android:label="lunchpick"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -37,4 +37,5 @@
- `doc/restaurant_data/store.db`가 변경되면 `flutter pub run build_runner build --delete-conflicting-outputs` 또는 `watch`를 실행할 때마다 `assets/data/store_seed.json``store_seed.meta.json`이 자동으로 재생성/병합됩니다(중복 제외, 해시 기반 버전 기록). - `doc/restaurant_data/store.db`가 변경되면 `flutter pub run build_runner build --delete-conflicting-outputs` 또는 `watch`를 실행할 때마다 `assets/data/store_seed.json``store_seed.meta.json`이 자동으로 재생성/병합됩니다(중복 제외, 해시 기반 버전 기록).
- 개발 중에는 `flutter pub run build_runner watch --delete-conflicting-outputs`를 켜두고, CI/빌드 파이프라인에도 동일 명령을 pre-step으로 추가하면 배포 전에 항상 최신 시드가 패키징됩니다. - 개발 중에는 `flutter pub run build_runner watch --delete-conflicting-outputs`를 켜두고, CI/빌드 파이프라인에도 동일 명령을 pre-step으로 추가하면 배포 전에 항상 최신 시드가 패키징됩니다.
flutter run -d chrome --dart-define=KMA_SERVICE_KEY=MTg0Y2UzN2VlZmFjMGJlNWNmY2JjYWUyNmUxZDZlNjIzYmU5MDYyZmY3NDM5NjVlMzkwZmNkMzgzMGY3MTFiZg== flutter run -d R3CN70AJJ6Y --debug --uninstall-first --dart-define=KMA_SERVICE_KEY=MTg0Y2UzN2VlZmFjMGJlNWNmY2JjYWUyNmUxZDZlNjIzYmU5MDYyZmY3NDM5NjVlMzkwZmNkMzgzMGY3MTFiZg== --dart-define=VWORLD_API_KEY=7E33D818-6B06-3957-BCEF-E37EF702FAD6
빌드시 키값을 포함해야 함.

View File

@@ -29,6 +29,14 @@ class ApiKeys {
static const String naverLocalSearchEndpoint = static const String naverLocalSearchEndpoint =
'https://openapi.naver.com/v1/search/local.json'; 'https://openapi.naver.com/v1/search/local.json';
// VWorld 지오코딩 키 (dart-define: VWORLD_API_KEY, base64 권장)
static const String _encodedVworldApiKey = String.fromEnvironment(
'VWORLD_API_KEY',
defaultValue: '',
);
static String get vworldApiKey => _decodeIfNeeded(_encodedVworldApiKey);
static bool areKeysConfigured() { static bool areKeysConfigured() {
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty; return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
} }

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:lunchpick/core/constants/api_keys.dart';
import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:lunchpick/core/utils/app_logger.dart';
/// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스 /// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스
@@ -16,6 +17,13 @@ class GeocodingService {
Future<({double latitude, double longitude})?> geocode(String address) async { Future<({double latitude, double longitude})?> geocode(String address) async {
if (address.trim().isEmpty) return null; if (address.trim().isEmpty) return null;
// 1차: VWorld 지오코딩 시도 (키가 존재할 때만)
final vworldResult = await _geocodeWithVworld(address);
if (vworldResult != null) {
return vworldResult;
}
// 2차: Nominatim (fallback)
try { try {
final uri = Uri.parse( final uri = Uri.parse(
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}', '$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}',
@@ -55,4 +63,62 @@ class GeocodingService {
({double latitude, double longitude}) defaultCoordinates() { ({double latitude, double longitude}) defaultCoordinates() {
return (latitude: _fallbackLatitude, longitude: _fallbackLongitude); return (latitude: _fallbackLatitude, longitude: _fallbackLongitude);
} }
Future<({double latitude, double longitude})?> _geocodeWithVworld(
String address,
) async {
final apiKey = ApiKeys.vworldApiKey;
if (apiKey.isEmpty) {
return null;
}
try {
final uri = Uri.https('api.vworld.kr', '/req/address', {
'service': 'address',
'request': 'getcoord',
'format': 'json',
'type': 'road', // 도로명 주소 기준
'key': apiKey,
'address': address,
});
final response = await http.get(
uri,
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
);
if (response.statusCode != 200) {
AppLogger.debug(
'[GeocodingService] VWorld 실패 status: ${response.statusCode}',
);
return null;
}
final Map<String, dynamic> json = jsonDecode(response.body);
final responseNode = json['response'] as Map<String, dynamic>?;
if (responseNode == null || responseNode['status'] != 'OK') {
AppLogger.debug('[GeocodingService] VWorld 응답 오류: ${response.body}');
return null;
}
// VWorld 포인트는 WGS84 lon/lat 순서(x=lon, y=lat)
final result = responseNode['result'] as Map<String, dynamic>?;
final point = result?['point'] as Map<String, dynamic>?;
final x = point?['x']?.toString();
final y = point?['y']?.toString();
final lon = x != null ? double.tryParse(x) : null;
final lat = y != null ? double.tryParse(y) : null;
if (lat == null || lon == null) {
AppLogger.debug(
'[GeocodingService] VWorld 좌표 파싱 실패: ${point.toString()}',
);
return null;
}
return (latitude: lat, longitude: lon);
} catch (e) {
AppLogger.debug('[GeocodingService] VWorld 예외: $e');
return null;
}
}
} }

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:lunchpick/core/utils/app_logger.dart';
import '../../../core/network/network_client.dart'; import '../../../core/network/network_client.dart';
@@ -37,6 +38,12 @@ class NaverUrlResolver {
return location; return location;
} }
// Location이 없는 경우, http.Client로 리다이렉트를 끝까지 따라가며 최종 URL 추출 (fallback)
final expanded = await _followRedirectsWithHttp(shortUrl);
if (expanded != null) {
return expanded;
}
// 리다이렉트가 없으면 원본 URL 반환 // 리다이렉트가 없으면 원본 URL 반환
return shortUrl; return shortUrl;
} on DioException catch (e) { } on DioException catch (e) {
@@ -54,6 +61,12 @@ class NaverUrlResolver {
} }
} }
// Dio 실패 시 fallback으로 http.Client 리다이렉트 추적 시도
final expanded = await _followRedirectsWithHttp(shortUrl);
if (expanded != null) {
return expanded;
}
// 오류 발생 시 원본 URL 반환 // 오류 발생 시 원본 URL 반환
return shortUrl; return shortUrl;
} }
@@ -161,4 +174,26 @@ class NaverUrlResolver {
void dispose() { void dispose() {
// 필요시 리소스 정리 // 필요시 리소스 정리
} }
/// http.Client를 사용해 리다이렉트를 끝까지 따라가며 최종 URL을 반환한다.
/// 실패 시 null 반환.
Future<String?> _followRedirectsWithHttp(String shortUrl) async {
final client = http.Client();
try {
final request = http.Request('HEAD', Uri.parse(shortUrl))
..followRedirects = true
..maxRedirects = 5;
final response = await client.send(request);
return response.request?.url.toString();
} catch (e, stackTrace) {
AppLogger.error(
'_followRedirectsWithHttp error: $e',
error: e,
stackTrace: stackTrace,
);
return null;
} finally {
client.close();
}
}
} }

View File

@@ -23,8 +23,9 @@ class NaverMapParser {
// 정규식 패턴 // 정규식 패턴
static final RegExp _placeIdRegex = RegExp( static final RegExp _placeIdRegex = RegExp(
r'/p/(?:restaurant|entry/place)/(\d+)', r'(?:/p/(?:restaurant|entry/place)/|/place/)(\d+)',
); );
static final RegExp _pinIdRegex = RegExp(r'pinId["=](\d+)');
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$'); static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
// 기본 좌표 (서울 시청) // 기본 좌표 (서울 시청)
@@ -62,7 +63,7 @@ class NaverMapParser {
throw NaverMapParseException('이미 dispose된 파서입니다'); throw NaverMapParseException('이미 dispose된 파서입니다');
} }
try { try {
AppLogger.debug('NaverMapParser: Starting to parse URL: $url'); AppLogger.debug('[naver_url] 원본 URL 수신: $url');
// URL 유효성 검증 // URL 유효성 검증
if (!_isValidNaverUrl(url)) { if (!_isValidNaverUrl(url)) {
@@ -72,7 +73,7 @@ class NaverMapParser {
// 짧은 URL인 경우 리다이렉트 처리 // 짧은 URL인 경우 리다이렉트 처리
final String finalUrl = await _apiClient.resolveShortUrl(url); final String finalUrl = await _apiClient.resolveShortUrl(url);
AppLogger.debug('NaverMapParser: Final URL after redirect: $finalUrl'); AppLogger.debug('[naver_url] resolveShortUrl 결과: $finalUrl');
// Place ID 추출 (10자리 숫자) // Place ID 추출 (10자리 숫자)
final String? placeId = _extractPlaceId(finalUrl); final String? placeId = _extractPlaceId(finalUrl);
@@ -80,13 +81,12 @@ class NaverMapParser {
// 짧은 URL에서 직접 ID 추출 시도 // 짧은 URL에서 직접 ID 추출 시도
final shortUrlId = _extractShortUrlId(url); final shortUrlId = _extractShortUrlId(url);
if (shortUrlId != null) { if (shortUrlId != null) {
AppLogger.debug( AppLogger.debug('[naver_url] 단축 URL ID를 Place ID로 사용: $shortUrlId');
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
);
return _createFallbackRestaurant(shortUrlId, url); return _createFallbackRestaurant(shortUrlId, url);
} }
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url'); throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
} }
AppLogger.debug('[naver_url] Place ID 추출 성공: $placeId');
// 단축 URL인 경우 특별 처리 // 단축 URL인 경우 특별 처리
final isShortUrl = url.contains('naver.me'); final isShortUrl = url.contains('naver.me');
@@ -102,7 +102,10 @@ class NaverMapParser {
userLatitude, userLatitude,
userLongitude, userLongitude,
); );
AppLogger.debug('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}'); AppLogger.debug(
'[naver_url] LocalSearch 파싱 성공: '
'name=${restaurant.name}, road=${restaurant.roadAddress}',
);
return restaurant; return restaurant;
} catch (e, stackTrace) { } catch (e, stackTrace) {
AppLogger.error( AppLogger.error(
@@ -120,6 +123,12 @@ class NaverMapParser {
userLatitude: userLatitude, userLatitude: userLatitude,
userLongitude: userLongitude, userLongitude: userLongitude,
); );
AppLogger.debug(
'[naver_url] GraphQL/검색 파싱 결과 요약: '
'name=${restaurantData['name']}, '
'road=${restaurantData['roadAddress']}, '
'phone=${restaurantData['phone']}',
);
return _createRestaurant(restaurantData, placeId, finalUrl); return _createRestaurant(restaurantData, placeId, finalUrl);
} catch (e) { } catch (e) {
if (e is NaverMapParseException) { if (e is NaverMapParseException) {
@@ -150,7 +159,11 @@ class NaverMapParser {
/// URL에서 Place ID 추출 /// URL에서 Place ID 추출
String? _extractPlaceId(String url) { String? _extractPlaceId(String url) {
final match = _placeIdRegex.firstMatch(url); final match = _placeIdRegex.firstMatch(url);
return match?.group(1); if (match != null) return match.group(1);
// 핀 공유 형식: pinId="1234567890" 또는 pinId=1234567890
final pinMatch = _pinIdRegex.firstMatch(url);
return pinMatch?.group(1);
} }
/// 짧은 URL에서 ID 추출 /// 짧은 URL에서 ID 추출
@@ -188,6 +201,10 @@ class NaverMapParser {
longitude: userLongitude, longitude: userLongitude,
display: _searchDisplayCount, display: _searchDisplayCount,
); );
AppLogger.debug(
'[naver_url] URL 기반 검색 응답 개수: ${searchResults.length}, '
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
);
if (searchResults.isNotEmpty) { if (searchResults.isNotEmpty) {
// place ID가 포함된 결과 찾기 // place ID가 포함된 결과 찾기
@@ -226,6 +243,10 @@ class NaverMapParser {
longitude: userLongitude, longitude: userLongitude,
display: _searchDisplayCount, display: _searchDisplayCount,
); );
AppLogger.debug(
'[naver_url] Place ID 검색 응답 개수: ${searchResults.length}, '
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
);
if (searchResults.isNotEmpty) { if (searchResults.isNotEmpty) {
AppLogger.debug( AppLogger.debug(
@@ -273,6 +294,9 @@ class NaverMapParser {
variables: {'id': placeId}, variables: {'id': placeId},
query: NaverGraphQLQueries.placeDetailQuery, query: NaverGraphQLQueries.placeDetailQuery,
); );
AppLogger.debug(
'[naver_url] places query 응답 keys: ${response.keys.toList()}',
);
// places 응답 처리 (배열일 수도 있음) // places 응답 처리 (배열일 수도 있음)
final placesData = response['data']?['places']; final placesData = response['data']?['places'];
@@ -299,6 +323,9 @@ class NaverMapParser {
variables: {'id': placeId}, variables: {'id': placeId},
query: NaverGraphQLQueries.nxPlaceDetailQuery, query: NaverGraphQLQueries.nxPlaceDetailQuery,
); );
AppLogger.debug(
'[naver_url] nxPlaces query 응답 keys: ${response.keys.toList()}',
);
// nxPlaces 응답 처리 (배열일 수도 있음) // nxPlaces 응답 처리 (배열일 수도 있음)
final nxPlacesData = response['data']?['nxPlaces']; final nxPlacesData = response['data']?['nxPlaces'];

View File

@@ -98,9 +98,34 @@ class RecommendationEngine {
.toSet(); .toSet();
// 최근 방문하지 않은 식당만 필터링 // 최근 방문하지 않은 식당만 필터링
return restaurants.where((restaurant) { final filtered = restaurants.where((restaurant) {
return !recentlyVisitedIds.contains(restaurant.id); return !recentlyVisitedIds.contains(restaurant.id);
}).toList(); }).toList();
if (filtered.isNotEmpty) return filtered;
// 모든 식당이 제외되면 가장 오래전에 방문한 식당을 반환
final lastVisitByRestaurant = <String, DateTime>{};
for (final visit in recentVisits) {
final current = lastVisitByRestaurant[visit.restaurantId];
if (current == null || visit.visitDate.isAfter(current)) {
lastVisitByRestaurant[visit.restaurantId] = visit.visitDate;
}
}
Restaurant? oldestRestaurant;
DateTime? oldestVisitDate;
for (final restaurant in restaurants) {
final lastVisit = lastVisitByRestaurant[restaurant.id];
if (lastVisit == null) continue;
if (oldestVisitDate == null || lastVisit.isBefore(oldestVisitDate)) {
oldestVisitDate = lastVisit;
oldestRestaurant = restaurant;
}
}
return oldestRestaurant != null ? [oldestRestaurant] : restaurants;
} }
/// 카테고리 필터링 /// 카테고리 필터링
@@ -123,142 +148,7 @@ class RecommendationEngine {
) { ) {
if (restaurants.isEmpty) return null; if (restaurants.isEmpty) return null;
// 각 식당에 대한 가중치 계산 // 가중치 미적용: 거리/방문 필터를 통과한 식당 중 균등 무작위 선택
final weightedRestaurants = restaurants.map((restaurant) { return restaurants[_random.nextInt(restaurants.length)];
double weight = 1.0;
// 카테고리 가중치 적용
final categoryWeight =
config.userSettings.categoryWeights[restaurant.category];
if (categoryWeight != null) {
weight *= categoryWeight;
}
// 거리 가중치 적용 (가까울수록 높은 가중치)
final distance = DistanceCalculator.calculateDistance(
lat1: config.userLatitude,
lon1: config.userLongitude,
lat2: restaurant.latitude,
lon2: restaurant.longitude,
);
final distanceWeight = 1.0 - (distance / config.maxDistance);
weight *= (0.5 + distanceWeight * 0.5); // 50% ~ 100% 범위
// 시간대별 가중치 적용
weight *= _getTimeBasedWeight(restaurant, config.currentTime);
// 날씨 기반 가중치 적용
if (config.weather != null) {
weight *= _getWeatherBasedWeight(restaurant, config.weather!);
}
return _WeightedRestaurant(restaurant, weight);
}).toList();
// 가중치 기반 랜덤 선택
return _weightedRandomSelection(weightedRestaurants);
}
/// 시간대별 가중치 계산
double _getTimeBasedWeight(Restaurant restaurant, DateTime currentTime) {
final hour = currentTime.hour;
// 아침 시간대 (7-10시)
if (hour >= 7 && hour < 10) {
if (restaurant.category == 'cafe' || restaurant.category == 'korean') {
return 1.2;
}
if (restaurant.category == 'bar') {
return 0.3;
}
}
// 점심 시간대 (11-14시)
else if (hour >= 11 && hour < 14) {
if (restaurant.category == 'korean' ||
restaurant.category == 'chinese' ||
restaurant.category == 'japanese') {
return 1.3;
}
}
// 저녁 시간대 (17-21시)
else if (hour >= 17 && hour < 21) {
if (restaurant.category == 'bar' || restaurant.category == 'western') {
return 1.2;
}
}
// 늦은 저녁 (21시 이후)
else if (hour >= 21) {
if (restaurant.category == 'bar' || restaurant.category == 'fastfood') {
return 1.3;
}
if (restaurant.category == 'cafe') {
return 0.5;
}
}
return 1.0;
}
/// 날씨 기반 가중치 계산
double _getWeatherBasedWeight(Restaurant restaurant, WeatherInfo weather) {
if (weather.current.isRainy) {
// 비가 올 때는 가까운 식당 선호
// 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호
if (restaurant.category == 'cafe' || restaurant.category == 'fastfood') {
return 1.2;
}
}
// 더운 날씨 (25도 이상)
if (weather.current.temperature >= 25) {
if (restaurant.category == 'cafe' || restaurant.category == 'japanese') {
return 1.1;
}
}
// 추운 날씨 (10도 이하)
if (weather.current.temperature <= 10) {
if (restaurant.category == 'korean' || restaurant.category == 'chinese') {
return 1.2;
}
}
return 1.0;
}
/// 가중치 기반 랜덤 선택
Restaurant? _weightedRandomSelection(
List<_WeightedRestaurant> weightedRestaurants,
) {
if (weightedRestaurants.isEmpty) return null;
// 전체 가중치 합계 계산
final totalWeight = weightedRestaurants.fold<double>(
0,
(sum, item) => sum + item.weight,
);
// 랜덤 값 생성
final randomValue = _random.nextDouble() * totalWeight;
// 누적 가중치로 선택
double cumulativeWeight = 0;
for (final weightedRestaurant in weightedRestaurants) {
cumulativeWeight += weightedRestaurant.weight;
if (randomValue <= cumulativeWeight) {
return weightedRestaurant.restaurant;
}
}
// 예외 처리 (여기에 도달하면 안됨)
return weightedRestaurants.last.restaurant;
} }
} }
/// 가중치가 적용된 식당 모델
class _WeightedRestaurant {
final Restaurant restaurant;
final double weight;
_WeightedRestaurant(this.restaurant, this.weight);
}

View File

@@ -9,6 +9,7 @@ import '../../../domain/entities/visit_record.dart';
import '../../providers/recommendation_provider.dart'; import '../../providers/recommendation_provider.dart';
import '../../providers/debug_test_data_provider.dart'; import '../../providers/debug_test_data_provider.dart';
import '../../providers/visit_provider.dart'; import '../../providers/visit_provider.dart';
import '../../widgets/native_ad_placeholder.dart';
import 'widgets/visit_record_card.dart'; import 'widgets/visit_record_card.dart';
import 'widgets/recommendation_record_card.dart'; import 'widgets/recommendation_record_card.dart';
import 'widgets/visit_statistics.dart'; import 'widgets/visit_statistics.dart';
@@ -106,129 +107,144 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
_events = _buildEvents(visits, recommendations); _events = _buildEvents(visits, recommendations);
} }
return Column( return LayoutBuilder(
children: [ builder: (context, constraints) {
if (kDebugMode) return SingleChildScrollView(
const DebugTestDataBanner( padding: const EdgeInsets.only(bottom: 16),
margin: EdgeInsets.fromLTRB(16, 16, 16, 8), child: ConstrainedBox(
), constraints: BoxConstraints(minHeight: constraints.maxHeight),
// 캘린더 child: Column(
Card( children: [
margin: const EdgeInsets.all(16), if (kDebugMode)
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, const DebugTestDataBanner(
elevation: 2, margin: EdgeInsets.fromLTRB(16, 16, 16, 8),
shape: RoundedRectangleBorder( ),
borderRadius: BorderRadius.circular(12), Card(
), margin: const EdgeInsets.all(16),
child: TableCalendar( color: isDark
firstDay: DateTime.utc(2025, 1, 1), ? AppColors.darkSurface
lastDay: DateTime.utc(2030, 12, 31), : AppColors.lightSurface,
focusedDay: _focusedDay, elevation: 2,
calendarFormat: _calendarFormat, shape: RoundedRectangleBorder(
selectedDayPredicate: (day) => isSameDay(_selectedDay, day), borderRadius: BorderRadius.circular(12),
onDaySelected: (selectedDay, focusedDay) { ),
setState(() { child: TableCalendar(
_selectedDay = selectedDay; firstDay: DateTime.utc(2025, 1, 1),
_focusedDay = focusedDay; lastDay: DateTime.utc(2030, 12, 31),
}); focusedDay: _focusedDay,
}, calendarFormat: _calendarFormat,
onFormatChanged: (format) { selectedDayPredicate: (day) =>
setState(() { isSameDay(_selectedDay, day),
_calendarFormat = format; onDaySelected: (selectedDay, focusedDay) {
}); setState(() {
}, _selectedDay = selectedDay;
eventLoader: _getEventsForDay, _focusedDay = focusedDay;
calendarBuilders: CalendarBuilders( });
markerBuilder: (context, day, events) { },
if (events.isEmpty) return null; onFormatChanged: (format) {
setState(() {
_calendarFormat = format;
});
},
eventLoader: _getEventsForDay,
calendarBuilders: CalendarBuilders(
markerBuilder: (context, day, events) {
if (events.isEmpty) return null;
final calendarEvents = events.cast<_CalendarEvent>(); final calendarEvents = events
final confirmedVisits = calendarEvents.where( .cast<_CalendarEvent>();
(e) => e.visitRecord?.isConfirmed == true, final confirmedVisits = calendarEvents.where(
); (e) => e.visitRecord?.isConfirmed == true,
final recommendedOnly = calendarEvents.where( );
(e) => e.recommendationRecord != null, final recommendedOnly = calendarEvents.where(
); (e) => e.recommendationRecord != null,
);
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (confirmedVisits.isNotEmpty) if (confirmedVisits.isNotEmpty)
Container( Container(
width: 6, width: 6,
height: 6, height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1), margin: const EdgeInsets.symmetric(
decoration: const BoxDecoration( horizontal: 1,
color: AppColors.lightPrimary, ),
shape: BoxShape.circle, decoration: const BoxDecoration(
), color: AppColors.lightPrimary,
shape: BoxShape.circle,
),
),
if (recommendedOnly.isNotEmpty)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 1,
),
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
],
);
},
),
calendarStyle: CalendarStyle(
outsideDaysVisible: false,
selectedDecoration: const BoxDecoration(
color: AppColors.lightPrimary,
shape: BoxShape.circle,
), ),
if (recommendedOnly.isNotEmpty) todayDecoration: BoxDecoration(
Container( color: AppColors.lightPrimary.withOpacity(0.5),
width: 6, shape: BoxShape.circle,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
), ),
], markersMaxCount: 2,
); markerDecoration: const BoxDecoration(
}, color: AppColors.lightSecondary,
), shape: BoxShape.circle,
calendarStyle: CalendarStyle( ),
outsideDaysVisible: false, weekendTextStyle: const TextStyle(
selectedDecoration: const BoxDecoration( color: AppColors.lightError,
color: AppColors.lightPrimary, ),
shape: BoxShape.circle, ),
), headerStyle: HeaderStyle(
todayDecoration: BoxDecoration( formatButtonVisible: true,
color: AppColors.lightPrimary.withOpacity(0.5), titleCentered: true,
shape: BoxShape.circle, formatButtonShowsNext: false,
), formatButtonDecoration: BoxDecoration(
markersMaxCount: 2, color: AppColors.lightPrimary.withOpacity(0.1),
markerDecoration: const BoxDecoration( borderRadius: BorderRadius.circular(12),
color: AppColors.lightSecondary, ),
shape: BoxShape.circle, formatButtonTextStyle: const TextStyle(
), color: AppColors.lightPrimary,
weekendTextStyle: const TextStyle( ),
color: AppColors.lightError, ),
), ),
), ),
headerStyle: HeaderStyle( Padding(
formatButtonVisible: true, padding: const EdgeInsets.symmetric(horizontal: 16),
titleCentered: true, child: Row(
formatButtonShowsNext: false, mainAxisAlignment: MainAxisAlignment.center,
formatButtonDecoration: BoxDecoration( children: [
color: AppColors.lightPrimary.withOpacity(0.1), _buildLegend('추천받음', Colors.orange, isDark),
borderRadius: BorderRadius.circular(12), const SizedBox(width: 24),
), _buildLegend('방문완료', Colors.green, isDark),
formatButtonTextStyle: const TextStyle( ],
color: AppColors.lightPrimary, ),
), ),
const SizedBox(height: 16),
const NativeAdPlaceholder(
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
),
_buildDayRecords(_selectedDay, isDark),
],
), ),
), ),
), );
},
// 범례
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend('추천받음', Colors.orange, isDark),
const SizedBox(width: 24),
_buildLegend('방문완료', Colors.green, isDark),
],
),
),
const SizedBox(height: 16),
// 선택된 날짜의 기록
Expanded(child: _buildDayRecords(_selectedDay, isDark)),
],
); );
}, },
); );
@@ -302,30 +318,35 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
], ],
), ),
), ),
Expanded( ListView.builder(
child: ListView.builder( shrinkWrap: true,
itemCount: events.length, physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) { itemCount: events.length,
final event = events[index]; itemBuilder: (context, index) {
if (event.visitRecord != null) { final event = events[index];
return VisitRecordCard( if (event.visitRecord != null) {
visitRecord: event.visitRecord!, return VisitRecordCard(
onTap: () {}, visitRecord: event.visitRecord!,
); onTap: () {},
} );
if (event.recommendationRecord != null) { }
return RecommendationRecordCard( if (event.recommendationRecord != null) {
recommendation: event.recommendationRecord!, return RecommendationRecordCard(
onConfirmVisit: () async { recommendation: event.recommendationRecord!,
await ref onConfirmVisit: () async {
.read(recommendationNotifierProvider.notifier) await ref
.confirmVisit(event.recommendationRecord!.id); .read(recommendationNotifierProvider.notifier)
}, .confirmVisit(event.recommendationRecord!.id);
); },
} onDelete: () async {
return const SizedBox.shrink(); await ref
}, .read(recommendationNotifierProvider.notifier)
), .deleteRecommendation(event.recommendationRecord!.id);
},
);
}
return const SizedBox.shrink();
},
), ),
], ],
); );

View File

@@ -8,11 +8,13 @@ import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
class RecommendationRecordCard extends ConsumerWidget { class RecommendationRecordCard extends ConsumerWidget {
final RecommendationRecord recommendation; final RecommendationRecord recommendation;
final VoidCallback onConfirmVisit; final VoidCallback onConfirmVisit;
final VoidCallback onDelete;
const RecommendationRecordCard({ const RecommendationRecordCard({
super.key, super.key,
required this.recommendation, required this.recommendation,
required this.onConfirmVisit, required this.onConfirmVisit,
required this.onDelete,
}); });
String _formatTime(DateTime dateTime) { String _formatTime(DateTime dateTime) {
@@ -43,96 +45,127 @@ class RecommendationRecordCard extends ConsumerWidget {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.whatshot,
color: Colors.orange,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.category_outlined,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
restaurant.category,
style: AppTypography.caption(isDark),
),
const SizedBox(width: 8),
Icon(
Icons.access_time,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
_formatTime(recommendation.recommendationDate),
style: AppTypography.caption(isDark),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline,
size: 16,
color: Colors.orange,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
style: AppTypography.caption(isDark).copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
softWrap: true,
maxLines: 3,
overflow: TextOverflow.visible,
),
),
],
),
],
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: onConfirmVisit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
),
child: const Text('방문 확인'),
),
],
),
const SizedBox(height: 10),
Container( Container(
width: 40, height: 1,
height: 40, color: isDark
decoration: BoxDecoration( ? AppColors.darkDivider
color: Colors.orange.withValues(alpha: 0.1), : AppColors.lightDivider,
shape: BoxShape.circle,
),
child: const Icon(
Icons.whatshot,
color: Colors.orange,
size: 24,
),
), ),
const SizedBox(width: 16), Align(
Expanded( alignment: Alignment.centerRight,
child: Column( child: TextButton(
crossAxisAlignment: CrossAxisAlignment.start, onPressed: onDelete,
children: [ style: TextButton.styleFrom(
Text( foregroundColor: Colors.redAccent,
restaurant.name, padding: const EdgeInsets.only(top: 6),
style: AppTypography.body1( minimumSize: const Size(0, 32),
isDark, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
).copyWith(fontWeight: FontWeight.bold), ),
maxLines: 1, child: const Text('삭제'),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.category_outlined,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
restaurant.category,
style: AppTypography.caption(isDark),
),
const SizedBox(width: 8),
Icon(
Icons.access_time,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
_formatTime(recommendation.recommendationDate),
style: AppTypography.caption(isDark),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.info_outline,
size: 16,
color: Colors.orange,
),
const SizedBox(width: 6),
Text(
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
style: AppTypography.caption(isDark).copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
),
],
),
],
), ),
), ),
const SizedBox(width: 12),
ElevatedButton(
onPressed: onConfirmVisit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
),
child: const Text('방문 확인'),
),
], ],
), ),
), ),

View File

@@ -6,6 +6,7 @@ import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/presentation/providers/visit_provider.dart'; import 'package:lunchpick/presentation/providers/visit_provider.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart'; import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart';
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
class VisitStatistics extends ConsumerWidget { class VisitStatistics extends ConsumerWidget {
final DateTime selectedMonth; final DateTime selectedMonth;
@@ -42,6 +43,9 @@ class VisitStatistics extends ConsumerWidget {
_buildMonthlyStats(monthlyStatsAsync, isDark), _buildMonthlyStats(monthlyStatsAsync, isDark),
const SizedBox(height: 16), const SizedBox(height: 16),
const NativeAdPlaceholder(),
const SizedBox(height: 16),
// 주간 통계 차트 // 주간 통계 차트
_buildWeeklyChart(weeklyStatsAsync, isDark), _buildWeeklyChart(weeklyStatsAsync, isDark),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -13,6 +13,7 @@ import '../../providers/location_provider.dart';
import '../../providers/recommendation_provider.dart'; import '../../providers/recommendation_provider.dart';
import '../../providers/restaurant_provider.dart'; import '../../providers/restaurant_provider.dart';
import '../../providers/weather_provider.dart'; import '../../providers/weather_provider.dart';
import '../../widgets/native_ad_placeholder.dart';
import 'widgets/recommendation_result_dialog.dart'; import 'widgets/recommendation_result_dialog.dart';
class RandomSelectionScreen extends ConsumerStatefulWidget { class RandomSelectionScreen extends ConsumerStatefulWidget {
@@ -51,64 +52,84 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 맛집 리스트 현황 카드 // 상단 요약 바 (높이 최소화)
Card( Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.symmetric(
child: Column( horizontal: 12,
vertical: 10,
),
child: Row(
children: [ children: [
const Icon( Container(
Icons.restaurant, width: 36,
size: 48, height: 36,
color: AppColors.lightPrimary, decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.restaurant,
size: 20,
color: AppColors.lightPrimary,
),
), ),
const SizedBox(height: 12), const SizedBox(width: 10),
Consumer( Expanded(
builder: (context, ref, child) { child: Consumer(
final restaurantsAsync = ref.watch( builder: (context, ref, child) {
restaurantListProvider, final restaurantsAsync = ref.watch(
); restaurantListProvider,
return restaurantsAsync.when( );
data: (restaurants) => Text( return restaurantsAsync.when(
'${restaurants.length}', data: (restaurants) => Text(
style: AppTypography.heading1( '등록된 맛집 ${restaurants.length}',
isDark, style: AppTypography.heading2(
).copyWith(color: AppColors.lightPrimary), isDark,
), ).copyWith(fontSize: 18),
loading: () => const CircularProgressIndicator( ),
color: AppColors.lightPrimary, loading: () => const SizedBox(
), height: 20,
error: (_, __) => Text( width: 20,
'0개', child: CircularProgressIndicator(
style: AppTypography.heading1( strokeWidth: 2,
isDark, color: AppColors.lightPrimary,
).copyWith(color: AppColors.lightPrimary), ),
), ),
); error: (_, __) => Text(
}, '등록된 맛집 0개',
style: AppTypography.heading2(
isDark,
).copyWith(fontSize: 18),
),
);
},
),
), ),
Text('등록된 맛집', style: AppTypography.body2(isDark)),
], ],
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// 날씨 정보 카드 // 날씨 정보 카드
Card( Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
child: Consumer( child: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final weatherAsync = ref.watch(weatherProvider); final weatherAsync = ref.watch(weatherProvider);
@@ -164,22 +185,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// 카테고리 선택 카드 // 카테고리 선택 카드
Card( Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('카테고리', style: AppTypography.heading2(isDark)), Text('카테고리', style: AppTypography.heading2(isDark)),
const SizedBox(height: 12), const SizedBox(height: 10),
Consumer( Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final categoriesAsync = ref.watch(categoriesProvider); final categoriesAsync = ref.watch(categoriesProvider);
@@ -204,7 +225,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
.toList(); .toList();
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 10,
children: categories.isEmpty children: categories.isEmpty
? [const Text('카테고리 없음')] ? [const Text('카테고리 없음')]
: [ : [
@@ -227,22 +248,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// 거리 설정 카드 // 거리 설정 카드
Card( Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('최대 거리', style: AppTypography.heading2(isDark)), Text('최대 거리', style: AppTypography.heading2(isDark)),
const SizedBox(height: 12), const SizedBox(height: 10),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -274,7 +295,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
Consumer( Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final locationAsync = ref.watch( final locationAsync = ref.watch(
@@ -322,7 +343,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
// 추천받기 버튼 // 추천받기 버튼
ElevatedButton( ElevatedButton(
@@ -362,6 +383,11 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
], ],
), ),
), ),
const SizedBox(height: 16),
const NativeAdPlaceholder(
margin: EdgeInsets.symmetric(vertical: 8),
),
], ],
), ),
), ),

View File

@@ -159,6 +159,7 @@ class _ManualRestaurantInputScreenState
onFieldChanged: _onFieldChanged, onFieldChanged: _onFieldChanged,
categories: categories, categories: categories,
subCategories: subCategories, subCategories: subCategories,
geocodingStatus: state.geocodingStatus,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Row( Row(

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_typography.dart'; import '../../../core/constants/app_typography.dart';
import '../../../core/utils/category_mapper.dart';
import '../../../core/utils/app_logger.dart'; import '../../../core/utils/app_logger.dart';
import '../../providers/restaurant_provider.dart'; import '../../providers/restaurant_provider.dart';
import '../../widgets/category_selector.dart'; import '../../widgets/category_selector.dart';
import '../../widgets/native_ad_placeholder.dart';
import 'manual_restaurant_input_screen.dart'; import 'manual_restaurant_input_screen.dart';
import 'widgets/restaurant_card.dart'; import 'widgets/restaurant_card.dart';
import 'widgets/add_restaurant_dialog.dart'; import 'widgets/add_restaurant_dialog.dart';
@@ -34,9 +35,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
final searchQuery = ref.watch(searchQueryProvider); final searchQuery = ref.watch(searchQueryProvider);
final selectedCategory = ref.watch(selectedCategoryProvider); final selectedCategory = ref.watch(selectedCategoryProvider);
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null; final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
final restaurantsAsync = isFiltered final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
? ref.watch(filteredRestaurantsProvider)
: ref.watch(sortedRestaurantsByDistanceProvider);
return Scaffold( return Scaffold(
backgroundColor: isDark backgroundColor: isDark
@@ -108,25 +107,56 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
AppLogger.debug( AppLogger.debug(
'[restaurant_list_ui] data received, filtered=$isFiltered', '[restaurant_list_ui] data received, filtered=$isFiltered',
); );
final items = isFiltered var items = restaurantsData;
? (restaurantsData as List<Restaurant>)
.map( if (isFiltered) {
(r) => (restaurant: r, distanceKm: null as double?), // 검색 필터
) if (searchQuery.isNotEmpty) {
.toList() final lowercaseQuery = searchQuery.toLowerCase();
: restaurantsData items = items.where((item) {
as List< final r = item.restaurant;
({Restaurant restaurant, double? distanceKm}) return r.name.toLowerCase().contains(lowercaseQuery) ||
>; (r.description?.toLowerCase().contains(
lowercaseQuery,
) ??
false) ||
r.category.toLowerCase().contains(lowercaseQuery);
}).toList();
}
// 카테고리 필터
if (selectedCategory != null) {
items = items.where((item) {
final r = item.restaurant;
return r.category == selectedCategory ||
r.category.contains(selectedCategory) ||
CategoryMapper.normalizeNaverCategory(
r.category,
r.subCategory,
) ==
selectedCategory ||
CategoryMapper.getDisplayName(r.category) ==
selectedCategory;
}).toList();
}
}
if (items.isEmpty) { if (items.isEmpty) {
return _buildEmptyState(isDark); return _buildEmptyState(isDark);
} }
return ListView.builder( return ListView.builder(
itemCount: items.length, itemCount: items.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; if (index == 0) {
return const NativeAdPlaceholder(
margin: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
);
}
final item = items[index - 1];
return RestaurantCard( return RestaurantCard(
restaurant: item.restaurant, restaurant: item.restaurant,
distanceKm: item.distanceKm, distanceKm: item.distanceKm,

View File

@@ -17,6 +17,7 @@ class AddRestaurantForm extends StatefulWidget {
final Function(String) onFieldChanged; final Function(String) onFieldChanged;
final List<String> categories; final List<String> categories;
final List<String> subCategories; final List<String> subCategories;
final String geocodingStatus;
const AddRestaurantForm({ const AddRestaurantForm({
super.key, super.key,
@@ -33,6 +34,7 @@ class AddRestaurantForm extends StatefulWidget {
required this.onFieldChanged, required this.onFieldChanged,
this.categories = const <String>[], this.categories = const <String>[],
this.subCategories = const <String>[], this.subCategories = const <String>[],
this.geocodingStatus = '',
}); });
@override @override
@@ -255,12 +257,28 @@ class _AddRestaurantFormState extends State<AddRestaurantForm> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Column(
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.', crossAxisAlignment: CrossAxisAlignment.center,
style: Theme.of( children: [
context, Text(
).textTheme.bodySmall?.copyWith(color: Colors.grey), '주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
textAlign: TextAlign.center, style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
textAlign: TextAlign.center,
),
if (widget.geocodingStatus.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.geocodingStatus,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.blueGrey,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
],
), ),
], ],
), ),

View File

@@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( // Row(
children: [ // children: [
Icon( // Icon(
Icons.info_outline, // Icons.info_outline,
size: 20, // size: 20,
color: isDark // color: isDark
? AppColors.darkPrimary // ? AppColors.darkPrimary
: AppColors.lightPrimary, // : AppColors.lightPrimary,
), // ),
const SizedBox(width: 8), // const SizedBox(width: 8),
Text( // Text(
'네이버 지도에서 맛집 정보 가져오기', // '네이버 지도에서 맛집 정보 가져오기',
style: AppTypography.body1( // style: AppTypography.body1(
isDark, // isDark,
).copyWith(fontWeight: FontWeight.bold), // ).copyWith(fontWeight: FontWeight.bold),
), // ),
], // ],
), // ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'1. 네이버 지도에서 맛집을 검색합니다\n' '1. 네이버 지도에서 맛집을 검색합니다\n'
@@ -71,6 +71,9 @@ class AddRestaurantUrlTab extends StatelessWidget {
// URL 입력 필드 // URL 입력 필드
TextField( TextField(
controller: urlController, controller: urlController,
keyboardType: TextInputType.multiline,
minLines: 1,
maxLines: 6,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '네이버 지도 URL', labelText: '네이버 지도 URL',
hintText: kIsWeb hintText: kIsWeb
@@ -79,6 +82,7 @@ class AddRestaurantUrlTab extends StatelessWidget {
prefixIcon: const Icon(Icons.link), prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
errorText: errorMessage, errorText: errorMessage,
errorMaxLines: 8,
), ),
onSubmitted: (_) => onFetchPressed(), onSubmitted: (_) => onFetchPressed(),
), ),

View File

@@ -4,7 +4,7 @@ import '../../../../core/constants/app_colors.dart';
import '../../../../core/constants/app_typography.dart'; import '../../../../core/constants/app_typography.dart';
import '../../../services/restaurant_form_validator.dart'; import '../../../services/restaurant_form_validator.dart';
class FetchedRestaurantJsonView extends StatelessWidget { class FetchedRestaurantJsonView extends StatefulWidget {
final bool isDark; final bool isDark;
final TextEditingController nameController; final TextEditingController nameController;
final TextEditingController categoryController; final TextEditingController categoryController;
@@ -34,17 +34,59 @@ class FetchedRestaurantJsonView extends StatelessWidget {
required this.onFieldChanged, required this.onFieldChanged,
}); });
@override
State<FetchedRestaurantJsonView> createState() =>
_FetchedRestaurantJsonViewState();
}
class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
late final FocusNode _categoryFocusNode;
late final FocusNode _subCategoryFocusNode;
late Set<String> _availableCategories;
late Set<String> _availableSubCategories;
@override
void initState() {
super.initState();
_categoryFocusNode = FocusNode();
_subCategoryFocusNode = FocusNode();
_availableCategories = {
'기타',
if (widget.categoryController.text.trim().isNotEmpty)
widget.categoryController.text.trim(),
};
_availableSubCategories = {
'기타',
if (widget.subCategoryController.text.trim().isNotEmpty)
widget.subCategoryController.text.trim(),
};
if (widget.categoryController.text.trim().isEmpty) {
widget.categoryController.text = '기타';
}
if (widget.subCategoryController.text.trim().isEmpty) {
widget.subCategoryController.text = '기타';
}
}
@override
void dispose() {
_categoryFocusNode.dispose();
_subCategoryFocusNode.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark color: widget.isDark
? AppColors.darkBackground ? AppColors.darkBackground
: AppColors.lightBackground.withOpacity(0.5), : AppColors.lightBackground.withOpacity(0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isDark ? AppColors.darkDivider : AppColors.lightDivider, color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider,
), ),
), ),
child: Column( child: Column(
@@ -57,78 +99,55 @@ class FetchedRestaurantJsonView extends StatelessWidget {
Text( Text(
'가져온 정보', '가져온 정보',
style: AppTypography.body1( style: AppTypography.body1(
isDark, widget.isDark,
).copyWith(fontWeight: FontWeight.w600), ).copyWith(fontWeight: FontWeight.w600),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text(
'{',
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
),
const SizedBox(height: 12),
_buildJsonField( _buildJsonField(
context, context,
label: 'name', label: '상호',
controller: nameController, controller: widget.nameController,
icon: Icons.store, icon: Icons.store,
validator: (value) => validator: (value) =>
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null, value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
), ),
_buildJsonField( _buildJsonField(
context, context,
label: 'category', label: '도로명 주소',
controller: categoryController, controller: widget.roadAddressController,
icon: Icons.category,
validator: RestaurantFormValidator.validateCategory,
),
_buildJsonField(
context,
label: 'subCategory',
controller: subCategoryController,
icon: Icons.label_outline,
),
_buildJsonField(
context,
label: 'description',
controller: descriptionController,
icon: Icons.description,
maxLines: 2,
),
_buildJsonField(
context,
label: 'phoneNumber',
controller: phoneController,
icon: Icons.phone,
keyboardType: TextInputType.phone,
validator: RestaurantFormValidator.validatePhoneNumber,
),
_buildJsonField(
context,
label: 'roadAddress',
controller: roadAddressController,
icon: Icons.location_on, icon: Icons.location_on,
validator: RestaurantFormValidator.validateAddress, validator: RestaurantFormValidator.validateAddress,
), ),
_buildJsonField( _buildJsonField(
context, context,
label: 'jibunAddress', label: '지번 주소',
controller: jibunAddressController, controller: widget.jibunAddressController,
icon: Icons.map, icon: Icons.map,
), ),
_buildCoordinateFields(context), _buildCoordinateFields(context),
_buildJsonField( _buildJsonField(
context, context,
label: 'naverUrl', label: '전화번호',
controller: naverUrlController, controller: widget.phoneController,
icon: Icons.link, icon: Icons.phone,
monospace: true, keyboardType: TextInputType.phone,
validator: RestaurantFormValidator.validatePhoneNumber,
), ),
const SizedBox(height: 12), Row(
const Text( children: [
'}', Expanded(child: _buildCategoryField(context)),
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16), const SizedBox(width: 8),
Expanded(child: _buildSubCategoryField(context)),
],
),
_buildJsonField(
context,
label: '설명',
controller: widget.descriptionController,
icon: Icons.description,
maxLines: 2,
), ),
], ],
), ),
@@ -145,7 +164,7 @@ class FetchedRestaurantJsonView extends StatelessWidget {
children: const [ children: const [
Icon(Icons.my_location, size: 16), Icon(Icons.my_location, size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Text('coordinates'), Text('좌표'),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -153,16 +172,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: TextFormField( child: TextFormField(
controller: latitudeController, controller: widget.latitudeController,
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true, decimal: true,
), ),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'latitude', labelText: '위도',
border: border, border: border,
isDense: true, isDense: true,
), ),
onChanged: onFieldChanged, onChanged: widget.onFieldChanged,
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '위도를 입력해주세요'; return '위도를 입력해주세요';
@@ -178,16 +197,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: TextFormField( child: TextFormField(
controller: longitudeController, controller: widget.longitudeController,
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true, decimal: true,
), ),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'longitude', labelText: '경도',
border: border, border: border,
isDense: true, isDense: true,
), ),
onChanged: onFieldChanged, onChanged: widget.onFieldChanged,
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '경도를 입력해주세요'; return '경도를 입력해주세요';
@@ -209,6 +228,170 @@ class FetchedRestaurantJsonView extends StatelessWidget {
); );
} }
Widget _buildCategoryField(BuildContext context) {
return RawAutocomplete<String>(
textEditingController: widget.categoryController,
focusNode: _categoryFocusNode,
optionsBuilder: (TextEditingValue value) {
final query = value.text.trim();
if (query.isEmpty) return _availableCategories;
final lowerQuery = query.toLowerCase();
final matches = _availableCategories
.where((c) => c.toLowerCase().contains(lowerQuery))
.toList();
final hasExact = _availableCategories.any(
(c) => c.toLowerCase() == lowerQuery,
);
if (!hasExact) {
matches.insert(0, query.isEmpty ? '기타' : query);
}
return matches;
},
displayStringForOption: (option) => option,
onSelected: (option) {
final normalized = option.trim().isEmpty ? '기타' : option.trim();
setState(() {
_availableCategories.add(normalized);
});
widget.categoryController.text = normalized;
widget.onFieldChanged(normalized);
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: '카테고리',
hintText: '예: 한식',
// prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
onChanged: widget.onFieldChanged,
onFieldSubmitted: (_) => onFieldSubmitted(),
validator: RestaurantFormValidator.validateCategory,
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
final isNew = !_availableCategories.contains(option);
return ListTile(
dense: true,
title: Text(
isNew ? '새 카테고리 추가: $option' : option,
style: TextStyle(
fontWeight: isNew ? FontWeight.w600 : null,
),
),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
);
}
Widget _buildSubCategoryField(BuildContext context) {
return RawAutocomplete<String>(
textEditingController: widget.subCategoryController,
focusNode: _subCategoryFocusNode,
optionsBuilder: (TextEditingValue value) {
final query = value.text.trim();
if (query.isEmpty) return _availableSubCategories;
final lowerQuery = query.toLowerCase();
final matches = _availableSubCategories
.where((c) => c.toLowerCase().contains(lowerQuery))
.toList();
final hasExact = _availableSubCategories.any(
(c) => c.toLowerCase() == lowerQuery,
);
if (!hasExact) {
matches.insert(0, query.isEmpty ? '기타' : query);
}
return matches;
},
displayStringForOption: (option) => option,
onSelected: (option) {
final normalized = option.trim().isEmpty ? '기타' : option.trim();
setState(() {
_availableSubCategories.add(normalized);
});
widget.subCategoryController.text = normalized;
widget.onFieldChanged(normalized);
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: '세부 카테고리',
hintText: '예: 갈비',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
onChanged: widget.onFieldChanged,
onFieldSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
final isNew = !_availableSubCategories.contains(option);
return ListTile(
dense: true,
title: Text(
isNew ? '새 세부 카테고리 추가: $option' : option,
style: TextStyle(
fontWeight: isNew ? FontWeight.w600 : null,
),
),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
);
}
Widget _buildJsonField( Widget _buildJsonField(
BuildContext context, { BuildContext context, {
required String label, required String label,
@@ -236,17 +419,18 @@ class FetchedRestaurantJsonView extends StatelessWidget {
controller: controller, controller: controller,
maxLines: maxLines, maxLines: maxLines,
keyboardType: keyboardType, keyboardType: keyboardType,
onChanged: onFieldChanged, onChanged: widget.onFieldChanged,
validator: validator, validator: validator,
style: monospace
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
: null,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, labelText: label,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
isDense: true,
), ),
style: monospace
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14)
: null,
), ),
], ],
), ),

View File

@@ -13,6 +13,7 @@ import 'package:lunchpick/domain/entities/share_device.dart';
import 'package:lunchpick/presentation/providers/ad_provider.dart'; import 'package:lunchpick/presentation/providers/ad_provider.dart';
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart'; import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ShareScreen extends ConsumerStatefulWidget { class ShareScreen extends ConsumerStatefulWidget {
@@ -144,7 +145,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요', subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
child: _buildSendSection(isDark), child: _buildSendSection(isDark),
), ),
const SizedBox(height: 20), const SizedBox(height: 16),
const NativeAdPlaceholder(),
const SizedBox(height: 16),
_ShareCard( _ShareCard(
isDark: isDark, isDark: isDark,
icon: Icons.download_rounded, icon: Icons.download_rounded,

View File

@@ -1,9 +1,11 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_typography.dart'; import '../../../core/constants/app_typography.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../../../core/services/permission_service.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@@ -178,13 +180,26 @@ class _SplashScreenState extends State<SplashScreen>
} }
void _navigateToHome() { void _navigateToHome() {
Future.delayed(AppConstants.splashAnimationDuration, () { Future.wait([
_ensurePermissions(),
Future.delayed(AppConstants.splashAnimationDuration),
]).then((_) {
if (mounted) { if (mounted) {
context.go('/home'); context.go('/home');
} }
}); });
} }
Future<void> _ensurePermissions() async {
try {
await Permission.notification.request();
await Permission.location.request();
await PermissionService.checkAndRequestBluetoothPermission();
} catch (_) {
// 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다.
}
}
@override @override
void dispose() { void dispose() {
for (final controller in _foodControllers) { for (final controller in _foodControllers) {

View File

@@ -122,7 +122,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
final config = RecommendationConfig( final config = RecommendationConfig(
userLatitude: location.latitude, userLatitude: location.latitude,
userLongitude: location.longitude, userLongitude: location.longitude,
maxDistance: maxDistance, maxDistance: maxDistance / 1000, // 미터 입력을 km 단위로 변환
selectedCategories: selectedCategories, selectedCategories: selectedCategories,
userSettings: userSettings, userSettings: userSettings,
weather: weather, weather: weather,
@@ -307,7 +307,7 @@ class EnhancedRecommendationNotifier
final config = RecommendationConfig( final config = RecommendationConfig(
userLatitude: location.latitude, userLatitude: location.latitude,
userLongitude: location.longitude, userLongitude: location.longitude,
maxDistance: maxDistanceNormal.toDouble(), maxDistance: maxDistanceNormal.toDouble() / 1000, // 미터 입력을 km 단위로 변환
selectedCategories: categories, selectedCategories: categories,
userSettings: userSettings, userSettings: userSettings,
weather: weather, weather: weather,

View File

@@ -102,8 +102,8 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
required DateTime recommendationTime, required DateTime recommendationTime,
bool isConfirmed = false, bool isConfirmed = false,
}) async { }) async {
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정 // 추천 확인 시점으로 방문 시간을 기록
final visitTime = recommendationTime.add(const Duration(minutes: 90)); final visitTime = DateTime.now();
await addVisitRecord( await addVisitRecord(
restaurantId: restaurantId, restaurantId: restaurantId,

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../domain/entities/restaurant.dart'; import '../../domain/entities/restaurant.dart';
import '../../data/datasources/remote/naver_map_parser.dart';
import '../providers/di_providers.dart'; import '../providers/di_providers.dart';
import '../providers/restaurant_provider.dart'; import '../providers/restaurant_provider.dart';
import '../providers/location_provider.dart'; import '../providers/location_provider.dart';
@@ -15,6 +16,7 @@ class AddRestaurantState {
final Restaurant? fetchedRestaurantData; final Restaurant? fetchedRestaurantData;
final RestaurantFormData formData; final RestaurantFormData formData;
final List<Restaurant> searchResults; final List<Restaurant> searchResults;
final String geocodingStatus;
const AddRestaurantState({ const AddRestaurantState({
this.isLoading = false, this.isLoading = false,
@@ -23,6 +25,7 @@ class AddRestaurantState {
this.fetchedRestaurantData, this.fetchedRestaurantData,
required this.formData, required this.formData,
this.searchResults = const [], this.searchResults = const [],
this.geocodingStatus = '',
}); });
AddRestaurantState copyWith({ AddRestaurantState copyWith({
@@ -34,6 +37,7 @@ class AddRestaurantState {
List<Restaurant>? searchResults, List<Restaurant>? searchResults,
bool clearFetchedRestaurant = false, bool clearFetchedRestaurant = false,
bool clearError = false, bool clearError = false,
String? geocodingStatus,
}) { }) {
return AddRestaurantState( return AddRestaurantState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
@@ -44,6 +48,7 @@ class AddRestaurantState {
: (fetchedRestaurantData ?? this.fetchedRestaurantData), : (fetchedRestaurantData ?? this.fetchedRestaurantData),
formData: formData ?? this.formData, formData: formData ?? this.formData,
searchResults: searchResults ?? this.searchResults, searchResults: searchResults ?? this.searchResults,
geocodingStatus: geocodingStatus ?? this.geocodingStatus,
); );
} }
} }
@@ -179,24 +184,61 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
/// 네이버 URL로부터 식당 정보 가져오기 /// 네이버 URL로부터 식당 정보 가져오기
Future<void> fetchFromNaverUrl(String url) async { Future<void> fetchFromNaverUrl(String url) async {
if (url.trim().isEmpty) { final parsed = _parseSharedNaverContent(url);
if (parsed.url.trim().isEmpty) {
state = state.copyWith(errorMessage: 'URL을 입력해주세요.'); state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
return; return;
} }
// 공유 텍스트에 포함된 상호명/도로명주소를 미리 채워 넣는다.
state = state.copyWith(isLoading: true, clearError: true); state = state.copyWith(isLoading: true, clearError: true);
try { try {
final repository = _ref.read(restaurantRepositoryProvider); final normalizedUrl = _normalizeUrl(parsed.url);
final restaurant = await repository.previewRestaurantFromUrl(url); state = state.copyWith(
geocodingStatus: '지오코딩 시도: ${parsed.roadAddress ?? ''}',
);
final coords = await _tryGeocode(parsed.roadAddress ?? '');
if (coords != null) {
state = state.copyWith(
geocodingStatus:
'지오코딩 성공: ${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)}',
);
} else {
state = state.copyWith(
geocodingStatus: '지오코딩 실패: 현재 위치/기본 좌표를 사용할 수 있습니다.',
);
}
final newForm = state.formData.copyWith(
name: parsed.name ?? state.formData.name,
roadAddress: parsed.roadAddress ?? state.formData.roadAddress,
jibunAddress: state.formData.jibunAddress,
latitude: coords != null
? coords.latitude.toString()
: state.formData.latitude,
longitude: coords != null
? coords.longitude.toString()
: state.formData.longitude,
category: '기타',
subCategory: '기타',
naverUrl: normalizedUrl,
);
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
fetchedRestaurantData: restaurant, fetchedRestaurantData: newForm.toRestaurant(),
formData: RestaurantFormData.fromRestaurant(restaurant), formData: newForm,
); );
} catch (e) { } catch (e) {
state = state.copyWith(isLoading: false, errorMessage: e.toString()); final message = e is NaverMapParseException
? '네이버 지도 파싱 실패: ${e.message}'
: e.toString();
state = state.copyWith(
isLoading: false,
errorMessage: message,
geocodingStatus: '지오코딩 실패: ${parsed.roadAddress ?? '주소 없음'}',
);
} }
} }
@@ -241,6 +283,12 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
/// 식당 정보 저장 /// 식당 정보 저장
Future<bool> saveRestaurant() async { Future<bool> saveRestaurant() async {
final notifier = _ref.read(restaurantNotifierProvider.notifier); final notifier = _ref.read(restaurantNotifierProvider.notifier);
final fallbackCategory = state.formData.category.isNotEmpty
? state.formData.category
: '기타';
final fallbackSubCategory = state.formData.subCategory.isNotEmpty
? state.formData.subCategory
: fallbackCategory;
try { try {
state = state.copyWith(isLoading: true, clearError: true); state = state.copyWith(isLoading: true, clearError: true);
@@ -260,10 +308,8 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
restaurantToSave = fetchedData.copyWith( restaurantToSave = fetchedData.copyWith(
name: state.formData.name, name: state.formData.name,
category: state.formData.category, category: fallbackCategory,
subCategory: state.formData.subCategory.isEmpty subCategory: fallbackSubCategory,
? state.formData.category
: state.formData.subCategory,
description: state.formData.description.isEmpty description: state.formData.description.isEmpty
? null ? null
: state.formData.description, : state.formData.description,
@@ -292,6 +338,8 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
); );
restaurantToSave = state.formData.toRestaurant().copyWith( restaurantToSave = state.formData.toRestaurant().copyWith(
category: fallbackCategory,
subCategory: fallbackSubCategory,
latitude: coords.latitude, latitude: coords.latitude,
longitude: coords.longitude, longitude: coords.longitude,
needsAddressVerification: coords.usedCurrentLocation, needsAddressVerification: coords.usedCurrentLocation,
@@ -317,6 +365,69 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
state = state.copyWith(clearError: true); state = state.copyWith(clearError: true);
} }
/// 네이버 지도 공유 텍스트에서 URL/상호명/도로명주소를 추출한다.
_ParsedNaverShare _parseSharedNaverContent(String raw) {
final normalized = raw.replaceAll('\r\n', '\n').trim();
// URL 추출
final urlRegex = RegExp(
r'(https?://(?:map\.naver\.com|naver\.me)[^\s]+)',
caseSensitive: false,
);
final urlMatch = urlRegex.firstMatch(normalized);
final url = urlMatch?.group(0) ?? normalized;
// 패턴: [네이버지도]\n상호명\n도로명주소\nURL
final lines = normalized.split('\n').map((e) => e.trim()).toList();
String? name;
String? roadAddress;
if (lines.length >= 4 && lines.first.contains('네이버지도')) {
name = lines[1].isNotEmpty ? lines[1] : null;
roadAddress = lines[2].isNotEmpty ? lines[2] : null;
} else {
// 줄바꿈이 없거나 공백만 있는 경우: URL 앞 부분에서 이름/주소를 분리
final prefix = normalized.substring(0, urlMatch?.start ?? 0).trim();
if (prefix.isNotEmpty) {
final cleaned = prefix.replaceFirst('[네이버지도]', '').trim();
// 주소 패턴(시/도/구/로/길 등)을 먼저 찾는다.
final addressRegex = RegExp(
r'(서울|부산|대구|인천|광주|대전|울산|세종|제주|경기|강원|충북|충남|전북|전남|경북|경남)[^\n]*',
);
final addrMatch = addressRegex.firstMatch(cleaned);
if (addrMatch != null) {
roadAddress = addrMatch.group(0)?.trim();
final extractedName = cleaned.substring(0, addrMatch.start).trim();
name = extractedName.isNotEmpty ? extractedName : null;
} else {
// 주소 패턴이 없으면 첫 단어가 아닌 전체를 이름으로 유지해 공백이 있어도 깨지지 않게 함
name = cleaned.isNotEmpty ? cleaned : null;
}
}
}
return _ParsedNaverShare(url: url, name: name, roadAddress: roadAddress);
}
Future<({double latitude, double longitude})?> _tryGeocode(
String roadAddress,
) async {
if (roadAddress.isEmpty) return null;
try {
final geocodingService = _ref.read(geocodingServiceProvider);
final result = await geocodingService.geocode(roadAddress);
if (result == null) return null;
return (latitude: result.latitude, longitude: result.longitude);
} catch (_) {
return null;
}
}
String _normalizeUrl(String rawUrl) {
final trimmed = rawUrl.trim();
if (trimmed.startsWith('http')) return trimmed;
return 'https://$trimmed';
}
Future<({double latitude, double longitude, bool usedCurrentLocation})> Future<({double latitude, double longitude, bool usedCurrentLocation})>
_resolveCoordinates({ _resolveCoordinates({
required String latitudeText, required String latitudeText,
@@ -329,6 +440,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
final parsedLat = double.tryParse(latitudeText); final parsedLat = double.tryParse(latitudeText);
final parsedLon = double.tryParse(longitudeText); final parsedLon = double.tryParse(longitudeText);
if (parsedLat != null && parsedLon != null) { if (parsedLat != null && parsedLon != null) {
state = state.copyWith(
geocodingStatus:
'사용자 입력 좌표 사용: ${parsedLat.toStringAsFixed(6)}, ${parsedLon.toStringAsFixed(6)}',
);
return ( return (
latitude: parsedLat, latitude: parsedLat,
longitude: parsedLon, longitude: parsedLon,
@@ -339,13 +454,22 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
final geocodingService = _ref.read(geocodingServiceProvider); final geocodingService = _ref.read(geocodingServiceProvider);
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress; final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
if (address.isNotEmpty) { if (address.isNotEmpty) {
state = state.copyWith(geocodingStatus: '지오코딩 시도: $address');
final result = await geocodingService.geocode(address); final result = await geocodingService.geocode(address);
if (result != null) { if (result != null) {
state = state.copyWith(
geocodingStatus:
'지오코딩 성공: ${result.latitude.toStringAsFixed(6)}, ${result.longitude.toStringAsFixed(6)}',
);
return ( return (
latitude: result.latitude, latitude: result.latitude,
longitude: result.longitude, longitude: result.longitude,
usedCurrentLocation: false, usedCurrentLocation: false,
); );
} else {
state = state.copyWith(
geocodingStatus: '지오코딩 실패: $address, 현재 위치/기본 좌표로 대체',
);
} }
} }
@@ -353,6 +477,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
try { try {
final position = await _ref.read(currentLocationProvider.future); final position = await _ref.read(currentLocationProvider.future);
if (position != null) { if (position != null) {
state = state.copyWith(
geocodingStatus:
'현재 위치 사용: ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}',
);
return ( return (
latitude: position.latitude, latitude: position.latitude,
longitude: position.longitude, longitude: position.longitude,
@@ -364,6 +492,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
} }
if (fallbackLatitude != null && fallbackLongitude != null) { if (fallbackLatitude != null && fallbackLongitude != null) {
state = state.copyWith(
geocodingStatus:
'네이버 데이터 좌표 사용: ${fallbackLatitude.toStringAsFixed(6)}, ${fallbackLongitude.toStringAsFixed(6)}',
);
return ( return (
latitude: fallbackLatitude, latitude: fallbackLatitude,
longitude: fallbackLongitude, longitude: fallbackLongitude,
@@ -372,6 +504,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
} }
final defaultCoords = geocodingService.defaultCoordinates(); final defaultCoords = geocodingService.defaultCoordinates();
state = state.copyWith(
geocodingStatus:
'기본 좌표 사용: ${defaultCoords.latitude.toStringAsFixed(6)}, ${defaultCoords.longitude.toStringAsFixed(6)}',
);
return ( return (
latitude: defaultCoords.latitude, latitude: defaultCoords.latitude,
longitude: defaultCoords.longitude, longitude: defaultCoords.longitude,
@@ -380,6 +516,14 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
} }
} }
class _ParsedNaverShare {
final String url;
final String? name;
final String? roadAddress;
_ParsedNaverShare({required this.url, this.name, this.roadAddress});
}
/// AddRestaurantViewModel Provider /// AddRestaurantViewModel Provider
final addRestaurantViewModelProvider = final addRestaurantViewModelProvider =
StateNotifierProvider.autoDispose< StateNotifierProvider.autoDispose<

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:lunchpick/core/constants/app_colors.dart';
import 'package:lunchpick/core/constants/app_typography.dart';
/// 네이티브 광고(Native Ad) 플레이스홀더
class NativeAdPlaceholder extends StatelessWidget {
final EdgeInsetsGeometry? margin;
final double height;
const NativeAdPlaceholder({super.key, this.margin, this.height = 120});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
margin: margin ?? EdgeInsets.zero,
padding: const EdgeInsets.all(16),
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurface : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
width: 2,
),
boxShadow: [
BoxShadow(
color: (isDark ? Colors.black : Colors.grey).withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.ad_units, color: AppColors.lightPrimary, size: 24),
const SizedBox(width: 8),
Text('광고 영역', style: AppTypography.heading2(isDark)),
],
),
),
);
}
}