From 3ff9e5f837742f5b7490071d2950a1e51f173066 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 3 Dec 2025 14:30:20 +0900 Subject: [PATCH] feat(app): add vworld geocoding and native ads placeholders --- android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 10 + doc/README.md | 3 +- lib/core/constants/api_keys.dart | 8 + lib/core/services/geocoding_service.dart | 66 ++++ lib/data/api/naver/naver_url_resolver.dart | 35 ++ .../datasources/remote/naver_map_parser.dart | 43 ++- .../usecases/recommendation_engine.dart | 166 ++-------- .../pages/calendar/calendar_screen.dart | 303 +++++++++-------- .../widgets/recommendation_record_card.dart | 203 +++++++----- .../calendar/widgets/visit_statistics.dart | 4 + .../random_selection_screen.dart | 120 ++++--- .../manual_restaurant_input_screen.dart | 1 + .../restaurant_list_screen.dart | 62 +++- .../widgets/add_restaurant_form.dart | 30 +- .../widgets/add_restaurant_url_tab.dart | 40 ++- .../widgets/fetched_restaurant_json_view.dart | 310 ++++++++++++++---- .../pages/share/share_screen.dart | 5 +- .../pages/splash/splash_screen.dart | 17 +- .../providers/recommendation_provider.dart | 4 +- .../providers/visit_provider.dart | 4 +- .../add_restaurant_view_model.dart | 164 ++++++++- .../widgets/native_ad_placeholder.dart | 48 +++ 23 files changed, 1108 insertions(+), 540 deletions(-) create mode 100644 lib/presentation/widgets/native_ad_placeholder.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4196cdf..032e711 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -25,7 +25,7 @@ android { applicationId = "com.naturebridgeai.lunchpick" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 + minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 723c640..4a830fc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,16 @@ + + + + + + + + + + _decodeIfNeeded(_encodedVworldApiKey); + static bool areKeysConfigured() { return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty; } diff --git a/lib/core/services/geocoding_service.dart b/lib/core/services/geocoding_service.dart index 0254671..ba7d8b3 100644 --- a/lib/core/services/geocoding_service.dart +++ b/lib/core/services/geocoding_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:lunchpick/core/constants/api_keys.dart'; import 'package:lunchpick/core/utils/app_logger.dart'; /// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스 @@ -16,6 +17,13 @@ class GeocodingService { Future<({double latitude, double longitude})?> geocode(String address) async { if (address.trim().isEmpty) return null; + // 1차: VWorld 지오코딩 시도 (키가 존재할 때만) + final vworldResult = await _geocodeWithVworld(address); + if (vworldResult != null) { + return vworldResult; + } + + // 2차: Nominatim (fallback) try { final uri = Uri.parse( '$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}', @@ -55,4 +63,62 @@ class GeocodingService { ({double latitude, double longitude}) defaultCoordinates() { 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 json = jsonDecode(response.body); + final responseNode = json['response'] as Map?; + 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?; + final point = result?['point'] as Map?; + 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; + } + } } diff --git a/lib/data/api/naver/naver_url_resolver.dart b/lib/data/api/naver/naver_url_resolver.dart index 6525843..997e510 100644 --- a/lib/data/api/naver/naver_url_resolver.dart +++ b/lib/data/api/naver/naver_url_resolver.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:lunchpick/core/utils/app_logger.dart'; import '../../../core/network/network_client.dart'; @@ -37,6 +38,12 @@ class NaverUrlResolver { return location; } + // Location이 없는 경우, http.Client로 리다이렉트를 끝까지 따라가며 최종 URL 추출 (fallback) + final expanded = await _followRedirectsWithHttp(shortUrl); + if (expanded != null) { + return expanded; + } + // 리다이렉트가 없으면 원본 URL 반환 return shortUrl; } 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 반환 return shortUrl; } @@ -161,4 +174,26 @@ class NaverUrlResolver { void dispose() { // 필요시 리소스 정리 } + + /// http.Client를 사용해 리다이렉트를 끝까지 따라가며 최종 URL을 반환한다. + /// 실패 시 null 반환. + Future _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(); + } + } } diff --git a/lib/data/datasources/remote/naver_map_parser.dart b/lib/data/datasources/remote/naver_map_parser.dart index 7b16402..23dc2eb 100644 --- a/lib/data/datasources/remote/naver_map_parser.dart +++ b/lib/data/datasources/remote/naver_map_parser.dart @@ -23,8 +23,9 @@ class NaverMapParser { // 정규식 패턴 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]+)$'); // 기본 좌표 (서울 시청) @@ -62,7 +63,7 @@ class NaverMapParser { throw NaverMapParseException('이미 dispose된 파서입니다'); } try { - AppLogger.debug('NaverMapParser: Starting to parse URL: $url'); + AppLogger.debug('[naver_url] 원본 URL 수신: $url'); // URL 유효성 검증 if (!_isValidNaverUrl(url)) { @@ -72,7 +73,7 @@ class NaverMapParser { // 짧은 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자리 숫자) final String? placeId = _extractPlaceId(finalUrl); @@ -80,13 +81,12 @@ class NaverMapParser { // 짧은 URL에서 직접 ID 추출 시도 final shortUrlId = _extractShortUrlId(url); if (shortUrlId != null) { - AppLogger.debug( - 'NaverMapParser: Using short URL ID as place ID: $shortUrlId', - ); + AppLogger.debug('[naver_url] 단축 URL ID를 Place ID로 사용: $shortUrlId'); return _createFallbackRestaurant(shortUrlId, url); } throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url'); } + AppLogger.debug('[naver_url] Place ID 추출 성공: $placeId'); // 단축 URL인 경우 특별 처리 final isShortUrl = url.contains('naver.me'); @@ -102,7 +102,10 @@ class NaverMapParser { userLatitude, userLongitude, ); - AppLogger.debug('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}'); + AppLogger.debug( + '[naver_url] LocalSearch 파싱 성공: ' + 'name=${restaurant.name}, road=${restaurant.roadAddress}', + ); return restaurant; } catch (e, stackTrace) { AppLogger.error( @@ -120,6 +123,12 @@ class NaverMapParser { userLatitude: userLatitude, userLongitude: userLongitude, ); + AppLogger.debug( + '[naver_url] GraphQL/검색 파싱 결과 요약: ' + 'name=${restaurantData['name']}, ' + 'road=${restaurantData['roadAddress']}, ' + 'phone=${restaurantData['phone']}', + ); return _createRestaurant(restaurantData, placeId, finalUrl); } catch (e) { if (e is NaverMapParseException) { @@ -150,7 +159,11 @@ class NaverMapParser { /// URL에서 Place ID 추출 String? _extractPlaceId(String 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 추출 @@ -188,6 +201,10 @@ class NaverMapParser { longitude: userLongitude, display: _searchDisplayCount, ); + AppLogger.debug( + '[naver_url] URL 기반 검색 응답 개수: ${searchResults.length}, ' + '첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}', + ); if (searchResults.isNotEmpty) { // place ID가 포함된 결과 찾기 @@ -226,6 +243,10 @@ class NaverMapParser { longitude: userLongitude, display: _searchDisplayCount, ); + AppLogger.debug( + '[naver_url] Place ID 검색 응답 개수: ${searchResults.length}, ' + '첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}', + ); if (searchResults.isNotEmpty) { AppLogger.debug( @@ -273,6 +294,9 @@ class NaverMapParser { variables: {'id': placeId}, query: NaverGraphQLQueries.placeDetailQuery, ); + AppLogger.debug( + '[naver_url] places query 응답 keys: ${response.keys.toList()}', + ); // places 응답 처리 (배열일 수도 있음) final placesData = response['data']?['places']; @@ -299,6 +323,9 @@ class NaverMapParser { variables: {'id': placeId}, query: NaverGraphQLQueries.nxPlaceDetailQuery, ); + AppLogger.debug( + '[naver_url] nxPlaces query 응답 keys: ${response.keys.toList()}', + ); // nxPlaces 응답 처리 (배열일 수도 있음) final nxPlacesData = response['data']?['nxPlaces']; diff --git a/lib/domain/usecases/recommendation_engine.dart b/lib/domain/usecases/recommendation_engine.dart index 9ec91b1..7fbc225 100644 --- a/lib/domain/usecases/recommendation_engine.dart +++ b/lib/domain/usecases/recommendation_engine.dart @@ -98,9 +98,34 @@ class RecommendationEngine { .toSet(); // 최근 방문하지 않은 식당만 필터링 - return restaurants.where((restaurant) { + final filtered = restaurants.where((restaurant) { return !recentlyVisitedIds.contains(restaurant.id); }).toList(); + + if (filtered.isNotEmpty) return filtered; + + // 모든 식당이 제외되면 가장 오래전에 방문한 식당을 반환 + final lastVisitByRestaurant = {}; + 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; - // 각 식당에 대한 가중치 계산 - final weightedRestaurants = restaurants.map((restaurant) { - 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( - 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; + // 가중치 미적용: 거리/방문 필터를 통과한 식당 중 균등 무작위 선택 + return restaurants[_random.nextInt(restaurants.length)]; } } - -/// 가중치가 적용된 식당 모델 -class _WeightedRestaurant { - final Restaurant restaurant; - final double weight; - - _WeightedRestaurant(this.restaurant, this.weight); -} diff --git a/lib/presentation/pages/calendar/calendar_screen.dart b/lib/presentation/pages/calendar/calendar_screen.dart index 3770322..92a0368 100644 --- a/lib/presentation/pages/calendar/calendar_screen.dart +++ b/lib/presentation/pages/calendar/calendar_screen.dart @@ -9,6 +9,7 @@ import '../../../domain/entities/visit_record.dart'; import '../../providers/recommendation_provider.dart'; import '../../providers/debug_test_data_provider.dart'; import '../../providers/visit_provider.dart'; +import '../../widgets/native_ad_placeholder.dart'; import 'widgets/visit_record_card.dart'; import 'widgets/recommendation_record_card.dart'; import 'widgets/visit_statistics.dart'; @@ -106,129 +107,144 @@ class _CalendarScreenState extends ConsumerState _events = _buildEvents(visits, recommendations); } - return Column( - children: [ - if (kDebugMode) - const DebugTestDataBanner( - margin: EdgeInsets.fromLTRB(16, 16, 16, 8), - ), - // 캘린더 - Card( - margin: const 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; - }); - }, - eventLoader: _getEventsForDay, - calendarBuilders: CalendarBuilders( - markerBuilder: (context, day, events) { - if (events.isEmpty) return null; + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + children: [ + if (kDebugMode) + const DebugTestDataBanner( + margin: EdgeInsets.fromLTRB(16, 16, 16, 8), + ), + Card( + margin: const 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; + }); + }, + eventLoader: _getEventsForDay, + calendarBuilders: CalendarBuilders( + markerBuilder: (context, day, events) { + if (events.isEmpty) return null; - final calendarEvents = events.cast<_CalendarEvent>(); - final confirmedVisits = calendarEvents.where( - (e) => e.visitRecord?.isConfirmed == true, - ); - final recommendedOnly = calendarEvents.where( - (e) => e.recommendationRecord != null, - ); + final calendarEvents = events + .cast<_CalendarEvent>(); + final confirmedVisits = calendarEvents.where( + (e) => e.visitRecord?.isConfirmed == true, + ); + final recommendedOnly = calendarEvents.where( + (e) => e.recommendationRecord != null, + ); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (confirmedVisits.isNotEmpty) - Container( - width: 6, - height: 6, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: const BoxDecoration( - color: AppColors.lightPrimary, - shape: BoxShape.circle, - ), + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (confirmedVisits.isNotEmpty) + Container( + width: 6, + height: 6, + margin: const EdgeInsets.symmetric( + horizontal: 1, + ), + 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) - Container( - width: 6, - height: 6, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: const BoxDecoration( - color: Colors.orange, - shape: BoxShape.circle, - ), + todayDecoration: BoxDecoration( + color: AppColors.lightPrimary.withOpacity(0.5), + shape: BoxShape.circle, ), - ], - ); - }, - ), - calendarStyle: CalendarStyle( - outsideDaysVisible: false, - selectedDecoration: const BoxDecoration( - color: AppColors.lightPrimary, - shape: BoxShape.circle, - ), - todayDecoration: BoxDecoration( - color: AppColors.lightPrimary.withOpacity(0.5), - shape: BoxShape.circle, - ), - markersMaxCount: 2, - markerDecoration: const BoxDecoration( - color: AppColors.lightSecondary, - shape: BoxShape.circle, - ), - weekendTextStyle: const 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: const TextStyle( - color: AppColors.lightPrimary, - ), + markersMaxCount: 2, + markerDecoration: const BoxDecoration( + color: AppColors.lightSecondary, + shape: BoxShape.circle, + ), + weekendTextStyle: const 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: const TextStyle( + color: AppColors.lightPrimary, + ), + ), + ), + ), + 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), + 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 ], ), ), - Expanded( - child: ListView.builder( - itemCount: events.length, - itemBuilder: (context, index) { - final event = events[index]; - if (event.visitRecord != null) { - return VisitRecordCard( - visitRecord: event.visitRecord!, - onTap: () {}, - ); - } - if (event.recommendationRecord != null) { - return RecommendationRecordCard( - recommendation: event.recommendationRecord!, - onConfirmVisit: () async { - await ref - .read(recommendationNotifierProvider.notifier) - .confirmVisit(event.recommendationRecord!.id); - }, - ); - } - return const SizedBox.shrink(); - }, - ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + if (event.visitRecord != null) { + return VisitRecordCard( + visitRecord: event.visitRecord!, + onTap: () {}, + ); + } + if (event.recommendationRecord != null) { + return RecommendationRecordCard( + recommendation: event.recommendationRecord!, + onConfirmVisit: () async { + await ref + .read(recommendationNotifierProvider.notifier) + .confirmVisit(event.recommendationRecord!.id); + }, + onDelete: () async { + await ref + .read(recommendationNotifierProvider.notifier) + .deleteRecommendation(event.recommendationRecord!.id); + }, + ); + } + return const SizedBox.shrink(); + }, ), ], ); diff --git a/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart b/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart index 107daf2..eaeb740 100644 --- a/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart +++ b/lib/presentation/pages/calendar/widgets/recommendation_record_card.dart @@ -8,11 +8,13 @@ import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; class RecommendationRecordCard extends ConsumerWidget { final RecommendationRecord recommendation; final VoidCallback onConfirmVisit; + final VoidCallback onDelete; const RecommendationRecordCard({ super.key, required this.recommendation, required this.onConfirmVisit, + required this.onDelete, }); String _formatTime(DateTime dateTime) { @@ -43,96 +45,127 @@ class RecommendationRecordCard extends ConsumerWidget { ), child: Padding( padding: const EdgeInsets.all(16), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, 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( - 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, - ), + height: 1, + color: isDark + ? AppColors.darkDivider + : AppColors.lightDivider, ), - 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( - 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, - ), - ), - ], - ), - ], + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onDelete, + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + padding: const EdgeInsets.only(top: 6), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('삭제'), ), ), - 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('방문 확인'), - ), ], ), ), diff --git a/lib/presentation/pages/calendar/widgets/visit_statistics.dart b/lib/presentation/pages/calendar/widgets/visit_statistics.dart index 61e7be0..1100556 100644 --- a/lib/presentation/pages/calendar/widgets/visit_statistics.dart +++ b/lib/presentation/pages/calendar/widgets/visit_statistics.dart @@ -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/restaurant_provider.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 { final DateTime selectedMonth; @@ -42,6 +43,9 @@ class VisitStatistics extends ConsumerWidget { _buildMonthlyStats(monthlyStatsAsync, isDark), const SizedBox(height: 16), + const NativeAdPlaceholder(), + const SizedBox(height: 16), + // 주간 통계 차트 _buildWeeklyChart(weeklyStatsAsync, isDark), const SizedBox(height: 16), diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index 717db75..6d7c3f2 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -13,6 +13,7 @@ import '../../providers/location_provider.dart'; import '../../providers/recommendation_provider.dart'; import '../../providers/restaurant_provider.dart'; import '../../providers/weather_provider.dart'; +import '../../widgets/native_ad_placeholder.dart'; import 'widgets/recommendation_result_dialog.dart'; class RandomSelectionScreen extends ConsumerStatefulWidget { @@ -51,64 +52,84 @@ class _RandomSelectionScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 맛집 리스트 현황 카드 + // 상단 요약 바 (높이 최소화) Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - elevation: 2, + elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( - padding: const EdgeInsets.all(20), - child: Column( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( children: [ - const Icon( - Icons.restaurant, - size: 48, - color: AppColors.lightPrimary, + Container( + width: 36, + height: 36, + 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), - Consumer( - builder: (context, ref, child) { - final restaurantsAsync = ref.watch( - restaurantListProvider, - ); - return restaurantsAsync.when( - data: (restaurants) => Text( - '${restaurants.length}개', - style: AppTypography.heading1( - isDark, - ).copyWith(color: AppColors.lightPrimary), - ), - loading: () => const CircularProgressIndicator( - color: AppColors.lightPrimary, - ), - error: (_, __) => Text( - '0개', - style: AppTypography.heading1( - isDark, - ).copyWith(color: AppColors.lightPrimary), - ), - ); - }, + const SizedBox(width: 10), + Expanded( + child: Consumer( + builder: (context, ref, child) { + final restaurantsAsync = ref.watch( + restaurantListProvider, + ); + return restaurantsAsync.when( + data: (restaurants) => Text( + '등록된 맛집 ${restaurants.length}개', + style: AppTypography.heading2( + isDark, + ).copyWith(fontSize: 18), + ), + loading: () => const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + 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( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - elevation: 2, + elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), child: Consumer( builder: (context, ref, child) { final weatherAsync = ref.watch(weatherProvider); @@ -164,22 +185,22 @@ class _RandomSelectionScreenState extends ConsumerState { ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), // 카테고리 선택 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - elevation: 2, + elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(12, 14, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('카테고리', style: AppTypography.heading2(isDark)), - const SizedBox(height: 12), + const SizedBox(height: 10), Consumer( builder: (context, ref, child) { final categoriesAsync = ref.watch(categoriesProvider); @@ -204,7 +225,7 @@ class _RandomSelectionScreenState extends ConsumerState { .toList(); return Wrap( spacing: 8, - runSpacing: 8, + runSpacing: 10, children: categories.isEmpty ? [const Text('카테고리 없음')] : [ @@ -227,22 +248,22 @@ class _RandomSelectionScreenState extends ConsumerState { ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), // 거리 설정 카드 Card( color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - elevation: 2, + elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(12, 14, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('최대 거리', style: AppTypography.heading2(isDark)), - const SizedBox(height: 12), + const SizedBox(height: 10), Row( children: [ Expanded( @@ -274,7 +295,7 @@ class _RandomSelectionScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 6), Consumer( builder: (context, ref, child) { final locationAsync = ref.watch( @@ -322,7 +343,7 @@ class _RandomSelectionScreenState extends ConsumerState { ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), // 추천받기 버튼 ElevatedButton( @@ -362,6 +383,11 @@ class _RandomSelectionScreenState extends ConsumerState { ], ), ), + + const SizedBox(height: 16), + const NativeAdPlaceholder( + margin: EdgeInsets.symmetric(vertical: 8), + ), ], ), ), diff --git a/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart b/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart index dfa801e..a0d08dd 100644 --- a/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart +++ b/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart @@ -159,6 +159,7 @@ class _ManualRestaurantInputScreenState onFieldChanged: _onFieldChanged, categories: categories, subCategories: subCategories, + geocodingStatus: state.geocodingStatus, ), const SizedBox(height: 24), Row( diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart index 9abecf6..d880406 100644 --- a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart +++ b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.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_typography.dart'; +import '../../../core/utils/category_mapper.dart'; import '../../../core/utils/app_logger.dart'; import '../../providers/restaurant_provider.dart'; import '../../widgets/category_selector.dart'; +import '../../widgets/native_ad_placeholder.dart'; import 'manual_restaurant_input_screen.dart'; import 'widgets/restaurant_card.dart'; import 'widgets/add_restaurant_dialog.dart'; @@ -34,9 +35,7 @@ class _RestaurantListScreenState extends ConsumerState { final searchQuery = ref.watch(searchQueryProvider); final selectedCategory = ref.watch(selectedCategoryProvider); final isFiltered = searchQuery.isNotEmpty || selectedCategory != null; - final restaurantsAsync = isFiltered - ? ref.watch(filteredRestaurantsProvider) - : ref.watch(sortedRestaurantsByDistanceProvider); + final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider); return Scaffold( backgroundColor: isDark @@ -108,25 +107,56 @@ class _RestaurantListScreenState extends ConsumerState { AppLogger.debug( '[restaurant_list_ui] data received, filtered=$isFiltered', ); - final items = isFiltered - ? (restaurantsData as List) - .map( - (r) => (restaurant: r, distanceKm: null as double?), - ) - .toList() - : restaurantsData - as List< - ({Restaurant restaurant, double? distanceKm}) - >; + var items = restaurantsData; + + if (isFiltered) { + // 검색 필터 + if (searchQuery.isNotEmpty) { + final lowercaseQuery = searchQuery.toLowerCase(); + items = items.where((item) { + final r = item.restaurant; + 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) { return _buildEmptyState(isDark); } return ListView.builder( - itemCount: items.length, + itemCount: items.length + 1, 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( restaurant: item.restaurant, distanceKm: item.distanceKm, diff --git a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart index 6da741f..d5e1a63 100644 --- a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart +++ b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart @@ -17,6 +17,7 @@ class AddRestaurantForm extends StatefulWidget { final Function(String) onFieldChanged; final List categories; final List subCategories; + final String geocodingStatus; const AddRestaurantForm({ super.key, @@ -33,6 +34,7 @@ class AddRestaurantForm extends StatefulWidget { required this.onFieldChanged, this.categories = const [], this.subCategories = const [], + this.geocodingStatus = '', }); @override @@ -255,12 +257,28 @@ class _AddRestaurantFormState extends State { ], ), const SizedBox(height: 8), - Text( - '주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.grey), - textAlign: TextAlign.center, + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.', + 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, + ), + ], + ], ), ], ), diff --git a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_url_tab.dart b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_url_tab.dart index b12919f..1c717d5 100644 --- a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_url_tab.dart +++ b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_url_tab.dart @@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon( - Icons.info_outline, - size: 20, - color: isDark - ? AppColors.darkPrimary - : AppColors.lightPrimary, - ), - const SizedBox(width: 8), - Text( - '네이버 지도에서 맛집 정보 가져오기', - style: AppTypography.body1( - isDark, - ).copyWith(fontWeight: FontWeight.bold), - ), - ], - ), + // Row( + // children: [ + // Icon( + // Icons.info_outline, + // size: 20, + // color: isDark + // ? AppColors.darkPrimary + // : AppColors.lightPrimary, + // ), + // const SizedBox(width: 8), + // Text( + // '네이버 지도에서 맛집 정보 가져오기', + // style: AppTypography.body1( + // isDark, + // ).copyWith(fontWeight: FontWeight.bold), + // ), + // ], + // ), const SizedBox(height: 8), Text( '1. 네이버 지도에서 맛집을 검색합니다\n' @@ -71,6 +71,9 @@ class AddRestaurantUrlTab extends StatelessWidget { // URL 입력 필드 TextField( controller: urlController, + keyboardType: TextInputType.multiline, + minLines: 1, + maxLines: 6, decoration: InputDecoration( labelText: '네이버 지도 URL', hintText: kIsWeb @@ -79,6 +82,7 @@ class AddRestaurantUrlTab extends StatelessWidget { prefixIcon: const Icon(Icons.link), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), errorText: errorMessage, + errorMaxLines: 8, ), onSubmitted: (_) => onFetchPressed(), ), diff --git a/lib/presentation/pages/restaurant_list/widgets/fetched_restaurant_json_view.dart b/lib/presentation/pages/restaurant_list/widgets/fetched_restaurant_json_view.dart index 69de8e0..ae1bcb0 100644 --- a/lib/presentation/pages/restaurant_list/widgets/fetched_restaurant_json_view.dart +++ b/lib/presentation/pages/restaurant_list/widgets/fetched_restaurant_json_view.dart @@ -4,7 +4,7 @@ import '../../../../core/constants/app_colors.dart'; import '../../../../core/constants/app_typography.dart'; import '../../../services/restaurant_form_validator.dart'; -class FetchedRestaurantJsonView extends StatelessWidget { +class FetchedRestaurantJsonView extends StatefulWidget { final bool isDark; final TextEditingController nameController; final TextEditingController categoryController; @@ -34,17 +34,59 @@ class FetchedRestaurantJsonView extends StatelessWidget { required this.onFieldChanged, }); + @override + State createState() => + _FetchedRestaurantJsonViewState(); +} + +class _FetchedRestaurantJsonViewState extends State { + late final FocusNode _categoryFocusNode; + late final FocusNode _subCategoryFocusNode; + late Set _availableCategories; + late Set _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 Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: isDark + color: widget.isDark ? AppColors.darkBackground : AppColors.lightBackground.withOpacity(0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: isDark ? AppColors.darkDivider : AppColors.lightDivider, + color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider, ), ), child: Column( @@ -57,78 +99,55 @@ class FetchedRestaurantJsonView extends StatelessWidget { Text( '가져온 정보', style: AppTypography.body1( - isDark, + widget.isDark, ).copyWith(fontWeight: FontWeight.w600), ), ], ), const SizedBox(height: 12), - const Text( - '{', - style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16), - ), - const SizedBox(height: 12), _buildJsonField( context, - label: 'name', - controller: nameController, + label: '상호', + controller: widget.nameController, icon: Icons.store, validator: (value) => value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null, ), _buildJsonField( context, - label: 'category', - controller: categoryController, - 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, + label: '도로명 주소', + controller: widget.roadAddressController, icon: Icons.location_on, validator: RestaurantFormValidator.validateAddress, ), _buildJsonField( context, - label: 'jibunAddress', - controller: jibunAddressController, + label: '지번 주소', + controller: widget.jibunAddressController, icon: Icons.map, ), _buildCoordinateFields(context), _buildJsonField( context, - label: 'naverUrl', - controller: naverUrlController, - icon: Icons.link, - monospace: true, + label: '전화번호', + controller: widget.phoneController, + icon: Icons.phone, + keyboardType: TextInputType.phone, + validator: RestaurantFormValidator.validatePhoneNumber, ), - const SizedBox(height: 12), - const Text( - '}', - style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16), + Row( + children: [ + Expanded(child: _buildCategoryField(context)), + 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 [ Icon(Icons.my_location, size: 16), SizedBox(width: 8), - Text('coordinates'), + Text('좌표'), ], ), const SizedBox(height: 6), @@ -153,16 +172,16 @@ class FetchedRestaurantJsonView extends StatelessWidget { children: [ Expanded( child: TextFormField( - controller: latitudeController, + controller: widget.latitudeController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: InputDecoration( - labelText: 'latitude', + labelText: '위도', border: border, isDense: true, ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) { if (value == null || value.isEmpty) { return '위도를 입력해주세요'; @@ -178,16 +197,16 @@ class FetchedRestaurantJsonView extends StatelessWidget { const SizedBox(width: 12), Expanded( child: TextFormField( - controller: longitudeController, + controller: widget.longitudeController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: InputDecoration( - labelText: 'longitude', + labelText: '경도', border: border, isDense: true, ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) { if (value == null || value.isEmpty) { return '경도를 입력해주세요'; @@ -209,6 +228,170 @@ class FetchedRestaurantJsonView extends StatelessWidget { ); } + Widget _buildCategoryField(BuildContext context) { + return RawAutocomplete( + 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( + 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( BuildContext context, { required String label, @@ -236,17 +419,18 @@ class FetchedRestaurantJsonView extends StatelessWidget { controller: controller, maxLines: maxLines, keyboardType: keyboardType, - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: validator, - style: monospace - ? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13) - : null, decoration: InputDecoration( - isDense: true, + labelText: label, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), + isDense: true, ), + style: monospace + ? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14) + : null, ), ], ), diff --git a/lib/presentation/pages/share/share_screen.dart b/lib/presentation/pages/share/share_screen.dart index 880e0c6..7f630a8 100644 --- a/lib/presentation/pages/share/share_screen.dart +++ b/lib/presentation/pages/share/share_screen.dart @@ -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/bluetooth_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; +import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart'; import 'package:uuid/uuid.dart'; class ShareScreen extends ConsumerStatefulWidget { @@ -144,7 +145,9 @@ class _ShareScreenState extends ConsumerState { subtitle: '내 맛집 리스트를 다른 사람과 공유하세요', child: _buildSendSection(isDark), ), - const SizedBox(height: 20), + const SizedBox(height: 16), + const NativeAdPlaceholder(), + const SizedBox(height: 16), _ShareCard( isDark: isDark, icon: Icons.download_rounded, diff --git a/lib/presentation/pages/splash/splash_screen.dart b/lib/presentation/pages/splash/splash_screen.dart index f9b73c0..849b006 100644 --- a/lib/presentation/pages/splash/splash_screen.dart +++ b/lib/presentation/pages/splash/splash_screen.dart @@ -1,9 +1,11 @@ import 'dart:math' as math; import 'package:flutter/material.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_typography.dart'; import '../../../core/constants/app_constants.dart'; +import '../../../core/services/permission_service.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -178,13 +180,26 @@ class _SplashScreenState extends State } void _navigateToHome() { - Future.delayed(AppConstants.splashAnimationDuration, () { + Future.wait([ + _ensurePermissions(), + Future.delayed(AppConstants.splashAnimationDuration), + ]).then((_) { if (mounted) { context.go('/home'); } }); } + Future _ensurePermissions() async { + try { + await Permission.notification.request(); + await Permission.location.request(); + await PermissionService.checkAndRequestBluetoothPermission(); + } catch (_) { + // 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다. + } + } + @override void dispose() { for (final controller in _foodControllers) { diff --git a/lib/presentation/providers/recommendation_provider.dart b/lib/presentation/providers/recommendation_provider.dart index 5ed153a..417df9e 100644 --- a/lib/presentation/providers/recommendation_provider.dart +++ b/lib/presentation/providers/recommendation_provider.dart @@ -122,7 +122,7 @@ class RecommendationNotifier extends StateNotifier> { final config = RecommendationConfig( userLatitude: location.latitude, userLongitude: location.longitude, - maxDistance: maxDistance, + maxDistance: maxDistance / 1000, // 미터 입력을 km 단위로 변환 selectedCategories: selectedCategories, userSettings: userSettings, weather: weather, @@ -307,7 +307,7 @@ class EnhancedRecommendationNotifier final config = RecommendationConfig( userLatitude: location.latitude, userLongitude: location.longitude, - maxDistance: maxDistanceNormal.toDouble(), + maxDistance: maxDistanceNormal.toDouble() / 1000, // 미터 입력을 km 단위로 변환 selectedCategories: categories, userSettings: userSettings, weather: weather, diff --git a/lib/presentation/providers/visit_provider.dart b/lib/presentation/providers/visit_provider.dart index ea4a109..ddd677c 100644 --- a/lib/presentation/providers/visit_provider.dart +++ b/lib/presentation/providers/visit_provider.dart @@ -102,8 +102,8 @@ class VisitNotifier extends StateNotifier> { required DateTime recommendationTime, bool isConfirmed = false, }) async { - // 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정 - final visitTime = recommendationTime.add(const Duration(minutes: 90)); + // 추천 확인 시점으로 방문 시간을 기록 + final visitTime = DateTime.now(); await addVisitRecord( restaurantId: restaurantId, diff --git a/lib/presentation/view_models/add_restaurant_view_model.dart b/lib/presentation/view_models/add_restaurant_view_model.dart index 2fbe9a8..cf0625f 100644 --- a/lib/presentation/view_models/add_restaurant_view_model.dart +++ b/lib/presentation/view_models/add_restaurant_view_model.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../domain/entities/restaurant.dart'; +import '../../data/datasources/remote/naver_map_parser.dart'; import '../providers/di_providers.dart'; import '../providers/restaurant_provider.dart'; import '../providers/location_provider.dart'; @@ -15,6 +16,7 @@ class AddRestaurantState { final Restaurant? fetchedRestaurantData; final RestaurantFormData formData; final List searchResults; + final String geocodingStatus; const AddRestaurantState({ this.isLoading = false, @@ -23,6 +25,7 @@ class AddRestaurantState { this.fetchedRestaurantData, required this.formData, this.searchResults = const [], + this.geocodingStatus = '', }); AddRestaurantState copyWith({ @@ -34,6 +37,7 @@ class AddRestaurantState { List? searchResults, bool clearFetchedRestaurant = false, bool clearError = false, + String? geocodingStatus, }) { return AddRestaurantState( isLoading: isLoading ?? this.isLoading, @@ -44,6 +48,7 @@ class AddRestaurantState { : (fetchedRestaurantData ?? this.fetchedRestaurantData), formData: formData ?? this.formData, searchResults: searchResults ?? this.searchResults, + geocodingStatus: geocodingStatus ?? this.geocodingStatus, ); } } @@ -179,24 +184,61 @@ class AddRestaurantViewModel extends StateNotifier { /// 네이버 URL로부터 식당 정보 가져오기 Future fetchFromNaverUrl(String url) async { - if (url.trim().isEmpty) { + final parsed = _parseSharedNaverContent(url); + + if (parsed.url.trim().isEmpty) { state = state.copyWith(errorMessage: 'URL을 입력해주세요.'); return; } + // 공유 텍스트에 포함된 상호명/도로명주소를 미리 채워 넣는다. state = state.copyWith(isLoading: true, clearError: true); try { - final repository = _ref.read(restaurantRepositoryProvider); - final restaurant = await repository.previewRestaurantFromUrl(url); + final normalizedUrl = _normalizeUrl(parsed.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( isLoading: false, - fetchedRestaurantData: restaurant, - formData: RestaurantFormData.fromRestaurant(restaurant), + fetchedRestaurantData: newForm.toRestaurant(), + formData: newForm, ); } 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 { /// 식당 정보 저장 Future saveRestaurant() async { 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 { state = state.copyWith(isLoading: true, clearError: true); @@ -260,10 +308,8 @@ class AddRestaurantViewModel extends StateNotifier { restaurantToSave = fetchedData.copyWith( name: state.formData.name, - category: state.formData.category, - subCategory: state.formData.subCategory.isEmpty - ? state.formData.category - : state.formData.subCategory, + category: fallbackCategory, + subCategory: fallbackSubCategory, description: state.formData.description.isEmpty ? null : state.formData.description, @@ -292,6 +338,8 @@ class AddRestaurantViewModel extends StateNotifier { ); restaurantToSave = state.formData.toRestaurant().copyWith( + category: fallbackCategory, + subCategory: fallbackSubCategory, latitude: coords.latitude, longitude: coords.longitude, needsAddressVerification: coords.usedCurrentLocation, @@ -317,6 +365,69 @@ class AddRestaurantViewModel extends StateNotifier { 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})> _resolveCoordinates({ required String latitudeText, @@ -329,6 +440,10 @@ class AddRestaurantViewModel extends StateNotifier { final parsedLat = double.tryParse(latitudeText); final parsedLon = double.tryParse(longitudeText); if (parsedLat != null && parsedLon != null) { + state = state.copyWith( + geocodingStatus: + '사용자 입력 좌표 사용: ${parsedLat.toStringAsFixed(6)}, ${parsedLon.toStringAsFixed(6)}', + ); return ( latitude: parsedLat, longitude: parsedLon, @@ -339,13 +454,22 @@ class AddRestaurantViewModel extends StateNotifier { final geocodingService = _ref.read(geocodingServiceProvider); final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress; if (address.isNotEmpty) { + state = state.copyWith(geocodingStatus: '지오코딩 시도: $address'); final result = await geocodingService.geocode(address); if (result != null) { + state = state.copyWith( + geocodingStatus: + '지오코딩 성공: ${result.latitude.toStringAsFixed(6)}, ${result.longitude.toStringAsFixed(6)}', + ); return ( latitude: result.latitude, longitude: result.longitude, usedCurrentLocation: false, ); + } else { + state = state.copyWith( + geocodingStatus: '지오코딩 실패: $address, 현재 위치/기본 좌표로 대체', + ); } } @@ -353,6 +477,10 @@ class AddRestaurantViewModel extends StateNotifier { try { final position = await _ref.read(currentLocationProvider.future); if (position != null) { + state = state.copyWith( + geocodingStatus: + '현재 위치 사용: ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}', + ); return ( latitude: position.latitude, longitude: position.longitude, @@ -364,6 +492,10 @@ class AddRestaurantViewModel extends StateNotifier { } if (fallbackLatitude != null && fallbackLongitude != null) { + state = state.copyWith( + geocodingStatus: + '네이버 데이터 좌표 사용: ${fallbackLatitude.toStringAsFixed(6)}, ${fallbackLongitude.toStringAsFixed(6)}', + ); return ( latitude: fallbackLatitude, longitude: fallbackLongitude, @@ -372,6 +504,10 @@ class AddRestaurantViewModel extends StateNotifier { } final defaultCoords = geocodingService.defaultCoordinates(); + state = state.copyWith( + geocodingStatus: + '기본 좌표 사용: ${defaultCoords.latitude.toStringAsFixed(6)}, ${defaultCoords.longitude.toStringAsFixed(6)}', + ); return ( latitude: defaultCoords.latitude, longitude: defaultCoords.longitude, @@ -380,6 +516,14 @@ class AddRestaurantViewModel extends StateNotifier { } } +class _ParsedNaverShare { + final String url; + final String? name; + final String? roadAddress; + + _ParsedNaverShare({required this.url, this.name, this.roadAddress}); +} + /// AddRestaurantViewModel Provider final addRestaurantViewModelProvider = StateNotifierProvider.autoDispose< diff --git a/lib/presentation/widgets/native_ad_placeholder.dart b/lib/presentation/widgets/native_ad_placeholder.dart new file mode 100644 index 0000000..1da124b --- /dev/null +++ b/lib/presentation/widgets/native_ad_placeholder.dart @@ -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)), + ], + ), + ), + ); + } +}