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)),
+ ],
+ ),
+ ),
+ );
+ }
+}