feat(app): add vworld geocoding and native ads placeholders
This commit is contained in:
@@ -25,7 +25,7 @@ android {
|
|||||||
applicationId = "com.naturebridgeai.lunchpick"
|
applicationId = "com.naturebridgeai.lunchpick"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = 23
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<!-- 부팅 시 실행 권한 (예약된 알림 유지) -->
|
<!-- 부팅 시 실행 권한 (예약된 알림 유지) -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<!-- 위치 권한 -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<!-- 블루투스 권한 (Android 12+) -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<!-- 블루투스 권한 (Android 11 이하 호환) -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
<application
|
<application
|
||||||
android:label="lunchpick"
|
android:label="lunchpick"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -37,4 +37,5 @@
|
|||||||
- `doc/restaurant_data/store.db`가 변경되면 `flutter pub run build_runner build --delete-conflicting-outputs` 또는 `watch`를 실행할 때마다 `assets/data/store_seed.json`과 `store_seed.meta.json`이 자동으로 재생성/병합됩니다(중복 제외, 해시 기반 버전 기록).
|
- `doc/restaurant_data/store.db`가 변경되면 `flutter pub run build_runner build --delete-conflicting-outputs` 또는 `watch`를 실행할 때마다 `assets/data/store_seed.json`과 `store_seed.meta.json`이 자동으로 재생성/병합됩니다(중복 제외, 해시 기반 버전 기록).
|
||||||
- 개발 중에는 `flutter pub run build_runner watch --delete-conflicting-outputs`를 켜두고, CI/빌드 파이프라인에도 동일 명령을 pre-step으로 추가하면 배포 전에 항상 최신 시드가 패키징됩니다.
|
- 개발 중에는 `flutter pub run build_runner watch --delete-conflicting-outputs`를 켜두고, CI/빌드 파이프라인에도 동일 명령을 pre-step으로 추가하면 배포 전에 항상 최신 시드가 패키징됩니다.
|
||||||
|
|
||||||
flutter run -d chrome --dart-define=KMA_SERVICE_KEY=MTg0Y2UzN2VlZmFjMGJlNWNmY2JjYWUyNmUxZDZlNjIzYmU5MDYyZmY3NDM5NjVlMzkwZmNkMzgzMGY3MTFiZg==
|
flutter run -d R3CN70AJJ6Y --debug --uninstall-first --dart-define=KMA_SERVICE_KEY=MTg0Y2UzN2VlZmFjMGJlNWNmY2JjYWUyNmUxZDZlNjIzYmU5MDYyZmY3NDM5NjVlMzkwZmNkMzgzMGY3MTFiZg== --dart-define=VWORLD_API_KEY=7E33D818-6B06-3957-BCEF-E37EF702FAD6
|
||||||
|
빌드시 키값을 포함해야 함.
|
||||||
@@ -29,6 +29,14 @@ class ApiKeys {
|
|||||||
static const String naverLocalSearchEndpoint =
|
static const String naverLocalSearchEndpoint =
|
||||||
'https://openapi.naver.com/v1/search/local.json';
|
'https://openapi.naver.com/v1/search/local.json';
|
||||||
|
|
||||||
|
// VWorld 지오코딩 키 (dart-define: VWORLD_API_KEY, base64 권장)
|
||||||
|
static const String _encodedVworldApiKey = String.fromEnvironment(
|
||||||
|
'VWORLD_API_KEY',
|
||||||
|
defaultValue: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
static String get vworldApiKey => _decodeIfNeeded(_encodedVworldApiKey);
|
||||||
|
|
||||||
static bool areKeysConfigured() {
|
static bool areKeysConfigured() {
|
||||||
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
/// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스
|
/// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스
|
||||||
@@ -16,6 +17,13 @@ class GeocodingService {
|
|||||||
Future<({double latitude, double longitude})?> geocode(String address) async {
|
Future<({double latitude, double longitude})?> geocode(String address) async {
|
||||||
if (address.trim().isEmpty) return null;
|
if (address.trim().isEmpty) return null;
|
||||||
|
|
||||||
|
// 1차: VWorld 지오코딩 시도 (키가 존재할 때만)
|
||||||
|
final vworldResult = await _geocodeWithVworld(address);
|
||||||
|
if (vworldResult != null) {
|
||||||
|
return vworldResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2차: Nominatim (fallback)
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}',
|
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}',
|
||||||
@@ -55,4 +63,62 @@ class GeocodingService {
|
|||||||
({double latitude, double longitude}) defaultCoordinates() {
|
({double latitude, double longitude}) defaultCoordinates() {
|
||||||
return (latitude: _fallbackLatitude, longitude: _fallbackLongitude);
|
return (latitude: _fallbackLatitude, longitude: _fallbackLongitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<({double latitude, double longitude})?> _geocodeWithVworld(
|
||||||
|
String address,
|
||||||
|
) async {
|
||||||
|
final apiKey = ApiKeys.vworldApiKey;
|
||||||
|
if (apiKey.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.https('api.vworld.kr', '/req/address', {
|
||||||
|
'service': 'address',
|
||||||
|
'request': 'getcoord',
|
||||||
|
'format': 'json',
|
||||||
|
'type': 'road', // 도로명 주소 기준
|
||||||
|
'key': apiKey,
|
||||||
|
'address': address,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
AppLogger.debug(
|
||||||
|
'[GeocodingService] VWorld 실패 status: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> json = jsonDecode(response.body);
|
||||||
|
final responseNode = json['response'] as Map<String, dynamic>?;
|
||||||
|
if (responseNode == null || responseNode['status'] != 'OK') {
|
||||||
|
AppLogger.debug('[GeocodingService] VWorld 응답 오류: ${response.body}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VWorld 포인트는 WGS84 lon/lat 순서(x=lon, y=lat)
|
||||||
|
final result = responseNode['result'] as Map<String, dynamic>?;
|
||||||
|
final point = result?['point'] as Map<String, dynamic>?;
|
||||||
|
final x = point?['x']?.toString();
|
||||||
|
final y = point?['y']?.toString();
|
||||||
|
final lon = x != null ? double.tryParse(x) : null;
|
||||||
|
final lat = y != null ? double.tryParse(y) : null;
|
||||||
|
if (lat == null || lon == null) {
|
||||||
|
AppLogger.debug(
|
||||||
|
'[GeocodingService] VWorld 좌표 파싱 실패: ${point.toString()}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (latitude: lat, longitude: lon);
|
||||||
|
} catch (e) {
|
||||||
|
AppLogger.debug('[GeocodingService] VWorld 예외: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../../../core/network/network_client.dart';
|
import '../../../core/network/network_client.dart';
|
||||||
@@ -37,6 +38,12 @@ class NaverUrlResolver {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location이 없는 경우, http.Client로 리다이렉트를 끝까지 따라가며 최종 URL 추출 (fallback)
|
||||||
|
final expanded = await _followRedirectsWithHttp(shortUrl);
|
||||||
|
if (expanded != null) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
// 리다이렉트가 없으면 원본 URL 반환
|
// 리다이렉트가 없으면 원본 URL 반환
|
||||||
return shortUrl;
|
return shortUrl;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -54,6 +61,12 @@ class NaverUrlResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dio 실패 시 fallback으로 http.Client 리다이렉트 추적 시도
|
||||||
|
final expanded = await _followRedirectsWithHttp(shortUrl);
|
||||||
|
if (expanded != null) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
// 오류 발생 시 원본 URL 반환
|
// 오류 발생 시 원본 URL 반환
|
||||||
return shortUrl;
|
return shortUrl;
|
||||||
}
|
}
|
||||||
@@ -161,4 +174,26 @@ class NaverUrlResolver {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
// 필요시 리소스 정리
|
// 필요시 리소스 정리
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// http.Client를 사용해 리다이렉트를 끝까지 따라가며 최종 URL을 반환한다.
|
||||||
|
/// 실패 시 null 반환.
|
||||||
|
Future<String?> _followRedirectsWithHttp(String shortUrl) async {
|
||||||
|
final client = http.Client();
|
||||||
|
try {
|
||||||
|
final request = http.Request('HEAD', Uri.parse(shortUrl))
|
||||||
|
..followRedirects = true
|
||||||
|
..maxRedirects = 5;
|
||||||
|
final response = await client.send(request);
|
||||||
|
return response.request?.url.toString();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
AppLogger.error(
|
||||||
|
'_followRedirectsWithHttp error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ class NaverMapParser {
|
|||||||
|
|
||||||
// 정규식 패턴
|
// 정규식 패턴
|
||||||
static final RegExp _placeIdRegex = RegExp(
|
static final RegExp _placeIdRegex = RegExp(
|
||||||
r'/p/(?:restaurant|entry/place)/(\d+)',
|
r'(?:/p/(?:restaurant|entry/place)/|/place/)(\d+)',
|
||||||
);
|
);
|
||||||
|
static final RegExp _pinIdRegex = RegExp(r'pinId["=](\d+)');
|
||||||
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
||||||
|
|
||||||
// 기본 좌표 (서울 시청)
|
// 기본 좌표 (서울 시청)
|
||||||
@@ -62,7 +63,7 @@ class NaverMapParser {
|
|||||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
AppLogger.debug('NaverMapParser: Starting to parse URL: $url');
|
AppLogger.debug('[naver_url] 원본 URL 수신: $url');
|
||||||
|
|
||||||
// URL 유효성 검증
|
// URL 유효성 검증
|
||||||
if (!_isValidNaverUrl(url)) {
|
if (!_isValidNaverUrl(url)) {
|
||||||
@@ -72,7 +73,7 @@ class NaverMapParser {
|
|||||||
// 짧은 URL인 경우 리다이렉트 처리
|
// 짧은 URL인 경우 리다이렉트 처리
|
||||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||||
|
|
||||||
AppLogger.debug('NaverMapParser: Final URL after redirect: $finalUrl');
|
AppLogger.debug('[naver_url] resolveShortUrl 결과: $finalUrl');
|
||||||
|
|
||||||
// Place ID 추출 (10자리 숫자)
|
// Place ID 추출 (10자리 숫자)
|
||||||
final String? placeId = _extractPlaceId(finalUrl);
|
final String? placeId = _extractPlaceId(finalUrl);
|
||||||
@@ -80,13 +81,12 @@ class NaverMapParser {
|
|||||||
// 짧은 URL에서 직접 ID 추출 시도
|
// 짧은 URL에서 직접 ID 추출 시도
|
||||||
final shortUrlId = _extractShortUrlId(url);
|
final shortUrlId = _extractShortUrlId(url);
|
||||||
if (shortUrlId != null) {
|
if (shortUrlId != null) {
|
||||||
AppLogger.debug(
|
AppLogger.debug('[naver_url] 단축 URL ID를 Place ID로 사용: $shortUrlId');
|
||||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
|
||||||
);
|
|
||||||
return _createFallbackRestaurant(shortUrlId, url);
|
return _createFallbackRestaurant(shortUrlId, url);
|
||||||
}
|
}
|
||||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||||
}
|
}
|
||||||
|
AppLogger.debug('[naver_url] Place ID 추출 성공: $placeId');
|
||||||
|
|
||||||
// 단축 URL인 경우 특별 처리
|
// 단축 URL인 경우 특별 처리
|
||||||
final isShortUrl = url.contains('naver.me');
|
final isShortUrl = url.contains('naver.me');
|
||||||
@@ -102,7 +102,10 @@ class NaverMapParser {
|
|||||||
userLatitude,
|
userLatitude,
|
||||||
userLongitude,
|
userLongitude,
|
||||||
);
|
);
|
||||||
AppLogger.debug('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
AppLogger.debug(
|
||||||
|
'[naver_url] LocalSearch 파싱 성공: '
|
||||||
|
'name=${restaurant.name}, road=${restaurant.roadAddress}',
|
||||||
|
);
|
||||||
return restaurant;
|
return restaurant;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
AppLogger.error(
|
AppLogger.error(
|
||||||
@@ -120,6 +123,12 @@ class NaverMapParser {
|
|||||||
userLatitude: userLatitude,
|
userLatitude: userLatitude,
|
||||||
userLongitude: userLongitude,
|
userLongitude: userLongitude,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] GraphQL/검색 파싱 결과 요약: '
|
||||||
|
'name=${restaurantData['name']}, '
|
||||||
|
'road=${restaurantData['roadAddress']}, '
|
||||||
|
'phone=${restaurantData['phone']}',
|
||||||
|
);
|
||||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is NaverMapParseException) {
|
if (e is NaverMapParseException) {
|
||||||
@@ -150,7 +159,11 @@ class NaverMapParser {
|
|||||||
/// URL에서 Place ID 추출
|
/// URL에서 Place ID 추출
|
||||||
String? _extractPlaceId(String url) {
|
String? _extractPlaceId(String url) {
|
||||||
final match = _placeIdRegex.firstMatch(url);
|
final match = _placeIdRegex.firstMatch(url);
|
||||||
return match?.group(1);
|
if (match != null) return match.group(1);
|
||||||
|
|
||||||
|
// 핀 공유 형식: pinId="1234567890" 또는 pinId=1234567890
|
||||||
|
final pinMatch = _pinIdRegex.firstMatch(url);
|
||||||
|
return pinMatch?.group(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 짧은 URL에서 ID 추출
|
/// 짧은 URL에서 ID 추출
|
||||||
@@ -188,6 +201,10 @@ class NaverMapParser {
|
|||||||
longitude: userLongitude,
|
longitude: userLongitude,
|
||||||
display: _searchDisplayCount,
|
display: _searchDisplayCount,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] URL 기반 검색 응답 개수: ${searchResults.length}, '
|
||||||
|
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
||||||
|
);
|
||||||
|
|
||||||
if (searchResults.isNotEmpty) {
|
if (searchResults.isNotEmpty) {
|
||||||
// place ID가 포함된 결과 찾기
|
// place ID가 포함된 결과 찾기
|
||||||
@@ -226,6 +243,10 @@ class NaverMapParser {
|
|||||||
longitude: userLongitude,
|
longitude: userLongitude,
|
||||||
display: _searchDisplayCount,
|
display: _searchDisplayCount,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] Place ID 검색 응답 개수: ${searchResults.length}, '
|
||||||
|
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
||||||
|
);
|
||||||
|
|
||||||
if (searchResults.isNotEmpty) {
|
if (searchResults.isNotEmpty) {
|
||||||
AppLogger.debug(
|
AppLogger.debug(
|
||||||
@@ -273,6 +294,9 @@ class NaverMapParser {
|
|||||||
variables: {'id': placeId},
|
variables: {'id': placeId},
|
||||||
query: NaverGraphQLQueries.placeDetailQuery,
|
query: NaverGraphQLQueries.placeDetailQuery,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] places query 응답 keys: ${response.keys.toList()}',
|
||||||
|
);
|
||||||
|
|
||||||
// places 응답 처리 (배열일 수도 있음)
|
// places 응답 처리 (배열일 수도 있음)
|
||||||
final placesData = response['data']?['places'];
|
final placesData = response['data']?['places'];
|
||||||
@@ -299,6 +323,9 @@ class NaverMapParser {
|
|||||||
variables: {'id': placeId},
|
variables: {'id': placeId},
|
||||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] nxPlaces query 응답 keys: ${response.keys.toList()}',
|
||||||
|
);
|
||||||
|
|
||||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||||
final nxPlacesData = response['data']?['nxPlaces'];
|
final nxPlacesData = response['data']?['nxPlaces'];
|
||||||
|
|||||||
@@ -98,9 +98,34 @@ class RecommendationEngine {
|
|||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
// 최근 방문하지 않은 식당만 필터링
|
// 최근 방문하지 않은 식당만 필터링
|
||||||
return restaurants.where((restaurant) {
|
final filtered = restaurants.where((restaurant) {
|
||||||
return !recentlyVisitedIds.contains(restaurant.id);
|
return !recentlyVisitedIds.contains(restaurant.id);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
if (filtered.isNotEmpty) return filtered;
|
||||||
|
|
||||||
|
// 모든 식당이 제외되면 가장 오래전에 방문한 식당을 반환
|
||||||
|
final lastVisitByRestaurant = <String, DateTime>{};
|
||||||
|
for (final visit in recentVisits) {
|
||||||
|
final current = lastVisitByRestaurant[visit.restaurantId];
|
||||||
|
if (current == null || visit.visitDate.isAfter(current)) {
|
||||||
|
lastVisitByRestaurant[visit.restaurantId] = visit.visitDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Restaurant? oldestRestaurant;
|
||||||
|
DateTime? oldestVisitDate;
|
||||||
|
for (final restaurant in restaurants) {
|
||||||
|
final lastVisit = lastVisitByRestaurant[restaurant.id];
|
||||||
|
if (lastVisit == null) continue;
|
||||||
|
|
||||||
|
if (oldestVisitDate == null || lastVisit.isBefore(oldestVisitDate)) {
|
||||||
|
oldestVisitDate = lastVisit;
|
||||||
|
oldestRestaurant = restaurant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldestRestaurant != null ? [oldestRestaurant] : restaurants;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 카테고리 필터링
|
/// 카테고리 필터링
|
||||||
@@ -123,142 +148,7 @@ class RecommendationEngine {
|
|||||||
) {
|
) {
|
||||||
if (restaurants.isEmpty) return null;
|
if (restaurants.isEmpty) return null;
|
||||||
|
|
||||||
// 각 식당에 대한 가중치 계산
|
// 가중치 미적용: 거리/방문 필터를 통과한 식당 중 균등 무작위 선택
|
||||||
final weightedRestaurants = restaurants.map((restaurant) {
|
return restaurants[_random.nextInt(restaurants.length)];
|
||||||
double weight = 1.0;
|
|
||||||
|
|
||||||
// 카테고리 가중치 적용
|
|
||||||
final categoryWeight =
|
|
||||||
config.userSettings.categoryWeights[restaurant.category];
|
|
||||||
if (categoryWeight != null) {
|
|
||||||
weight *= categoryWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 거리 가중치 적용 (가까울수록 높은 가중치)
|
|
||||||
final distance = DistanceCalculator.calculateDistance(
|
|
||||||
lat1: config.userLatitude,
|
|
||||||
lon1: config.userLongitude,
|
|
||||||
lat2: restaurant.latitude,
|
|
||||||
lon2: restaurant.longitude,
|
|
||||||
);
|
|
||||||
final distanceWeight = 1.0 - (distance / config.maxDistance);
|
|
||||||
weight *= (0.5 + distanceWeight * 0.5); // 50% ~ 100% 범위
|
|
||||||
|
|
||||||
// 시간대별 가중치 적용
|
|
||||||
weight *= _getTimeBasedWeight(restaurant, config.currentTime);
|
|
||||||
|
|
||||||
// 날씨 기반 가중치 적용
|
|
||||||
if (config.weather != null) {
|
|
||||||
weight *= _getWeatherBasedWeight(restaurant, config.weather!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _WeightedRestaurant(restaurant, weight);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// 가중치 기반 랜덤 선택
|
|
||||||
return _weightedRandomSelection(weightedRestaurants);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 시간대별 가중치 계산
|
|
||||||
double _getTimeBasedWeight(Restaurant restaurant, DateTime currentTime) {
|
|
||||||
final hour = currentTime.hour;
|
|
||||||
|
|
||||||
// 아침 시간대 (7-10시)
|
|
||||||
if (hour >= 7 && hour < 10) {
|
|
||||||
if (restaurant.category == 'cafe' || restaurant.category == 'korean') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
if (restaurant.category == 'bar') {
|
|
||||||
return 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 점심 시간대 (11-14시)
|
|
||||||
else if (hour >= 11 && hour < 14) {
|
|
||||||
if (restaurant.category == 'korean' ||
|
|
||||||
restaurant.category == 'chinese' ||
|
|
||||||
restaurant.category == 'japanese') {
|
|
||||||
return 1.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 저녁 시간대 (17-21시)
|
|
||||||
else if (hour >= 17 && hour < 21) {
|
|
||||||
if (restaurant.category == 'bar' || restaurant.category == 'western') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 늦은 저녁 (21시 이후)
|
|
||||||
else if (hour >= 21) {
|
|
||||||
if (restaurant.category == 'bar' || restaurant.category == 'fastfood') {
|
|
||||||
return 1.3;
|
|
||||||
}
|
|
||||||
if (restaurant.category == 'cafe') {
|
|
||||||
return 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 날씨 기반 가중치 계산
|
|
||||||
double _getWeatherBasedWeight(Restaurant restaurant, WeatherInfo weather) {
|
|
||||||
if (weather.current.isRainy) {
|
|
||||||
// 비가 올 때는 가까운 식당 선호
|
|
||||||
// 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호
|
|
||||||
if (restaurant.category == 'cafe' || restaurant.category == 'fastfood') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 더운 날씨 (25도 이상)
|
|
||||||
if (weather.current.temperature >= 25) {
|
|
||||||
if (restaurant.category == 'cafe' || restaurant.category == 'japanese') {
|
|
||||||
return 1.1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추운 날씨 (10도 이하)
|
|
||||||
if (weather.current.temperature <= 10) {
|
|
||||||
if (restaurant.category == 'korean' || restaurant.category == 'chinese') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가중치 기반 랜덤 선택
|
|
||||||
Restaurant? _weightedRandomSelection(
|
|
||||||
List<_WeightedRestaurant> weightedRestaurants,
|
|
||||||
) {
|
|
||||||
if (weightedRestaurants.isEmpty) return null;
|
|
||||||
|
|
||||||
// 전체 가중치 합계 계산
|
|
||||||
final totalWeight = weightedRestaurants.fold<double>(
|
|
||||||
0,
|
|
||||||
(sum, item) => sum + item.weight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 랜덤 값 생성
|
|
||||||
final randomValue = _random.nextDouble() * totalWeight;
|
|
||||||
|
|
||||||
// 누적 가중치로 선택
|
|
||||||
double cumulativeWeight = 0;
|
|
||||||
for (final weightedRestaurant in weightedRestaurants) {
|
|
||||||
cumulativeWeight += weightedRestaurant.weight;
|
|
||||||
if (randomValue <= cumulativeWeight) {
|
|
||||||
return weightedRestaurant.restaurant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 예외 처리 (여기에 도달하면 안됨)
|
|
||||||
return weightedRestaurants.last.restaurant;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 가중치가 적용된 식당 모델
|
|
||||||
class _WeightedRestaurant {
|
|
||||||
final Restaurant restaurant;
|
|
||||||
final double weight;
|
|
||||||
|
|
||||||
_WeightedRestaurant(this.restaurant, this.weight);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../domain/entities/visit_record.dart';
|
|||||||
import '../../providers/recommendation_provider.dart';
|
import '../../providers/recommendation_provider.dart';
|
||||||
import '../../providers/debug_test_data_provider.dart';
|
import '../../providers/debug_test_data_provider.dart';
|
||||||
import '../../providers/visit_provider.dart';
|
import '../../providers/visit_provider.dart';
|
||||||
|
import '../../widgets/native_ad_placeholder.dart';
|
||||||
import 'widgets/visit_record_card.dart';
|
import 'widgets/visit_record_card.dart';
|
||||||
import 'widgets/recommendation_record_card.dart';
|
import 'widgets/recommendation_record_card.dart';
|
||||||
import 'widgets/visit_statistics.dart';
|
import 'widgets/visit_statistics.dart';
|
||||||
@@ -106,129 +107,144 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
_events = _buildEvents(visits, recommendations);
|
_events = _buildEvents(visits, recommendations);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
if (kDebugMode)
|
return SingleChildScrollView(
|
||||||
const DebugTestDataBanner(
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
margin: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
child: ConstrainedBox(
|
||||||
),
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
// 캘린더
|
child: Column(
|
||||||
Card(
|
children: [
|
||||||
margin: const EdgeInsets.all(16),
|
if (kDebugMode)
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
const DebugTestDataBanner(
|
||||||
elevation: 2,
|
margin: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
shape: RoundedRectangleBorder(
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
Card(
|
||||||
),
|
margin: const EdgeInsets.all(16),
|
||||||
child: TableCalendar(
|
color: isDark
|
||||||
firstDay: DateTime.utc(2025, 1, 1),
|
? AppColors.darkSurface
|
||||||
lastDay: DateTime.utc(2030, 12, 31),
|
: AppColors.lightSurface,
|
||||||
focusedDay: _focusedDay,
|
elevation: 2,
|
||||||
calendarFormat: _calendarFormat,
|
shape: RoundedRectangleBorder(
|
||||||
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
borderRadius: BorderRadius.circular(12),
|
||||||
onDaySelected: (selectedDay, focusedDay) {
|
),
|
||||||
setState(() {
|
child: TableCalendar(
|
||||||
_selectedDay = selectedDay;
|
firstDay: DateTime.utc(2025, 1, 1),
|
||||||
_focusedDay = focusedDay;
|
lastDay: DateTime.utc(2030, 12, 31),
|
||||||
});
|
focusedDay: _focusedDay,
|
||||||
},
|
calendarFormat: _calendarFormat,
|
||||||
onFormatChanged: (format) {
|
selectedDayPredicate: (day) =>
|
||||||
setState(() {
|
isSameDay(_selectedDay, day),
|
||||||
_calendarFormat = format;
|
onDaySelected: (selectedDay, focusedDay) {
|
||||||
});
|
setState(() {
|
||||||
},
|
_selectedDay = selectedDay;
|
||||||
eventLoader: _getEventsForDay,
|
_focusedDay = focusedDay;
|
||||||
calendarBuilders: CalendarBuilders(
|
});
|
||||||
markerBuilder: (context, day, events) {
|
},
|
||||||
if (events.isEmpty) return null;
|
onFormatChanged: (format) {
|
||||||
|
setState(() {
|
||||||
|
_calendarFormat = format;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
eventLoader: _getEventsForDay,
|
||||||
|
calendarBuilders: CalendarBuilders(
|
||||||
|
markerBuilder: (context, day, events) {
|
||||||
|
if (events.isEmpty) return null;
|
||||||
|
|
||||||
final calendarEvents = events.cast<_CalendarEvent>();
|
final calendarEvents = events
|
||||||
final confirmedVisits = calendarEvents.where(
|
.cast<_CalendarEvent>();
|
||||||
(e) => e.visitRecord?.isConfirmed == true,
|
final confirmedVisits = calendarEvents.where(
|
||||||
);
|
(e) => e.visitRecord?.isConfirmed == true,
|
||||||
final recommendedOnly = calendarEvents.where(
|
);
|
||||||
(e) => e.recommendationRecord != null,
|
final recommendedOnly = calendarEvents.where(
|
||||||
);
|
(e) => e.recommendationRecord != null,
|
||||||
|
);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (confirmedVisits.isNotEmpty)
|
if (confirmedVisits.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
margin: const EdgeInsets.symmetric(
|
||||||
decoration: const BoxDecoration(
|
horizontal: 1,
|
||||||
color: AppColors.lightPrimary,
|
),
|
||||||
shape: BoxShape.circle,
|
decoration: const BoxDecoration(
|
||||||
),
|
color: AppColors.lightPrimary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (recommendedOnly.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 1,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.orange,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
calendarStyle: CalendarStyle(
|
||||||
|
outsideDaysVisible: false,
|
||||||
|
selectedDecoration: const BoxDecoration(
|
||||||
|
color: AppColors.lightPrimary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
if (recommendedOnly.isNotEmpty)
|
todayDecoration: BoxDecoration(
|
||||||
Container(
|
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||||
width: 6,
|
shape: BoxShape.circle,
|
||||||
height: 6,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.orange,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
markersMaxCount: 2,
|
||||||
);
|
markerDecoration: const BoxDecoration(
|
||||||
},
|
color: AppColors.lightSecondary,
|
||||||
),
|
shape: BoxShape.circle,
|
||||||
calendarStyle: CalendarStyle(
|
),
|
||||||
outsideDaysVisible: false,
|
weekendTextStyle: const TextStyle(
|
||||||
selectedDecoration: const BoxDecoration(
|
color: AppColors.lightError,
|
||||||
color: AppColors.lightPrimary,
|
),
|
||||||
shape: BoxShape.circle,
|
),
|
||||||
),
|
headerStyle: HeaderStyle(
|
||||||
todayDecoration: BoxDecoration(
|
formatButtonVisible: true,
|
||||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
titleCentered: true,
|
||||||
shape: BoxShape.circle,
|
formatButtonShowsNext: false,
|
||||||
),
|
formatButtonDecoration: BoxDecoration(
|
||||||
markersMaxCount: 2,
|
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||||
markerDecoration: const BoxDecoration(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: AppColors.lightSecondary,
|
),
|
||||||
shape: BoxShape.circle,
|
formatButtonTextStyle: const TextStyle(
|
||||||
),
|
color: AppColors.lightPrimary,
|
||||||
weekendTextStyle: const TextStyle(
|
),
|
||||||
color: AppColors.lightError,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
headerStyle: HeaderStyle(
|
Padding(
|
||||||
formatButtonVisible: true,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
titleCentered: true,
|
child: Row(
|
||||||
formatButtonShowsNext: false,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
formatButtonDecoration: BoxDecoration(
|
children: [
|
||||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
_buildLegend('추천받음', Colors.orange, isDark),
|
||||||
borderRadius: BorderRadius.circular(12),
|
const SizedBox(width: 24),
|
||||||
),
|
_buildLegend('방문완료', Colors.green, isDark),
|
||||||
formatButtonTextStyle: const TextStyle(
|
],
|
||||||
color: AppColors.lightPrimary,
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const NativeAdPlaceholder(
|
||||||
|
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
),
|
||||||
|
_buildDayRecords(_selectedDay, isDark),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
// 범례
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_buildLegend('추천받음', Colors.orange, isDark),
|
|
||||||
const SizedBox(width: 24),
|
|
||||||
_buildLegend('방문완료', Colors.green, isDark),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 선택된 날짜의 기록
|
|
||||||
Expanded(child: _buildDayRecords(_selectedDay, isDark)),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -302,30 +318,35 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
ListView.builder(
|
||||||
child: ListView.builder(
|
shrinkWrap: true,
|
||||||
itemCount: events.length,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemBuilder: (context, index) {
|
itemCount: events.length,
|
||||||
final event = events[index];
|
itemBuilder: (context, index) {
|
||||||
if (event.visitRecord != null) {
|
final event = events[index];
|
||||||
return VisitRecordCard(
|
if (event.visitRecord != null) {
|
||||||
visitRecord: event.visitRecord!,
|
return VisitRecordCard(
|
||||||
onTap: () {},
|
visitRecord: event.visitRecord!,
|
||||||
);
|
onTap: () {},
|
||||||
}
|
);
|
||||||
if (event.recommendationRecord != null) {
|
}
|
||||||
return RecommendationRecordCard(
|
if (event.recommendationRecord != null) {
|
||||||
recommendation: event.recommendationRecord!,
|
return RecommendationRecordCard(
|
||||||
onConfirmVisit: () async {
|
recommendation: event.recommendationRecord!,
|
||||||
await ref
|
onConfirmVisit: () async {
|
||||||
.read(recommendationNotifierProvider.notifier)
|
await ref
|
||||||
.confirmVisit(event.recommendationRecord!.id);
|
.read(recommendationNotifierProvider.notifier)
|
||||||
},
|
.confirmVisit(event.recommendationRecord!.id);
|
||||||
);
|
},
|
||||||
}
|
onDelete: () async {
|
||||||
return const SizedBox.shrink();
|
await ref
|
||||||
},
|
.read(recommendationNotifierProvider.notifier)
|
||||||
),
|
.deleteRecommendation(event.recommendationRecord!.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
|||||||
class RecommendationRecordCard extends ConsumerWidget {
|
class RecommendationRecordCard extends ConsumerWidget {
|
||||||
final RecommendationRecord recommendation;
|
final RecommendationRecord recommendation;
|
||||||
final VoidCallback onConfirmVisit;
|
final VoidCallback onConfirmVisit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
const RecommendationRecordCard({
|
const RecommendationRecordCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.recommendation,
|
required this.recommendation,
|
||||||
required this.onConfirmVisit,
|
required this.onConfirmVisit,
|
||||||
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _formatTime(DateTime dateTime) {
|
String _formatTime(DateTime dateTime) {
|
||||||
@@ -43,96 +45,127 @@ class RecommendationRecordCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.whatshot,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
restaurant.name,
|
||||||
|
style: AppTypography.body1(
|
||||||
|
isDark,
|
||||||
|
).copyWith(fontWeight: FontWeight.bold),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.category_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.darkTextSecondary
|
||||||
|
: AppColors.lightTextSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
restaurant.category,
|
||||||
|
style: AppTypography.caption(isDark),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.darkTextSecondary
|
||||||
|
: AppColors.lightTextSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatTime(recommendation.recommendationDate),
|
||||||
|
style: AppTypography.caption(isDark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
||||||
|
style: AppTypography.caption(isDark).copyWith(
|
||||||
|
color: Colors.orange,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
softWrap: true,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.visible,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: onConfirmVisit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.lightPrimary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
minimumSize: const Size(0, 40),
|
||||||
|
),
|
||||||
|
child: const Text('방문 확인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
Container(
|
Container(
|
||||||
width: 40,
|
height: 1,
|
||||||
height: 40,
|
color: isDark
|
||||||
decoration: BoxDecoration(
|
? AppColors.darkDivider
|
||||||
color: Colors.orange.withValues(alpha: 0.1),
|
: AppColors.lightDivider,
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.whatshot,
|
|
||||||
color: Colors.orange,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
Align(
|
||||||
Expanded(
|
alignment: Alignment.centerRight,
|
||||||
child: Column(
|
child: TextButton(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onPressed: onDelete,
|
||||||
children: [
|
style: TextButton.styleFrom(
|
||||||
Text(
|
foregroundColor: Colors.redAccent,
|
||||||
restaurant.name,
|
padding: const EdgeInsets.only(top: 6),
|
||||||
style: AppTypography.body1(
|
minimumSize: const Size(0, 32),
|
||||||
isDark,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
).copyWith(fontWeight: FontWeight.bold),
|
),
|
||||||
maxLines: 1,
|
child: const Text('삭제'),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.category_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: isDark
|
|
||||||
? AppColors.darkTextSecondary
|
|
||||||
: AppColors.lightTextSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
restaurant.category,
|
|
||||||
style: AppTypography.caption(isDark),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
size: 14,
|
|
||||||
color: isDark
|
|
||||||
? AppColors.darkTextSecondary
|
|
||||||
: AppColors.lightTextSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatTime(recommendation.recommendationDate),
|
|
||||||
style: AppTypography.caption(isDark),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.orange,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
|
||||||
style: AppTypography.caption(isDark).copyWith(
|
|
||||||
color: Colors.orange,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: onConfirmVisit,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.lightPrimary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
minimumSize: const Size(0, 40),
|
|
||||||
),
|
|
||||||
child: const Text('방문 확인'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:lunchpick/core/constants/app_typography.dart';
|
|||||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||||
import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart';
|
import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart';
|
||||||
|
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||||
|
|
||||||
class VisitStatistics extends ConsumerWidget {
|
class VisitStatistics extends ConsumerWidget {
|
||||||
final DateTime selectedMonth;
|
final DateTime selectedMonth;
|
||||||
@@ -42,6 +43,9 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
const NativeAdPlaceholder(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 주간 통계 차트
|
// 주간 통계 차트
|
||||||
_buildWeeklyChart(weeklyStatsAsync, isDark),
|
_buildWeeklyChart(weeklyStatsAsync, isDark),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../providers/location_provider.dart';
|
|||||||
import '../../providers/recommendation_provider.dart';
|
import '../../providers/recommendation_provider.dart';
|
||||||
import '../../providers/restaurant_provider.dart';
|
import '../../providers/restaurant_provider.dart';
|
||||||
import '../../providers/weather_provider.dart';
|
import '../../providers/weather_provider.dart';
|
||||||
|
import '../../widgets/native_ad_placeholder.dart';
|
||||||
import 'widgets/recommendation_result_dialog.dart';
|
import 'widgets/recommendation_result_dialog.dart';
|
||||||
|
|
||||||
class RandomSelectionScreen extends ConsumerStatefulWidget {
|
class RandomSelectionScreen extends ConsumerStatefulWidget {
|
||||||
@@ -51,64 +52,84 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// 맛집 리스트 현황 카드
|
// 상단 요약 바 (높이 최소화)
|
||||||
Card(
|
Card(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: Column(
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Container(
|
||||||
Icons.restaurant,
|
width: 36,
|
||||||
size: 48,
|
height: 36,
|
||||||
color: AppColors.lightPrimary,
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.restaurant,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.lightPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(width: 10),
|
||||||
Consumer(
|
Expanded(
|
||||||
builder: (context, ref, child) {
|
child: Consumer(
|
||||||
final restaurantsAsync = ref.watch(
|
builder: (context, ref, child) {
|
||||||
restaurantListProvider,
|
final restaurantsAsync = ref.watch(
|
||||||
);
|
restaurantListProvider,
|
||||||
return restaurantsAsync.when(
|
);
|
||||||
data: (restaurants) => Text(
|
return restaurantsAsync.when(
|
||||||
'${restaurants.length}개',
|
data: (restaurants) => Text(
|
||||||
style: AppTypography.heading1(
|
'등록된 맛집 ${restaurants.length}개',
|
||||||
isDark,
|
style: AppTypography.heading2(
|
||||||
).copyWith(color: AppColors.lightPrimary),
|
isDark,
|
||||||
),
|
).copyWith(fontSize: 18),
|
||||||
loading: () => const CircularProgressIndicator(
|
),
|
||||||
color: AppColors.lightPrimary,
|
loading: () => const SizedBox(
|
||||||
),
|
height: 20,
|
||||||
error: (_, __) => Text(
|
width: 20,
|
||||||
'0개',
|
child: CircularProgressIndicator(
|
||||||
style: AppTypography.heading1(
|
strokeWidth: 2,
|
||||||
isDark,
|
color: AppColors.lightPrimary,
|
||||||
).copyWith(color: AppColors.lightPrimary),
|
),
|
||||||
),
|
),
|
||||||
);
|
error: (_, __) => Text(
|
||||||
},
|
'등록된 맛집 0개',
|
||||||
|
style: AppTypography.heading2(
|
||||||
|
isDark,
|
||||||
|
).copyWith(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text('등록된 맛집', style: AppTypography.body2(isDark)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 날씨 정보 카드
|
// 날씨 정보 카드
|
||||||
Card(
|
Card(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
child: Consumer(
|
child: Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final weatherAsync = ref.watch(weatherProvider);
|
final weatherAsync = ref.watch(weatherProvider);
|
||||||
@@ -164,22 +185,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 카테고리 선택 카드
|
// 카테고리 선택 카드
|
||||||
Card(
|
Card(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('카테고리', style: AppTypography.heading2(isDark)),
|
Text('카테고리', style: AppTypography.heading2(isDark)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final categoriesAsync = ref.watch(categoriesProvider);
|
final categoriesAsync = ref.watch(categoriesProvider);
|
||||||
@@ -204,7 +225,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 10,
|
||||||
children: categories.isEmpty
|
children: categories.isEmpty
|
||||||
? [const Text('카테고리 없음')]
|
? [const Text('카테고리 없음')]
|
||||||
: [
|
: [
|
||||||
@@ -227,22 +248,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 거리 설정 카드
|
// 거리 설정 카드
|
||||||
Card(
|
Card(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('최대 거리', style: AppTypography.heading2(isDark)),
|
Text('최대 거리', style: AppTypography.heading2(isDark)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -274,7 +295,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final locationAsync = ref.watch(
|
final locationAsync = ref.watch(
|
||||||
@@ -322,7 +343,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 추천받기 버튼
|
// 추천받기 버튼
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -362,6 +383,11 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const NativeAdPlaceholder(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class _ManualRestaurantInputScreenState
|
|||||||
onFieldChanged: _onFieldChanged,
|
onFieldChanged: _onFieldChanged,
|
||||||
categories: categories,
|
categories: categories,
|
||||||
subCategories: subCategories,
|
subCategories: subCategories,
|
||||||
|
geocodingStatus: state.geocodingStatus,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
|
import '../../../core/utils/category_mapper.dart';
|
||||||
import '../../../core/utils/app_logger.dart';
|
import '../../../core/utils/app_logger.dart';
|
||||||
import '../../providers/restaurant_provider.dart';
|
import '../../providers/restaurant_provider.dart';
|
||||||
import '../../widgets/category_selector.dart';
|
import '../../widgets/category_selector.dart';
|
||||||
|
import '../../widgets/native_ad_placeholder.dart';
|
||||||
import 'manual_restaurant_input_screen.dart';
|
import 'manual_restaurant_input_screen.dart';
|
||||||
import 'widgets/restaurant_card.dart';
|
import 'widgets/restaurant_card.dart';
|
||||||
import 'widgets/add_restaurant_dialog.dart';
|
import 'widgets/add_restaurant_dialog.dart';
|
||||||
@@ -34,9 +35,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
final searchQuery = ref.watch(searchQueryProvider);
|
final searchQuery = ref.watch(searchQueryProvider);
|
||||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||||
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||||
final restaurantsAsync = isFiltered
|
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
|
||||||
? ref.watch(filteredRestaurantsProvider)
|
|
||||||
: ref.watch(sortedRestaurantsByDistanceProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -108,25 +107,56 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
AppLogger.debug(
|
AppLogger.debug(
|
||||||
'[restaurant_list_ui] data received, filtered=$isFiltered',
|
'[restaurant_list_ui] data received, filtered=$isFiltered',
|
||||||
);
|
);
|
||||||
final items = isFiltered
|
var items = restaurantsData;
|
||||||
? (restaurantsData as List<Restaurant>)
|
|
||||||
.map(
|
if (isFiltered) {
|
||||||
(r) => (restaurant: r, distanceKm: null as double?),
|
// 검색 필터
|
||||||
)
|
if (searchQuery.isNotEmpty) {
|
||||||
.toList()
|
final lowercaseQuery = searchQuery.toLowerCase();
|
||||||
: restaurantsData
|
items = items.where((item) {
|
||||||
as List<
|
final r = item.restaurant;
|
||||||
({Restaurant restaurant, double? distanceKm})
|
return r.name.toLowerCase().contains(lowercaseQuery) ||
|
||||||
>;
|
(r.description?.toLowerCase().contains(
|
||||||
|
lowercaseQuery,
|
||||||
|
) ??
|
||||||
|
false) ||
|
||||||
|
r.category.toLowerCase().contains(lowercaseQuery);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 필터
|
||||||
|
if (selectedCategory != null) {
|
||||||
|
items = items.where((item) {
|
||||||
|
final r = item.restaurant;
|
||||||
|
return r.category == selectedCategory ||
|
||||||
|
r.category.contains(selectedCategory) ||
|
||||||
|
CategoryMapper.normalizeNaverCategory(
|
||||||
|
r.category,
|
||||||
|
r.subCategory,
|
||||||
|
) ==
|
||||||
|
selectedCategory ||
|
||||||
|
CategoryMapper.getDisplayName(r.category) ==
|
||||||
|
selectedCategory;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return _buildEmptyState(isDark);
|
return _buildEmptyState(isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: items.length,
|
itemCount: items.length + 1,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
if (index == 0) {
|
||||||
|
return const NativeAdPlaceholder(
|
||||||
|
margin: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final item = items[index - 1];
|
||||||
return RestaurantCard(
|
return RestaurantCard(
|
||||||
restaurant: item.restaurant,
|
restaurant: item.restaurant,
|
||||||
distanceKm: item.distanceKm,
|
distanceKm: item.distanceKm,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AddRestaurantForm extends StatefulWidget {
|
|||||||
final Function(String) onFieldChanged;
|
final Function(String) onFieldChanged;
|
||||||
final List<String> categories;
|
final List<String> categories;
|
||||||
final List<String> subCategories;
|
final List<String> subCategories;
|
||||||
|
final String geocodingStatus;
|
||||||
|
|
||||||
const AddRestaurantForm({
|
const AddRestaurantForm({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -33,6 +34,7 @@ class AddRestaurantForm extends StatefulWidget {
|
|||||||
required this.onFieldChanged,
|
required this.onFieldChanged,
|
||||||
this.categories = const <String>[],
|
this.categories = const <String>[],
|
||||||
this.subCategories = const <String>[],
|
this.subCategories = const <String>[],
|
||||||
|
this.geocodingStatus = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -255,12 +257,28 @@ class _AddRestaurantFormState extends State<AddRestaurantForm> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Column(
|
||||||
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
style: Theme.of(
|
children: [
|
||||||
context,
|
Text(
|
||||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (widget.geocodingStatus.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.geocodingStatus,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// Row(
|
||||||
children: [
|
// children: [
|
||||||
Icon(
|
// Icon(
|
||||||
Icons.info_outline,
|
// Icons.info_outline,
|
||||||
size: 20,
|
// size: 20,
|
||||||
color: isDark
|
// color: isDark
|
||||||
? AppColors.darkPrimary
|
// ? AppColors.darkPrimary
|
||||||
: AppColors.lightPrimary,
|
// : AppColors.lightPrimary,
|
||||||
),
|
// ),
|
||||||
const SizedBox(width: 8),
|
// const SizedBox(width: 8),
|
||||||
Text(
|
// Text(
|
||||||
'네이버 지도에서 맛집 정보 가져오기',
|
// '네이버 지도에서 맛집 정보 가져오기',
|
||||||
style: AppTypography.body1(
|
// style: AppTypography.body1(
|
||||||
isDark,
|
// isDark,
|
||||||
).copyWith(fontWeight: FontWeight.bold),
|
// ).copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
// ),
|
||||||
],
|
// ],
|
||||||
),
|
// ),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
||||||
@@ -71,6 +71,9 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
|||||||
// URL 입력 필드
|
// URL 입력 필드
|
||||||
TextField(
|
TextField(
|
||||||
controller: urlController,
|
controller: urlController,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 6,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '네이버 지도 URL',
|
labelText: '네이버 지도 URL',
|
||||||
hintText: kIsWeb
|
hintText: kIsWeb
|
||||||
@@ -79,6 +82,7 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
|||||||
prefixIcon: const Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
errorText: errorMessage,
|
errorText: errorMessage,
|
||||||
|
errorMaxLines: 8,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => onFetchPressed(),
|
onSubmitted: (_) => onFetchPressed(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../../../../core/constants/app_colors.dart';
|
|||||||
import '../../../../core/constants/app_typography.dart';
|
import '../../../../core/constants/app_typography.dart';
|
||||||
import '../../../services/restaurant_form_validator.dart';
|
import '../../../services/restaurant_form_validator.dart';
|
||||||
|
|
||||||
class FetchedRestaurantJsonView extends StatelessWidget {
|
class FetchedRestaurantJsonView extends StatefulWidget {
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
final TextEditingController nameController;
|
final TextEditingController nameController;
|
||||||
final TextEditingController categoryController;
|
final TextEditingController categoryController;
|
||||||
@@ -34,17 +34,59 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
required this.onFieldChanged,
|
required this.onFieldChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FetchedRestaurantJsonView> createState() =>
|
||||||
|
_FetchedRestaurantJsonViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
|
||||||
|
late final FocusNode _categoryFocusNode;
|
||||||
|
late final FocusNode _subCategoryFocusNode;
|
||||||
|
late Set<String> _availableCategories;
|
||||||
|
late Set<String> _availableSubCategories;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_categoryFocusNode = FocusNode();
|
||||||
|
_subCategoryFocusNode = FocusNode();
|
||||||
|
_availableCategories = {
|
||||||
|
'기타',
|
||||||
|
if (widget.categoryController.text.trim().isNotEmpty)
|
||||||
|
widget.categoryController.text.trim(),
|
||||||
|
};
|
||||||
|
_availableSubCategories = {
|
||||||
|
'기타',
|
||||||
|
if (widget.subCategoryController.text.trim().isNotEmpty)
|
||||||
|
widget.subCategoryController.text.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (widget.categoryController.text.trim().isEmpty) {
|
||||||
|
widget.categoryController.text = '기타';
|
||||||
|
}
|
||||||
|
if (widget.subCategoryController.text.trim().isEmpty) {
|
||||||
|
widget.subCategoryController.text = '기타';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_categoryFocusNode.dispose();
|
||||||
|
_subCategoryFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark
|
color: widget.isDark
|
||||||
? AppColors.darkBackground
|
? AppColors.darkBackground
|
||||||
: AppColors.lightBackground.withOpacity(0.5),
|
: AppColors.lightBackground.withOpacity(0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -57,78 +99,55 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'가져온 정보',
|
'가져온 정보',
|
||||||
style: AppTypography.body1(
|
style: AppTypography.body1(
|
||||||
isDark,
|
widget.isDark,
|
||||||
).copyWith(fontWeight: FontWeight.w600),
|
).copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
|
||||||
'{',
|
|
||||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'name',
|
label: '상호',
|
||||||
controller: nameController,
|
controller: widget.nameController,
|
||||||
icon: Icons.store,
|
icon: Icons.store,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
||||||
),
|
),
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'category',
|
label: '도로명 주소',
|
||||||
controller: categoryController,
|
controller: widget.roadAddressController,
|
||||||
icon: Icons.category,
|
|
||||||
validator: RestaurantFormValidator.validateCategory,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'subCategory',
|
|
||||||
controller: subCategoryController,
|
|
||||||
icon: Icons.label_outline,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'description',
|
|
||||||
controller: descriptionController,
|
|
||||||
icon: Icons.description,
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'phoneNumber',
|
|
||||||
controller: phoneController,
|
|
||||||
icon: Icons.phone,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'roadAddress',
|
|
||||||
controller: roadAddressController,
|
|
||||||
icon: Icons.location_on,
|
icon: Icons.location_on,
|
||||||
validator: RestaurantFormValidator.validateAddress,
|
validator: RestaurantFormValidator.validateAddress,
|
||||||
),
|
),
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'jibunAddress',
|
label: '지번 주소',
|
||||||
controller: jibunAddressController,
|
controller: widget.jibunAddressController,
|
||||||
icon: Icons.map,
|
icon: Icons.map,
|
||||||
),
|
),
|
||||||
_buildCoordinateFields(context),
|
_buildCoordinateFields(context),
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'naverUrl',
|
label: '전화번호',
|
||||||
controller: naverUrlController,
|
controller: widget.phoneController,
|
||||||
icon: Icons.link,
|
icon: Icons.phone,
|
||||||
monospace: true,
|
keyboardType: TextInputType.phone,
|
||||||
|
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
Row(
|
||||||
const Text(
|
children: [
|
||||||
'}',
|
Expanded(child: _buildCategoryField(context)),
|
||||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _buildSubCategoryField(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildJsonField(
|
||||||
|
context,
|
||||||
|
label: '설명',
|
||||||
|
controller: widget.descriptionController,
|
||||||
|
icon: Icons.description,
|
||||||
|
maxLines: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -145,7 +164,7 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
children: const [
|
children: const [
|
||||||
Icon(Icons.my_location, size: 16),
|
Icon(Icons.my_location, size: 16),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text('coordinates'),
|
Text('좌표'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -153,16 +172,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: latitudeController,
|
controller: widget.latitudeController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
decimal: true,
|
decimal: true,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'latitude',
|
labelText: '위도',
|
||||||
border: border,
|
border: border,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return '위도를 입력해주세요';
|
return '위도를 입력해주세요';
|
||||||
@@ -178,16 +197,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: longitudeController,
|
controller: widget.longitudeController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
decimal: true,
|
decimal: true,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'longitude',
|
labelText: '경도',
|
||||||
border: border,
|
border: border,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return '경도를 입력해주세요';
|
return '경도를 입력해주세요';
|
||||||
@@ -209,6 +228,170 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryField(BuildContext context) {
|
||||||
|
return RawAutocomplete<String>(
|
||||||
|
textEditingController: widget.categoryController,
|
||||||
|
focusNode: _categoryFocusNode,
|
||||||
|
optionsBuilder: (TextEditingValue value) {
|
||||||
|
final query = value.text.trim();
|
||||||
|
if (query.isEmpty) return _availableCategories;
|
||||||
|
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
final matches = _availableCategories
|
||||||
|
.where((c) => c.toLowerCase().contains(lowerQuery))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final hasExact = _availableCategories.any(
|
||||||
|
(c) => c.toLowerCase() == lowerQuery,
|
||||||
|
);
|
||||||
|
if (!hasExact) {
|
||||||
|
matches.insert(0, query.isEmpty ? '기타' : query);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
displayStringForOption: (option) => option,
|
||||||
|
onSelected: (option) {
|
||||||
|
final normalized = option.trim().isEmpty ? '기타' : option.trim();
|
||||||
|
setState(() {
|
||||||
|
_availableCategories.add(normalized);
|
||||||
|
});
|
||||||
|
widget.categoryController.text = normalized;
|
||||||
|
widget.onFieldChanged(normalized);
|
||||||
|
},
|
||||||
|
fieldViewBuilder:
|
||||||
|
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: textEditingController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '카테고리',
|
||||||
|
hintText: '예: 한식',
|
||||||
|
// prefixIcon: const Icon(Icons.category),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||||
|
),
|
||||||
|
onChanged: widget.onFieldChanged,
|
||||||
|
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||||
|
validator: RestaurantFormValidator.validateCategory,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final option = options.elementAt(index);
|
||||||
|
final isNew = !_availableCategories.contains(option);
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
isNew ? '새 카테고리 추가: $option' : option,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isNew ? FontWeight.w600 : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubCategoryField(BuildContext context) {
|
||||||
|
return RawAutocomplete<String>(
|
||||||
|
textEditingController: widget.subCategoryController,
|
||||||
|
focusNode: _subCategoryFocusNode,
|
||||||
|
optionsBuilder: (TextEditingValue value) {
|
||||||
|
final query = value.text.trim();
|
||||||
|
if (query.isEmpty) return _availableSubCategories;
|
||||||
|
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
final matches = _availableSubCategories
|
||||||
|
.where((c) => c.toLowerCase().contains(lowerQuery))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final hasExact = _availableSubCategories.any(
|
||||||
|
(c) => c.toLowerCase() == lowerQuery,
|
||||||
|
);
|
||||||
|
if (!hasExact) {
|
||||||
|
matches.insert(0, query.isEmpty ? '기타' : query);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
displayStringForOption: (option) => option,
|
||||||
|
onSelected: (option) {
|
||||||
|
final normalized = option.trim().isEmpty ? '기타' : option.trim();
|
||||||
|
setState(() {
|
||||||
|
_availableSubCategories.add(normalized);
|
||||||
|
});
|
||||||
|
widget.subCategoryController.text = normalized;
|
||||||
|
widget.onFieldChanged(normalized);
|
||||||
|
},
|
||||||
|
fieldViewBuilder:
|
||||||
|
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: textEditingController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '세부 카테고리',
|
||||||
|
hintText: '예: 갈비',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||||
|
),
|
||||||
|
onChanged: widget.onFieldChanged,
|
||||||
|
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final option = options.elementAt(index);
|
||||||
|
final isNew = !_availableSubCategories.contains(option);
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
isNew ? '새 세부 카테고리 추가: $option' : option,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isNew ? FontWeight.w600 : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildJsonField(
|
Widget _buildJsonField(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String label,
|
required String label,
|
||||||
@@ -236,17 +419,18 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
style: monospace
|
|
||||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
|
|
||||||
: null,
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
labelText: label,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
|
isDense: true,
|
||||||
),
|
),
|
||||||
|
style: monospace
|
||||||
|
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:lunchpick/domain/entities/share_device.dart';
|
|||||||
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||||
|
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class ShareScreen extends ConsumerStatefulWidget {
|
class ShareScreen extends ConsumerStatefulWidget {
|
||||||
@@ -144,7 +145,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
|
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
|
||||||
child: _buildSendSection(isDark),
|
child: _buildSendSection(isDark),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 16),
|
||||||
|
const NativeAdPlaceholder(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_ShareCard(
|
_ShareCard(
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
icon: Icons.download_rounded,
|
icon: Icons.download_rounded,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../core/services/permission_service.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
@@ -178,13 +180,26 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToHome() {
|
void _navigateToHome() {
|
||||||
Future.delayed(AppConstants.splashAnimationDuration, () {
|
Future.wait([
|
||||||
|
_ensurePermissions(),
|
||||||
|
Future.delayed(AppConstants.splashAnimationDuration),
|
||||||
|
]).then((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go('/home');
|
context.go('/home');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensurePermissions() async {
|
||||||
|
try {
|
||||||
|
await Permission.notification.request();
|
||||||
|
await Permission.location.request();
|
||||||
|
await PermissionService.checkAndRequestBluetoothPermission();
|
||||||
|
} catch (_) {
|
||||||
|
// 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
for (final controller in _foodControllers) {
|
for (final controller in _foodControllers) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
|||||||
final config = RecommendationConfig(
|
final config = RecommendationConfig(
|
||||||
userLatitude: location.latitude,
|
userLatitude: location.latitude,
|
||||||
userLongitude: location.longitude,
|
userLongitude: location.longitude,
|
||||||
maxDistance: maxDistance,
|
maxDistance: maxDistance / 1000, // 미터 입력을 km 단위로 변환
|
||||||
selectedCategories: selectedCategories,
|
selectedCategories: selectedCategories,
|
||||||
userSettings: userSettings,
|
userSettings: userSettings,
|
||||||
weather: weather,
|
weather: weather,
|
||||||
@@ -307,7 +307,7 @@ class EnhancedRecommendationNotifier
|
|||||||
final config = RecommendationConfig(
|
final config = RecommendationConfig(
|
||||||
userLatitude: location.latitude,
|
userLatitude: location.latitude,
|
||||||
userLongitude: location.longitude,
|
userLongitude: location.longitude,
|
||||||
maxDistance: maxDistanceNormal.toDouble(),
|
maxDistance: maxDistanceNormal.toDouble() / 1000, // 미터 입력을 km 단위로 변환
|
||||||
selectedCategories: categories,
|
selectedCategories: categories,
|
||||||
userSettings: userSettings,
|
userSettings: userSettings,
|
||||||
weather: weather,
|
weather: weather,
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
|||||||
required DateTime recommendationTime,
|
required DateTime recommendationTime,
|
||||||
bool isConfirmed = false,
|
bool isConfirmed = false,
|
||||||
}) async {
|
}) async {
|
||||||
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
|
// 추천 확인 시점으로 방문 시간을 기록
|
||||||
final visitTime = recommendationTime.add(const Duration(minutes: 90));
|
final visitTime = DateTime.now();
|
||||||
|
|
||||||
await addVisitRecord(
|
await addVisitRecord(
|
||||||
restaurantId: restaurantId,
|
restaurantId: restaurantId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../domain/entities/restaurant.dart';
|
import '../../domain/entities/restaurant.dart';
|
||||||
|
import '../../data/datasources/remote/naver_map_parser.dart';
|
||||||
import '../providers/di_providers.dart';
|
import '../providers/di_providers.dart';
|
||||||
import '../providers/restaurant_provider.dart';
|
import '../providers/restaurant_provider.dart';
|
||||||
import '../providers/location_provider.dart';
|
import '../providers/location_provider.dart';
|
||||||
@@ -15,6 +16,7 @@ class AddRestaurantState {
|
|||||||
final Restaurant? fetchedRestaurantData;
|
final Restaurant? fetchedRestaurantData;
|
||||||
final RestaurantFormData formData;
|
final RestaurantFormData formData;
|
||||||
final List<Restaurant> searchResults;
|
final List<Restaurant> searchResults;
|
||||||
|
final String geocodingStatus;
|
||||||
|
|
||||||
const AddRestaurantState({
|
const AddRestaurantState({
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
@@ -23,6 +25,7 @@ class AddRestaurantState {
|
|||||||
this.fetchedRestaurantData,
|
this.fetchedRestaurantData,
|
||||||
required this.formData,
|
required this.formData,
|
||||||
this.searchResults = const [],
|
this.searchResults = const [],
|
||||||
|
this.geocodingStatus = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
AddRestaurantState copyWith({
|
AddRestaurantState copyWith({
|
||||||
@@ -34,6 +37,7 @@ class AddRestaurantState {
|
|||||||
List<Restaurant>? searchResults,
|
List<Restaurant>? searchResults,
|
||||||
bool clearFetchedRestaurant = false,
|
bool clearFetchedRestaurant = false,
|
||||||
bool clearError = false,
|
bool clearError = false,
|
||||||
|
String? geocodingStatus,
|
||||||
}) {
|
}) {
|
||||||
return AddRestaurantState(
|
return AddRestaurantState(
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
@@ -44,6 +48,7 @@ class AddRestaurantState {
|
|||||||
: (fetchedRestaurantData ?? this.fetchedRestaurantData),
|
: (fetchedRestaurantData ?? this.fetchedRestaurantData),
|
||||||
formData: formData ?? this.formData,
|
formData: formData ?? this.formData,
|
||||||
searchResults: searchResults ?? this.searchResults,
|
searchResults: searchResults ?? this.searchResults,
|
||||||
|
geocodingStatus: geocodingStatus ?? this.geocodingStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,24 +184,61 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
|
|
||||||
/// 네이버 URL로부터 식당 정보 가져오기
|
/// 네이버 URL로부터 식당 정보 가져오기
|
||||||
Future<void> fetchFromNaverUrl(String url) async {
|
Future<void> fetchFromNaverUrl(String url) async {
|
||||||
if (url.trim().isEmpty) {
|
final parsed = _parseSharedNaverContent(url);
|
||||||
|
|
||||||
|
if (parsed.url.trim().isEmpty) {
|
||||||
state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
|
state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 공유 텍스트에 포함된 상호명/도로명주소를 미리 채워 넣는다.
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final repository = _ref.read(restaurantRepositoryProvider);
|
final normalizedUrl = _normalizeUrl(parsed.url);
|
||||||
final restaurant = await repository.previewRestaurantFromUrl(url);
|
state = state.copyWith(
|
||||||
|
geocodingStatus: '지오코딩 시도: ${parsed.roadAddress ?? ''}',
|
||||||
|
);
|
||||||
|
final coords = await _tryGeocode(parsed.roadAddress ?? '');
|
||||||
|
if (coords != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus:
|
||||||
|
'지오코딩 성공: ${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus: '지오코딩 실패: 현재 위치/기본 좌표를 사용할 수 있습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final newForm = state.formData.copyWith(
|
||||||
|
name: parsed.name ?? state.formData.name,
|
||||||
|
roadAddress: parsed.roadAddress ?? state.formData.roadAddress,
|
||||||
|
jibunAddress: state.formData.jibunAddress,
|
||||||
|
latitude: coords != null
|
||||||
|
? coords.latitude.toString()
|
||||||
|
: state.formData.latitude,
|
||||||
|
longitude: coords != null
|
||||||
|
? coords.longitude.toString()
|
||||||
|
: state.formData.longitude,
|
||||||
|
category: '기타',
|
||||||
|
subCategory: '기타',
|
||||||
|
naverUrl: normalizedUrl,
|
||||||
|
);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
fetchedRestaurantData: restaurant,
|
fetchedRestaurantData: newForm.toRestaurant(),
|
||||||
formData: RestaurantFormData.fromRestaurant(restaurant),
|
formData: newForm,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
final message = e is NaverMapParseException
|
||||||
|
? '네이버 지도 파싱 실패: ${e.message}'
|
||||||
|
: e.toString();
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: message,
|
||||||
|
geocodingStatus: '지오코딩 실패: ${parsed.roadAddress ?? '주소 없음'}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +283,12 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
/// 식당 정보 저장
|
/// 식당 정보 저장
|
||||||
Future<bool> saveRestaurant() async {
|
Future<bool> saveRestaurant() async {
|
||||||
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||||
|
final fallbackCategory = state.formData.category.isNotEmpty
|
||||||
|
? state.formData.category
|
||||||
|
: '기타';
|
||||||
|
final fallbackSubCategory = state.formData.subCategory.isNotEmpty
|
||||||
|
? state.formData.subCategory
|
||||||
|
: fallbackCategory;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
@@ -260,10 +308,8 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
|
|
||||||
restaurantToSave = fetchedData.copyWith(
|
restaurantToSave = fetchedData.copyWith(
|
||||||
name: state.formData.name,
|
name: state.formData.name,
|
||||||
category: state.formData.category,
|
category: fallbackCategory,
|
||||||
subCategory: state.formData.subCategory.isEmpty
|
subCategory: fallbackSubCategory,
|
||||||
? state.formData.category
|
|
||||||
: state.formData.subCategory,
|
|
||||||
description: state.formData.description.isEmpty
|
description: state.formData.description.isEmpty
|
||||||
? null
|
? null
|
||||||
: state.formData.description,
|
: state.formData.description,
|
||||||
@@ -292,6 +338,8 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
restaurantToSave = state.formData.toRestaurant().copyWith(
|
restaurantToSave = state.formData.toRestaurant().copyWith(
|
||||||
|
category: fallbackCategory,
|
||||||
|
subCategory: fallbackSubCategory,
|
||||||
latitude: coords.latitude,
|
latitude: coords.latitude,
|
||||||
longitude: coords.longitude,
|
longitude: coords.longitude,
|
||||||
needsAddressVerification: coords.usedCurrentLocation,
|
needsAddressVerification: coords.usedCurrentLocation,
|
||||||
@@ -317,6 +365,69 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
state = state.copyWith(clearError: true);
|
state = state.copyWith(clearError: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 네이버 지도 공유 텍스트에서 URL/상호명/도로명주소를 추출한다.
|
||||||
|
_ParsedNaverShare _parseSharedNaverContent(String raw) {
|
||||||
|
final normalized = raw.replaceAll('\r\n', '\n').trim();
|
||||||
|
|
||||||
|
// URL 추출
|
||||||
|
final urlRegex = RegExp(
|
||||||
|
r'(https?://(?:map\.naver\.com|naver\.me)[^\s]+)',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final urlMatch = urlRegex.firstMatch(normalized);
|
||||||
|
final url = urlMatch?.group(0) ?? normalized;
|
||||||
|
|
||||||
|
// 패턴: [네이버지도]\n상호명\n도로명주소\nURL
|
||||||
|
final lines = normalized.split('\n').map((e) => e.trim()).toList();
|
||||||
|
String? name;
|
||||||
|
String? roadAddress;
|
||||||
|
if (lines.length >= 4 && lines.first.contains('네이버지도')) {
|
||||||
|
name = lines[1].isNotEmpty ? lines[1] : null;
|
||||||
|
roadAddress = lines[2].isNotEmpty ? lines[2] : null;
|
||||||
|
} else {
|
||||||
|
// 줄바꿈이 없거나 공백만 있는 경우: URL 앞 부분에서 이름/주소를 분리
|
||||||
|
final prefix = normalized.substring(0, urlMatch?.start ?? 0).trim();
|
||||||
|
if (prefix.isNotEmpty) {
|
||||||
|
final cleaned = prefix.replaceFirst('[네이버지도]', '').trim();
|
||||||
|
// 주소 패턴(시/도/구/로/길 등)을 먼저 찾는다.
|
||||||
|
final addressRegex = RegExp(
|
||||||
|
r'(서울|부산|대구|인천|광주|대전|울산|세종|제주|경기|강원|충북|충남|전북|전남|경북|경남)[^\n]*',
|
||||||
|
);
|
||||||
|
final addrMatch = addressRegex.firstMatch(cleaned);
|
||||||
|
if (addrMatch != null) {
|
||||||
|
roadAddress = addrMatch.group(0)?.trim();
|
||||||
|
final extractedName = cleaned.substring(0, addrMatch.start).trim();
|
||||||
|
name = extractedName.isNotEmpty ? extractedName : null;
|
||||||
|
} else {
|
||||||
|
// 주소 패턴이 없으면 첫 단어가 아닌 전체를 이름으로 유지해 공백이 있어도 깨지지 않게 함
|
||||||
|
name = cleaned.isNotEmpty ? cleaned : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ParsedNaverShare(url: url, name: name, roadAddress: roadAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({double latitude, double longitude})?> _tryGeocode(
|
||||||
|
String roadAddress,
|
||||||
|
) async {
|
||||||
|
if (roadAddress.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final geocodingService = _ref.read(geocodingServiceProvider);
|
||||||
|
final result = await geocodingService.geocode(roadAddress);
|
||||||
|
if (result == null) return null;
|
||||||
|
return (latitude: result.latitude, longitude: result.longitude);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeUrl(String rawUrl) {
|
||||||
|
final trimmed = rawUrl.trim();
|
||||||
|
if (trimmed.startsWith('http')) return trimmed;
|
||||||
|
return 'https://$trimmed';
|
||||||
|
}
|
||||||
|
|
||||||
Future<({double latitude, double longitude, bool usedCurrentLocation})>
|
Future<({double latitude, double longitude, bool usedCurrentLocation})>
|
||||||
_resolveCoordinates({
|
_resolveCoordinates({
|
||||||
required String latitudeText,
|
required String latitudeText,
|
||||||
@@ -329,6 +440,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
final parsedLat = double.tryParse(latitudeText);
|
final parsedLat = double.tryParse(latitudeText);
|
||||||
final parsedLon = double.tryParse(longitudeText);
|
final parsedLon = double.tryParse(longitudeText);
|
||||||
if (parsedLat != null && parsedLon != null) {
|
if (parsedLat != null && parsedLon != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus:
|
||||||
|
'사용자 입력 좌표 사용: ${parsedLat.toStringAsFixed(6)}, ${parsedLon.toStringAsFixed(6)}',
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
latitude: parsedLat,
|
latitude: parsedLat,
|
||||||
longitude: parsedLon,
|
longitude: parsedLon,
|
||||||
@@ -339,13 +454,22 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
final geocodingService = _ref.read(geocodingServiceProvider);
|
final geocodingService = _ref.read(geocodingServiceProvider);
|
||||||
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
|
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
|
||||||
if (address.isNotEmpty) {
|
if (address.isNotEmpty) {
|
||||||
|
state = state.copyWith(geocodingStatus: '지오코딩 시도: $address');
|
||||||
final result = await geocodingService.geocode(address);
|
final result = await geocodingService.geocode(address);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus:
|
||||||
|
'지오코딩 성공: ${result.latitude.toStringAsFixed(6)}, ${result.longitude.toStringAsFixed(6)}',
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
latitude: result.latitude,
|
latitude: result.latitude,
|
||||||
longitude: result.longitude,
|
longitude: result.longitude,
|
||||||
usedCurrentLocation: false,
|
usedCurrentLocation: false,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus: '지오코딩 실패: $address, 현재 위치/기본 좌표로 대체',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +477,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
try {
|
try {
|
||||||
final position = await _ref.read(currentLocationProvider.future);
|
final position = await _ref.read(currentLocationProvider.future);
|
||||||
if (position != null) {
|
if (position != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus:
|
||||||
|
'현재 위치 사용: ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}',
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
latitude: position.latitude,
|
latitude: position.latitude,
|
||||||
longitude: position.longitude,
|
longitude: position.longitude,
|
||||||
@@ -364,6 +492,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackLatitude != null && fallbackLongitude != null) {
|
if (fallbackLatitude != null && fallbackLongitude != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus:
|
||||||
|
'네이버 데이터 좌표 사용: ${fallbackLatitude.toStringAsFixed(6)}, ${fallbackLongitude.toStringAsFixed(6)}',
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
latitude: fallbackLatitude,
|
latitude: fallbackLatitude,
|
||||||
longitude: fallbackLongitude,
|
longitude: fallbackLongitude,
|
||||||
@@ -372,6 +504,10 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final defaultCoords = geocodingService.defaultCoordinates();
|
final defaultCoords = geocodingService.defaultCoordinates();
|
||||||
|
state = state.copyWith(
|
||||||
|
geocodingStatus:
|
||||||
|
'기본 좌표 사용: ${defaultCoords.latitude.toStringAsFixed(6)}, ${defaultCoords.longitude.toStringAsFixed(6)}',
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
latitude: defaultCoords.latitude,
|
latitude: defaultCoords.latitude,
|
||||||
longitude: defaultCoords.longitude,
|
longitude: defaultCoords.longitude,
|
||||||
@@ -380,6 +516,14 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ParsedNaverShare {
|
||||||
|
final String url;
|
||||||
|
final String? name;
|
||||||
|
final String? roadAddress;
|
||||||
|
|
||||||
|
_ParsedNaverShare({required this.url, this.name, this.roadAddress});
|
||||||
|
}
|
||||||
|
|
||||||
/// AddRestaurantViewModel Provider
|
/// AddRestaurantViewModel Provider
|
||||||
final addRestaurantViewModelProvider =
|
final addRestaurantViewModelProvider =
|
||||||
StateNotifierProvider.autoDispose<
|
StateNotifierProvider.autoDispose<
|
||||||
|
|||||||
48
lib/presentation/widgets/native_ad_placeholder.dart
Normal file
48
lib/presentation/widgets/native_ad_placeholder.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||||
|
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||||
|
|
||||||
|
/// 네이티브 광고(Native Ad) 플레이스홀더
|
||||||
|
class NativeAdPlaceholder extends StatelessWidget {
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
const NativeAdPlaceholder({super.key, this.margin, this.height = 120});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: margin ?? EdgeInsets.zero,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
height: height,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? AppColors.darkSurface : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: (isDark ? Colors.black : Colors.grey).withOpacity(0.08),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.ad_units, color: AppColors.lightPrimary, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('광고 영역', style: AppTypography.heading2(isDark)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user