Compare commits
21 Commits
master
...
bcc26f5e79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcc26f5e79 | ||
|
|
04b1c3e987 | ||
|
|
095222ef61 | ||
|
|
637507f02a | ||
|
|
3f659432e9 | ||
|
|
a4c7f55fc0 | ||
|
|
d733bf664b | ||
|
|
5cae033977 | ||
|
|
4b0e2b4e28 | ||
|
|
4cfff7252e | ||
|
|
e4c5fa7356 | ||
|
|
3ff9e5f837 | ||
|
|
d101f7d0dc | ||
|
|
9f82a0cfda | ||
|
|
df4c34194c | ||
|
|
69902bbc30 | ||
|
|
0e75a23ade | ||
|
|
c1aa16c521 | ||
|
|
d05e378569 | ||
|
|
0e8c06bade | ||
|
|
2a01fa50c6 |
63
AGENTS.md
@@ -1,52 +1,61 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`lib/` follows a Clean Architecture split: `core/` for shared constants, errors, network utilities, and base widgets; `data/` for API clients, datasources, and repository implementations; `domain/` for entities and use cases; and `presentation/` for Riverpod providers, pages, and widgets. Tests mirror the code by feature inside `test/`, while migration-heavy Hive specs live in `test_hive/`. Platform scaffolding resides under `android/`, `ios/`, `macos/`, `linux/`, `windows/`, and `web/`, and documentation resources live in `doc/`.
|
||||
`lib/` follows Clean Architecture: `core/` for shared constants, errors, network utilities, and base widgets; `data/` for API clients, datasources, and repository implementations; `domain/` for entities and use cases; `presentation/` for Riverpod providers, pages, and widgets. Tests mirror features in `test/`, Hive migration specs live in `test_hive/`. Platform scaffolding sits under `android/`, `ios/`, `macos/`, `linux/`, `windows/`, `web/`, and docs under `doc/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `flutter pub get` – fetch packages after cloning or switching branches.
|
||||
- `flutter pub run build_runner build --delete-conflicting-outputs` – regenerate adapters and JSON code when models change.
|
||||
- `flutter run -d ios|android|chrome` – start the app on the specified device; prefer simulators that can access location APIs.
|
||||
- `flutter build apk|appbundle|ios --release` – produce production bundles once QA is green.
|
||||
- `flutter pub get` – run after cloning or switching branches.
|
||||
- `flutter pub run build_runner build --delete-conflicting-outputs` – regenerates adapters, JSON code, and merges `doc/restaurant_data/store.db` changes into `assets/data/store_seed.json` and `store_seed.meta.json`.
|
||||
- `flutter pub run build_runner watch --delete-conflicting-outputs` – keep this on during development to auto-regenerate seeds when `store.db` changes.
|
||||
- `flutter run -d ios|android|chrome` – launch on the target device/simulator (prefer simulators with location APIs).
|
||||
- `flutter build apk|appbundle|ios --release` – production bundles after QA is green.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Follow the conventions in `doc/03_architecture/code_convention.md`: two-space indentation, 80-character soft wrap (120 hard), imports grouped by Dart → packages → project, and one public class per file. Use PascalCase for classes/providers, camelCase for methods and variables, UPPER_SNAKE_CASE for constants, and snake_case for file and folder names. Business logic, identifiers, and UI strings stay in English; explanatory comments and commit scopes may use Korean for clarity. Keep widgets small and compose them inside the relevant `presentation/*` module to preserve MVVM boundaries.
|
||||
See `doc/03_architecture/code_convention.md`: two-space indent, 80-char soft wrap (120 hard), imports grouped Dart → packages → project, one public class per file. Use PascalCase for classes/providers, camelCase for methods/variables, UPPER_SNAKE_CASE for constants, snake_case for files/folders. Business logic identifiers and UI strings stay in English; comments/docs may be in Korean with the first English term in parentheses. Keep widgets small and compose within the relevant `presentation/*` module to preserve MVVM boundaries.
|
||||
|
||||
## Testing Guidelines
|
||||
Use the Flutter test runner: `flutter test` for the whole suite, `flutter test test/..._test.dart` for targeted specs, and `flutter test --coverage && genhtml coverage/lcov.info -o coverage/html` when reporting coverage. Name test files with the `_test.dart` suffix alongside their source, and prefer `group`/`testWidgets` to describe behaviors (“should recommend restaurants under rainy limits”). Run the Hive suite (`flutter test test_hive`) when changing local persistence formats.
|
||||
Use the Flutter test runner: `flutter test` for all, `flutter test test/..._test.dart` for targeted runs, `flutter test --coverage && genhtml coverage/lcov.info -o coverage/html` for coverage. Name tests with `_test.dart` alongside sources. Prefer `group`/`testWidgets` to describe behaviors (e.g., “should recommend restaurants under rainy limits”). Run `flutter test test_hive` when Hive persistence changes.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Commits follow `type(scope): summary` (e.g., `feat(recommendation): enforce rainy radius`), with optional body text and `Resolves: #123`. Types include `feat`, `fix`, `docs`, `style`, `refactor`, `test`, and `chore`. Create feature branches from `develop`, reference API or UI screenshots in the PR when visual elements change, describe validation steps, and link issues before requesting review. Keep PRs focused on a single feature or bugfix to simplify review.
|
||||
Commit format `type(scope): summary` (e.g., `feat(recommendation): enforce rainy radius`), optional body and `Resolves: #123`. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Create feature branches from `develop`, include API/UI screenshots when visuals change, describe validation steps, and link issues before review. Keep PRs focused on a single feature/bugfix.
|
||||
|
||||
## Security & Configuration Tips
|
||||
Never commit API secrets. Instead, create `lib/core/constants/api_keys.dart` locally with placeholder values and wire them through secure storage for CI/CD. Double-check that location permissions and weather API keys are valid before recording screen captures or uploading builds.
|
||||
Never commit API secrets. Create `lib/core/constants/api_keys.dart` locally with placeholders and use secure storage/CI wiring. Verify location permissions and weather API keys before captures or uploads.
|
||||
|
||||
## Scope, Goals, and Guardrails
|
||||
- Applies to this entire repository unless a more specific instruction appears deeper in the tree; system/developer directives still take precedence.
|
||||
- Keep changes scoped to this workspace and prefer the smallest safe diffs. Avoid destructive rewrites, config changes, or dependency updates unless someone explicitly asks for them.
|
||||
- When a task requires multiple steps, maintain an `update_plan` with exactly one step marked `in_progress`.
|
||||
- Responses stay concise and list code/logs before rationale. If unsure, prefix with `Uncertain:` and surface at most the top two viable options.
|
||||
- Applies repo-wide unless more specific instructions exist; system/developer directives still win.
|
||||
- Keep changes minimal and localized; avoid destructive rewrites, config changes, or dependency bumps unless requested.
|
||||
- For multi-step tasks, maintain an `update_plan` with exactly one step `in_progress`.
|
||||
- Responses stay concise and list code/logs before rationale. If uncertain, prefix with “Uncertain:” and surface at most the top two options.
|
||||
- Work is workspace-scoped; ask before adding dependencies or new network calls. Avoid unrequested deletes/rewrites/config changes.
|
||||
|
||||
## Collaboration & Language
|
||||
- 기본 응답은 한국어로 작성하고, 코드/로그/명령어는 원문을 유지합니다.
|
||||
- Business logic, identifiers, and UI strings remain in English, but 주석과 문서 설명은 가능한 한 한국어로 작성하고 처음에는 해당 영어 용어를 괄호로 병기합니다.
|
||||
- Git push 보고나 작업 완료 보고 역시 한국어로 작성합니다.
|
||||
- Default responses must be in Korean; keep code/logs/commands in their original form.
|
||||
- Business logic, identifiers, and UI strings remain in English; comments/docs in Korean with the first English term in parentheses.
|
||||
- Git push reports and work completion reports are in Korean.
|
||||
- Write code comments and commit/PR/work summary comments in Korean; include English terms in parentheses when helpful.
|
||||
- All git comments (commit messages, PR descriptions, push notes) must be written in Korean.
|
||||
|
||||
## Validation & Quality Checks
|
||||
- Run `dart format --set-exit-if-changed .` before finishing a task to ensure formatting stays consistent.
|
||||
- Always run `flutter analyze` and the relevant `flutter test` suites (including `flutter test test_hive` when Hive code changes) before hand-off. Add or update tests whenever behavior changes or bugs are fixed.
|
||||
- Document which commands were executed as part of the validation notes in your PR or hand-off summary.
|
||||
- Run `dart format --set-exit-if-changed .` before finishing.
|
||||
- Always run `flutter analyze` and relevant `flutter test` suites (including `flutter test test_hive` when Hive code changes) before hand-off. Add/update tests when behavior changes or bugs are fixed.
|
||||
- Document executed commands in your PR or hand-off notes.
|
||||
|
||||
## Sensitive Areas & Approvals
|
||||
- Editing Android/iOS/macOS build settings, signing artifacts, and Gradle/Xcode configs requires explicit approval.
|
||||
- Do not touch `pubspec.yaml` dependency graphs, secrets, or introduce network activity/tools without checking with the requester first.
|
||||
- Editing Android/iOS/macOS build settings, signing artifacts, Gradle/Xcode configs needs explicit approval.
|
||||
- Do not touch `pubspec.yaml` dependency graphs, secrets, or add network activity/tools without requester confirmation.
|
||||
|
||||
## Branch & Reporting Conventions
|
||||
- Create task branches as `codex/<type>-<slug>` (e.g., `codex/fix-search-null`).
|
||||
- Continue using `type(scope): summary` commit messages, but keep explanations short and focused on observable behavior changes.
|
||||
- When presenting alternatives, only show the top two concise options to speed up decision-making.
|
||||
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-search-null`).
|
||||
- Commit messages use `type(scope): summary`; keep explanations short and focused on observable behavior.
|
||||
- When offering alternatives, show only the top two concise options.
|
||||
- When closing a work report, propose concrete next tasks; if none, state that work is complete.
|
||||
|
||||
## Notification & Wrap-up
|
||||
- Before final report or end of conversation, run `/Users/maximilian.j.sul/.codex/notify.py`.
|
||||
- Update any needed working docs before reporting results.
|
||||
|
||||
## SRP & Layering Checklist
|
||||
- Every file/class should have a single reason to change; split widgets over ~400 lines and methods over ~60 lines into helpers.
|
||||
- Preserve the presentation → domain → data dependency flow. Domain stays framework-agnostic, and data never references presentation.
|
||||
- Extract validation, transformations, sorting, and filtering logic into dedicated services/utilities instead of burying them inside widgets.
|
||||
- Each file/class should have a single reason to change; split widgets ~400 lines and methods ~60 lines into helpers.
|
||||
- Preserve presentation → domain → data dependency flow. Domain stays framework-agnostic; data must not reference presentation.
|
||||
- Extract validation/transform/sort/filter logic into services/utilities instead of burying it inside widgets.
|
||||
|
||||
@@ -25,10 +25,14 @@ android {
|
||||
applicationId = "com.naturebridgeai.lunchpick"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 23
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
manifestPlaceholders["admobAppId"] =
|
||||
project.findProperty("ADMOB_APP_ID")
|
||||
?: "ca-app-pub-3940256099942544~3347511713"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -7,10 +7,23 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- 부팅 시 실행 권한 (예약된 알림 유지) -->
|
||||
<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
|
||||
android:label="lunchpick"
|
||||
android:label="오늘뭐먹Z"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="${admobAppId}" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 33 KiB |
BIN
assets/appicon/appicon.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/appicon/appicon512.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
16535
assets/data/store_seed.json
Normal file
10
assets/data/store_seed.meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "47e28144",
|
||||
"generatedAt": "2025-11-26T07:30:53.780901Z",
|
||||
"sourceDb": "doc/restaurant_data/store.db",
|
||||
"itemCount": 1503,
|
||||
"sourceSignature": {
|
||||
"hash": "47e28144",
|
||||
"size": 458752
|
||||
}
|
||||
}
|
||||
31
build.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
targets:
|
||||
$default:
|
||||
sources:
|
||||
- $package$
|
||||
- lib/**
|
||||
- bin/**
|
||||
- test/**
|
||||
- web/**
|
||||
- example/**
|
||||
- doc/**
|
||||
- tool/**
|
||||
- assets/**
|
||||
- pubspec.yaml
|
||||
builders:
|
||||
lunchpick|store_seed_builder:
|
||||
enabled: true
|
||||
|
||||
builders:
|
||||
store_seed_builder:
|
||||
import: "package:lunchpick/builders/store_seed_builder.dart"
|
||||
builder_factories: ["storeSeedBuilder"]
|
||||
build_extensions:
|
||||
"doc/restaurant_data/store.db":
|
||||
- "assets/data/store_seed.json"
|
||||
- "assets/data/store_seed.meta.json"
|
||||
auto_apply: root_package
|
||||
build_to: source
|
||||
runs_before: ["source_gen|combining_builder"]
|
||||
defaults:
|
||||
generate_for:
|
||||
- doc/restaurant_data/store.db
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
- [x] API 키를 환경 변수/난독화 전략으로 분리하고 Git에서 추적되지 않게 재구성하기 (doc/03_architecture/tech_stack_decision.md:247-256, lib/core/constants/api_keys.dart:1-20). `ApiKeys`가 `--dart-define`으로 주입된(base64 인코딩) 값을 복호화하도록 수정하고 관련 문서를 업데이트했습니다.
|
||||
- [x] AddRestaurantDialog(JSON 뷰)와 네이버 URL/검색/직접 입력 흐름 구현 및 자동 저장 제거 (doc/02_design/add_restaurant_dialog_design.md:1-137, doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1491-1605, lib/presentation/pages/restaurant_list/widgets/add_restaurant_dialog.dart:108-177, lib/presentation/view_models/add_restaurant_view_model.dart:171-224). FAB → 바텀시트(링크/검색/직접 입력) 흐름을 추가하고, 링크·검색 다이얼로그에는 JSON 필드 에디터를 구성하여 데이터 확인 후 저장하도록 변경했으며 `ManualRestaurantInputScreen`에서 직접 입력을 처리합니다.
|
||||
- [ ] 광고보고 추천받기 플로우에서 광고 게이팅, 조건 필터링, 추천 팝업, 방문 처리, 재추천, 알림 연동까지 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:820-920, lib/presentation/pages/random_selection/random_selection_screen.dart:1-400, lib/presentation/providers/recommendation_provider.dart:1-220, lib/core/services/notification_service.dart:1-260). 현재 광고 버튼을 눌러도 조건에 맞는 식당이 제시되지 않으며, 광고·추천 다이얼로그·재추천 버튼·알림 예약 로직이 모두 누락되어 있다. 임시 광고 화면(닫기 이벤트 포함)을 띄운 뒤 n일/거리 조건을 만족하는 식당을 찾으면 팝업으로 노출하고, 다시 추천받기/닫기 버튼을 제공하며, 닫거나 추가 행동 없이 유지되면 방문 처리 및 옵션으로 지정한 시간 이후 푸시 알림을 예약해야 한다. 조건에 맞는 식당이 없으면 광고 없이 “조건에 맞는 식당이 존재하지 않습니다” 토스트만 출력한다.
|
||||
- [ ] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 현재 화면은 단순 토글과 더미 코드만 제공하며 요구된 광고 게이팅·기기 리스트·데이터 병합 로직이 없습니다.
|
||||
- [ ] 실시간 날씨 API(기상청) 연동 및 캐시 무효화 로직 구현하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:306-329, lib/data/repositories/weather_repository_impl.dart:16-92). 지금은 더미 WeatherInfo를 반환하고 있어 추천 화면의 날씨 카드가 실제 데이터를 사용하지 못합니다.
|
||||
- [ ] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 구현본은 `visitRecordsProvider`만 사용하며 추천 기록, 방문 확인 버튼, 추천 이벤트 마커가 모두 빠져 있습니다.
|
||||
- [ ] 문서에서 요구한 NaverUrlProcessor/NaverLocalApiClient 파이프라인을 별도 데이터 소스로 구축하고 캐싱·병렬 처리·에러 복구를 담당하도록 리팩터링하기 (doc/03_architecture/naver_url_processing_architecture.md:29-90,392-400, lib/data/datasources/remote/naver_map_parser.dart:32-640, lib/data/datasources/remote/naver_search_service.dart:19-210). 현재 구조에는 `naver_url_processor.dart`가 없고, 파싱·API 호출이 Parser와 SearchService에 산재해 있어 요구된 책임 분리가 이뤄지지 않습니다.
|
||||
- [ ] `print` 기반 디버그 출력을 공통 Logger로 치환하고 lints가 지적한 100+개 로그를 정리하기 (doc/06_testing/2025-07-30_update_summary.md:42-45, lib/core/services/notification_service.dart:209-214, lib/data/repositories/weather_repository_impl.dart:59-133, lib/presentation/providers/notification_handler_provider.dart:55-154 등). 현재 analyze 단계에서 warning을 유발하고 프로덕션 빌드에 불필요한 로그가 남습니다.
|
||||
- [ ] RestaurantRepositoryImpl 단위 테스트를 복구하고 `path_provider` 초기화 문제를 해결하기 (doc/07_test_report_lunchpick.md:52-57, test/unit/data/repositories/restaurant_repository_impl_test.dart). 관련 테스트 파일이 삭제된 상태라 7건 실패를 수정하지 못했고, Hive 경로 세팅도 검증되지 않습니다.
|
||||
- [x] 광고보고 추천받기 플로우에서 광고 게이팅, 조건 필터링, 추천 팝업, 방문 처리, 재추천, 알림 연동까지 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:820-920, lib/presentation/pages/random_selection/random_selection_screen.dart:1-400, lib/presentation/providers/recommendation_provider.dart:1-220, lib/core/services/notification_service.dart:1-260). 임시 광고(닫기 포함) 재생 후 조건 충족 시 추천 팝업을 띄우고, 닫기/자동확정 시 방문 기록·알림 예약을 처리하며 다시 추천은 광고 없이 제외 목록을 적용한다. 조건 불충족 시 광고 없이 토스트를 노출한다.
|
||||
- [x] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 공유 코드 생성 시 블루투스 권한 확인과 광고 게이팅을 적용하고, 수신 데이터는 광고 시청 후 중복 제거 병합하며, 스캔/전송/취소 흐름을 UI에 연결했습니다.
|
||||
- [x] 실시간 날씨 API(기상청) 연동 및 캐시 무효화 로직 구현하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:306-329, lib/data/repositories/weather_repository_impl.dart:16-92). 위경도→기상청 좌표 변환 후 초단기 실황/예보 API를 호출해 현재·1시간 후 데이터를 구성하고, 실패 시 캐시/기본값으로 폴백합니다. 캐시는 1시간 유효하며 `KMA_SERVICE_KEY`(base64 인코딩)를 `--dart-define`으로 주입해야 동작합니다.
|
||||
- [x] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 추천 기록을 함께 로딩해 마커/목록에 표시하고, 추천 카드에서 방문 확인 시 `RecommendationNotifier.confirmVisit`를 호출하도록 연계했습니다.
|
||||
- [x] 문서에서 요구한 NaverUrlProcessor/NaverLocalApiClient 파이프라인을 별도 데이터 소스로 구축하고 캐싱·병렬 처리·에러 복구를 담당하도록 리팩터링하기 (doc/03_architecture/naver_url_processing_architecture.md:29-90,392-400, lib/data/datasources/remote/naver_map_parser.dart:32-640, lib/data/datasources/remote/naver_search_service.dart:19-210). URL 처리 전용 `NaverUrlProcessor`를 추가하고 DI에 등록해 단축 URL 해석→지도 파싱→캐싱 흐름을 분리했습니다. `NaverSearchService`는 프로세서를 통해 URL을 처리하여 중복 호출을 줄입니다.
|
||||
- [x] `print` 기반 디버그 출력을 공통 Logger로 치환하고 lints가 지적한 100+개 로그를 정리하기 (doc/06_testing/2025-07-30_update_summary.md:42-45, lib/core/services/notification_service.dart:209-214, lib/data/repositories/weather_repository_impl.dart:59-133, lib/presentation/providers/notification_handler_provider.dart:55-154 등). 현재 analyze 단계에서 warning을 유발하고 프로덕션 빌드에 불필요한 로그가 남습니다.
|
||||
- [x] RestaurantRepositoryImpl 단위 테스트를 복구하고 `path_provider` 초기화 문제를 해결하기 (doc/07_test_report_lunchpick.md:52-57, test/unit/data/repositories/restaurant_repository_impl_test.dart). Hive 임시 디렉터리 초기화와 어댑터 등록 후 CRUD/URL 추가/미리보기 흐름을 검증하는 단위 테스트를 복구했습니다.
|
||||
|
||||
@@ -31,3 +31,11 @@
|
||||
- [아키텍처 개요](03_architecture/architecture_overview.md)
|
||||
- [코드 컨벤션](03_architecture/code_convention.md)
|
||||
- [네이버 URL 처리 가이드](04_api/naver_short_url_guide.md)
|
||||
|
||||
## 데이터 시드 자동화
|
||||
|
||||
- `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 run -d R3CN70AJJ6Y --debug --uninstall-first --dart-define=KMA_SERVICE_KEY=MTg0Y2UzN2VlZmFjMGJlNWNmY2JjYWUyNmUxZDZlNjIzYmU5MDYyZmY3NDM5NjVlMzkwZmNkMzgzMGY3MTFiZg== --dart-define=VWORLD_API_KEY=7E33D818-6B06-3957-BCEF-E37EF702FAD6
|
||||
빌드시 키값을 포함해야 함.
|
||||
BIN
doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_241128.docx
Normal file
BIN
doc/restaurant_data/store.db
Normal file
@@ -1,2 +1,4 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
GAD_APPLICATION_ID=ca-app-pub-3940256099942544~1458002511
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
GAD_APPLICATION_ID=ca-app-pub-3940256099942544~1458002511
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -7,7 +8,16 @@ import UIKit
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Lunchpick</string>
|
||||
<string>오늘뭐먹Z</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>lunchpick</string>
|
||||
<string>오늘뭐먹Z</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -54,5 +54,7 @@
|
||||
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>$(GAD_APPLICATION_ID)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
192
lib/builders/store_seed_builder.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:build/build.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class StoreSeedBuilder implements Builder {
|
||||
StoreSeedBuilder();
|
||||
|
||||
@override
|
||||
final Map<String, List<String>> buildExtensions = const {
|
||||
'doc/restaurant_data/store.db': [
|
||||
'assets/data/store_seed.json',
|
||||
'assets/data/store_seed.meta.json',
|
||||
],
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> build(BuildStep buildStep) async {
|
||||
final inputId = buildStep.inputId;
|
||||
final bytes = await buildStep.readAsBytes(inputId);
|
||||
if (bytes.isEmpty) {
|
||||
log.warning('store.db가 비어 있습니다. 시드를 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final tempDir = await Directory.systemTemp.createTemp('store_seed_');
|
||||
final tempDbPath = p.join(tempDir.path, 'store.db');
|
||||
await File(tempDbPath).writeAsBytes(bytes, flush: true);
|
||||
|
||||
final sqlitePath = await _findSqliteBinary();
|
||||
if (sqlitePath == null) {
|
||||
log.severe('sqlite3 바이너리를 찾을 수 없습니다. 설치 후 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final rows = await _fetchRows(sqlitePath, tempDbPath);
|
||||
if (rows.isEmpty) {
|
||||
log.warning('restaurants 테이블에서 가져온 행이 없습니다.');
|
||||
await tempDir.delete(recursive: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final newSeeds = rows.map(_seedFromMap).toList();
|
||||
final merged = await _mergeWithExisting(buildStep, newSeeds);
|
||||
|
||||
final signature = _buildSignature(bytes);
|
||||
final generatedAt = DateTime.now().toUtc().toIso8601String();
|
||||
final meta = {
|
||||
'version': signature,
|
||||
'generatedAt': generatedAt,
|
||||
'sourceDb': inputId.path,
|
||||
'itemCount': merged.length,
|
||||
'sourceSignature': {'hash': signature, 'size': bytes.length},
|
||||
};
|
||||
|
||||
final encoder = const JsonEncoder.withIndent(' ');
|
||||
await buildStep.writeAsString(
|
||||
AssetId(inputId.package, 'assets/data/store_seed.json'),
|
||||
'${encoder.convert(merged)}\n',
|
||||
);
|
||||
await buildStep.writeAsString(
|
||||
AssetId(inputId.package, 'assets/data/store_seed.meta.json'),
|
||||
'${encoder.convert(meta)}\n',
|
||||
);
|
||||
|
||||
await tempDir.delete(recursive: true);
|
||||
log.info(
|
||||
'store_seed 생성 완료: ${merged.length}개 (sig: $signature, src: ${inputId.path})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _fetchRows(
|
||||
String sqlitePath,
|
||||
String dbPath,
|
||||
) async {
|
||||
const query =
|
||||
'SELECT id, province, district, name, title, address, road_address, '
|
||||
'latitude, longitude FROM restaurants';
|
||||
|
||||
final result = await Process.run(
|
||||
sqlitePath,
|
||||
['-json', dbPath, query],
|
||||
stdoutEncoding: utf8,
|
||||
stderrEncoding: utf8,
|
||||
);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw StateError('sqlite3 실행 실패: ${result.stderr}');
|
||||
}
|
||||
|
||||
final output = result.stdout as String;
|
||||
final decoded = jsonDecode(output);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('예상치 못한 JSON 포맷입니다.');
|
||||
}
|
||||
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _seedFromMap(Map<String, dynamic> map) {
|
||||
return {
|
||||
'storeId': map['id'] as int,
|
||||
'province': (map['province'] as String).trim(),
|
||||
'district': (map['district'] as String).trim(),
|
||||
'name': (map['name'] as String).trim(),
|
||||
'title': (map['title'] as String).trim(),
|
||||
'address': (map['address'] as String).trim(),
|
||||
'roadAddress': (map['road_address'] as String).trim(),
|
||||
'latitude': (map['latitude'] as num).toDouble(),
|
||||
'longitude': (map['longitude'] as num).toDouble(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _mergeWithExisting(
|
||||
BuildStep buildStep,
|
||||
List<Map<String, dynamic>> newSeeds,
|
||||
) async {
|
||||
final existingId = AssetId(
|
||||
buildStep.inputId.package,
|
||||
'assets/data/store_seed.json',
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> existing = [];
|
||||
if (await buildStep.canRead(existingId)) {
|
||||
final raw = await buildStep.readAsString(existingId);
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
existing = decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (_) {
|
||||
log.warning('기존 store_seed.json 파싱 실패, 신규 데이터로 대체합니다.');
|
||||
}
|
||||
}
|
||||
|
||||
final byId = <String, Map<String, dynamic>>{};
|
||||
for (final seed in existing) {
|
||||
final id = _seedId(seed);
|
||||
byId[id] = seed;
|
||||
}
|
||||
|
||||
for (final seed in newSeeds) {
|
||||
final id = _seedId(seed);
|
||||
if (!byId.containsKey(id)) {
|
||||
byId[id] = seed;
|
||||
continue;
|
||||
}
|
||||
|
||||
final isDuplicateByNameAndAddress = byId.values.any((existingSeed) {
|
||||
return existingSeed['name'] == seed['name'] &&
|
||||
existingSeed['roadAddress'] == seed['roadAddress'];
|
||||
});
|
||||
|
||||
if (!isDuplicateByNameAndAddress) {
|
||||
byId[id] = seed; // 같은 ID는 최신 값으로 교체
|
||||
}
|
||||
}
|
||||
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => (_seedId(a)).compareTo(_seedId(b)));
|
||||
return merged;
|
||||
}
|
||||
|
||||
String _seedId(Map<String, dynamic> seed) => 'store-${seed['storeId']}';
|
||||
|
||||
Future<String?> _findSqliteBinary() async {
|
||||
try {
|
||||
final result = await Process.run('which', ['sqlite3']);
|
||||
if (result.exitCode == 0) {
|
||||
final path = (result.stdout as String).trim();
|
||||
if (path.isNotEmpty) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _buildSignature(List<int> bytes) {
|
||||
int hash = 0;
|
||||
for (final byte in bytes) {
|
||||
hash = (hash * 31 + byte) & 0x7fffffff;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
}
|
||||
|
||||
Builder storeSeedBuilder(BuilderOptions options) => StoreSeedBuilder();
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
@@ -18,9 +18,25 @@ class ApiKeys {
|
||||
static String get naverClientId => _decodeIfNeeded(_encodedClientId);
|
||||
static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret);
|
||||
|
||||
static const String _encodedWeatherServiceKey = String.fromEnvironment(
|
||||
'KMA_SERVICE_KEY',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static String get weatherServiceKey =>
|
||||
_decodeIfNeeded(_encodedWeatherServiceKey);
|
||||
|
||||
static const String naverLocalSearchEndpoint =
|
||||
'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() {
|
||||
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class AppColors {
|
||||
static const lightError = Color(0xFFFF5252);
|
||||
static const lightText = Color(0xFF222222); // 추가
|
||||
static const lightCard = Colors.white; // 추가
|
||||
static const lightWarning = Color(0xFFFFA000);
|
||||
|
||||
// Dark Theme Colors
|
||||
static const darkPrimary = Color(0xFF03C75A);
|
||||
@@ -24,4 +25,5 @@ class AppColors {
|
||||
static const darkError = Color(0xFFFF5252);
|
||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||
static const darkWarning = Color(0xFFFFB74D);
|
||||
}
|
||||
|
||||
@@ -14,20 +14,31 @@ class AppConstants {
|
||||
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
||||
static const String weatherApiKey = 'YOUR_WEATHER_API_KEY';
|
||||
|
||||
// AdMob IDs (Test IDs - Replace with real IDs in production)
|
||||
// AdMob IDs (Real)
|
||||
static const String androidAdAppId = 'ca-app-pub-3940256099942544~3347511713';
|
||||
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
||||
static const String interstitialAdUnitId =
|
||||
'ca-app-pub-3940256099942544/1033173712';
|
||||
'ca-app-pub-6691216385521068/6006297260';
|
||||
static const String androidNativeAdUnitId =
|
||||
'ca-app-pub-6691216385521068/7939870622';
|
||||
static const String iosNativeAdUnitId =
|
||||
'ca-app-pub-6691216385521068/7939870622';
|
||||
static const String testAndroidNativeAdUnitId =
|
||||
'ca-app-pub-3940256099942544/2247696110';
|
||||
static const String testIosNativeAdUnitId =
|
||||
'ca-app-pub-3940256099942544/3986624511';
|
||||
|
||||
// Hive Box Names
|
||||
static const String restaurantBox = 'restaurants';
|
||||
static const String visitRecordBox = 'visit_records';
|
||||
static const String recommendationBox = 'recommendations';
|
||||
static const String settingsBox = 'settings';
|
||||
static const String storeSeedVersionKey = 'store_seed_version';
|
||||
static const String storeSeedDataAsset = 'assets/data/store_seed.json';
|
||||
static const String storeSeedMetaAsset = 'assets/data/store_seed.meta.json';
|
||||
|
||||
// Default Settings
|
||||
static const int defaultDaysToExclude = 7;
|
||||
static const int defaultDaysToExclude = 14;
|
||||
static const int defaultNotificationMinutes = 90;
|
||||
static const int defaultMaxDistanceNormal = 1000; // meters
|
||||
static const int defaultMaxDistanceRainy = 500; // meters
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
///
|
||||
@@ -13,16 +14,18 @@ class LoggingInterceptor extends Interceptor {
|
||||
final method = options.method;
|
||||
final headers = options.headers;
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('>>> REQUEST [$method] $uri');
|
||||
print('>>> Headers: $headers');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
AppLogger.debug('>>> REQUEST [$method] $uri');
|
||||
AppLogger.debug('>>> Headers: $headers');
|
||||
|
||||
if (options.data != null) {
|
||||
print('>>> Body: ${options.data}');
|
||||
AppLogger.debug('>>> Body: ${options.data}');
|
||||
}
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
print('>>> Query Parameters: ${options.queryParameters}');
|
||||
AppLogger.debug('>>> Query Parameters: ${options.queryParameters}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,21 +38,25 @@ class LoggingInterceptor extends Interceptor {
|
||||
final statusCode = response.statusCode;
|
||||
final uri = response.requestOptions.uri;
|
||||
|
||||
print('<<< RESPONSE [$statusCode] $uri');
|
||||
AppLogger.debug('<<< RESPONSE [$statusCode] $uri');
|
||||
|
||||
if (response.headers.map.isNotEmpty) {
|
||||
print('<<< Headers: ${response.headers.map}');
|
||||
AppLogger.debug('<<< Headers: ${response.headers.map}');
|
||||
}
|
||||
|
||||
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
|
||||
final responseData = response.data.toString();
|
||||
if (responseData.length > 500) {
|
||||
print('<<< Body: ${responseData.substring(0, 500)}...(truncated)');
|
||||
AppLogger.debug(
|
||||
'<<< Body: ${responseData.substring(0, 500)}...(truncated)',
|
||||
);
|
||||
} else {
|
||||
print('<<< Body: $responseData');
|
||||
AppLogger.debug('<<< Body: $responseData');
|
||||
}
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
}
|
||||
|
||||
return handler.next(response);
|
||||
@@ -61,17 +68,21 @@ class LoggingInterceptor extends Interceptor {
|
||||
final uri = err.requestOptions.uri;
|
||||
final message = err.message;
|
||||
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
print('!!! ERROR $uri');
|
||||
print('!!! Message: $message');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
AppLogger.debug('!!! ERROR $uri');
|
||||
AppLogger.debug('!!! Message: $message');
|
||||
|
||||
if (err.response != null) {
|
||||
print('!!! Status Code: ${err.response!.statusCode}');
|
||||
print('!!! Response: ${err.response!.data}');
|
||||
AppLogger.debug('!!! Status Code: ${err.response!.statusCode}');
|
||||
AppLogger.debug('!!! Response: ${err.response!.data}');
|
||||
}
|
||||
|
||||
print('!!! Error Type: ${err.type}');
|
||||
print('═══════════════════════════════════════════════════════════════');
|
||||
AppLogger.debug('!!! Error Type: ${err.type}');
|
||||
AppLogger.debug(
|
||||
'═══════════════════════════════════════════════════════════════',
|
||||
);
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../network_config.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../errors/network_exceptions.dart';
|
||||
|
||||
/// 재시도 인터셉터
|
||||
@@ -24,7 +25,7 @@ class RetryInterceptor extends Interceptor {
|
||||
// 지수 백오프 계산
|
||||
final delay = _calculateBackoffDelay(retryCount);
|
||||
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기',
|
||||
);
|
||||
|
||||
@@ -59,7 +60,7 @@ class RetryInterceptor extends Interceptor {
|
||||
// 네이버 관련 요청은 재시도하지 않음
|
||||
final url = err.requestOptions.uri.toString();
|
||||
if (url.contains('naver.com') || url.contains('naver.me')) {
|
||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
AppLogger.debug('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'network_config.dart';
|
||||
import '../errors/network_exceptions.dart';
|
||||
@@ -88,8 +90,12 @@ class NetworkClient {
|
||||
);
|
||||
|
||||
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
|
||||
} catch (e) {
|
||||
debugPrint('NetworkClient: 캐시 설정 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NetworkClient: 캐시 설정 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// 캐시 실패해도 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +1,124 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:lunchpick/core/utils/ad_helper.dart';
|
||||
|
||||
/// 간단한 전면 광고(Interstitial Ad) 모의 서비스
|
||||
/// 실제 구글 전면 광고(Interstitial Ad) 서비스.
|
||||
class AdService {
|
||||
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
|
||||
InterstitialAd? _interstitialAd;
|
||||
Completer<bool>? _loadingCompleter;
|
||||
|
||||
/// 광고를 로드하고 재생한 뒤 완료 여부를 반환한다.
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
if (!AdHelper.isMobilePlatform) return true;
|
||||
|
||||
final closeLoading = _showLoadingOverlay(context);
|
||||
await _enterImmersiveMode();
|
||||
final loaded = await _ensureAdLoaded();
|
||||
closeLoading();
|
||||
if (!loaded) {
|
||||
await _restoreSystemUi();
|
||||
return false;
|
||||
}
|
||||
|
||||
final ad = _interstitialAd;
|
||||
if (ad == null) {
|
||||
await _restoreSystemUi();
|
||||
return false;
|
||||
}
|
||||
|
||||
_interstitialAd = null;
|
||||
|
||||
final completer = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
_preload();
|
||||
unawaited(_restoreSystemUi());
|
||||
completer.complete(true);
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
_preload();
|
||||
unawaited(_restoreSystemUi());
|
||||
completer.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
// 상하단 여백 없이 전체 화면으로 표시하도록 immersive 모드 설정.
|
||||
ad.setImmersiveMode(true);
|
||||
try {
|
||||
ad.show();
|
||||
} catch (_) {
|
||||
unawaited(_restoreSystemUi());
|
||||
completer.complete(false);
|
||||
}
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _enterImmersiveMode() async {
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
overlays: [],
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _restoreSystemUi() async {
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
VoidCallback _showLoadingOverlay(BuildContext context) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _MockInterstitialAdDialog(),
|
||||
barrierColor: Colors.black.withOpacity(0.35),
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialog extends StatefulWidget {
|
||||
const _MockInterstitialAdDialog();
|
||||
|
||||
@override
|
||||
State<_MockInterstitialAdDialog> createState() =>
|
||||
_MockInterstitialAdDialogState();
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
static const int _adDurationSeconds = 4;
|
||||
|
||||
late Timer _timer;
|
||||
int _elapsedSeconds = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_elapsedSeconds++;
|
||||
});
|
||||
if (_elapsedSeconds >= _adDurationSeconds) {
|
||||
_timer.cancel();
|
||||
return () {
|
||||
if (navigator.mounted && navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
Future<bool> _ensureAdLoaded() async {
|
||||
if (_interstitialAd != null) return true;
|
||||
|
||||
bool get _canClose => _elapsedSeconds >= _adDurationSeconds;
|
||||
if (_loadingCompleter != null) {
|
||||
return _loadingCompleter!.future;
|
||||
}
|
||||
|
||||
double get _progress => (_elapsedSeconds / _adDurationSeconds).clamp(0, 1);
|
||||
final completer = Completer<bool>();
|
||||
_loadingCompleter = completer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 80),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.ondemand_video,
|
||||
size: 56,
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'광고 시청 중...',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_canClose ? '광고가 완료되었습니다.' : '잠시만 기다려 주세요.',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.2),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_canClose
|
||||
? '이제 닫을 수 있어요.'
|
||||
: '남은 시간: ${_adDurationSeconds - _elapsedSeconds}초',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _canClose
|
||||
? () {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: Colors.deepPurple,
|
||||
),
|
||||
child: const Text('추천 계속 보기'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
InterstitialAd.load(
|
||||
adUnitId: AdHelper.interstitialAdUnitId,
|
||||
request: const AdRequest(),
|
||||
adLoadCallback: InterstitialAdLoadCallback(
|
||||
onAdLoaded: (ad) {
|
||||
_interstitialAd = ad;
|
||||
completer.complete(true);
|
||||
_loadingCompleter = null;
|
||||
},
|
||||
onAdFailedToLoad: (error) {
|
||||
completer.complete(false);
|
||||
_loadingCompleter = null;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void _preload() {
|
||||
if (_interstitialAd != null || _loadingCompleter != null) return;
|
||||
_ensureAdLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
124
lib/core/services/geocoding_service.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
/// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스
|
||||
class GeocodingService {
|
||||
static const _endpoint = 'https://nominatim.openstreetmap.org/search';
|
||||
static const _fallbackLatitude = 37.5665; // 서울시청 위도
|
||||
static const _fallbackLongitude = 126.9780; // 서울시청 경도
|
||||
|
||||
/// 도로명/지번 주소를 기반으로 위경도를 조회한다.
|
||||
///
|
||||
/// 무료(Nominatim) 엔드포인트를 사용하며 별도 API 키가 필요 없다.
|
||||
/// 실패 시 null을 반환하고, 호출 측에서 기본 좌표를 사용할 수 있게 둔다.
|
||||
Future<({double latitude, double longitude})?> geocode(String address) async {
|
||||
if (address.trim().isEmpty) return null;
|
||||
|
||||
// 1차: VWorld 지오코딩 시도 (키가 존재할 때만)
|
||||
final vworldResult = await _geocodeWithVworld(address);
|
||||
if (vworldResult != null) {
|
||||
return vworldResult;
|
||||
}
|
||||
|
||||
// 2차: Nominatim (fallback)
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}',
|
||||
);
|
||||
|
||||
// Nominatim은 User-Agent 헤더를 요구한다.
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
AppLogger.debug('[GeocodingService] 실패 status: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> results = jsonDecode(response.body) as List<dynamic>;
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
final first = results.first as Map<String, dynamic>;
|
||||
final lat = double.tryParse(first['lat']?.toString() ?? '');
|
||||
final lon = double.tryParse(first['lon']?.toString() ?? '');
|
||||
|
||||
if (lat == null || lon == null) {
|
||||
AppLogger.debug('[GeocodingService] 응답 파싱 실패: ${first.toString()}');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (latitude: lat, longitude: lon);
|
||||
} catch (e) {
|
||||
AppLogger.debug('[GeocodingService] 예외 발생: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 기본 좌표(서울시청)를 반환한다.
|
||||
({double latitude, double longitude}) defaultCoordinates() {
|
||||
return (latitude: _fallbackLatitude, longitude: _fallbackLongitude);
|
||||
}
|
||||
|
||||
Future<({double latitude, double longitude})?> _geocodeWithVworld(
|
||||
String address,
|
||||
) async {
|
||||
final apiKey = ApiKeys.vworldApiKey;
|
||||
if (apiKey.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.https('api.vworld.kr', '/req/address', {
|
||||
'service': 'address',
|
||||
'request': 'getcoord',
|
||||
'format': 'json',
|
||||
'type': 'road', // 도로명 주소 기준
|
||||
'key': apiKey,
|
||||
'address': address,
|
||||
});
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
AppLogger.debug(
|
||||
'[GeocodingService] VWorld 실패 status: ${response.statusCode}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// 알림 서비스 싱글톤 클래스
|
||||
class NotificationService {
|
||||
@@ -14,6 +15,7 @@ class NotificationService {
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _initialized = false;
|
||||
|
||||
// 알림 채널 정보
|
||||
static const String _channelId = 'lunchpick_visit_reminder';
|
||||
@@ -22,11 +24,36 @@ class NotificationService {
|
||||
|
||||
// 알림 ID (방문 확인용)
|
||||
static const int _visitReminderNotificationId = 1;
|
||||
bool _timezoneReady = false;
|
||||
tz.Location? _cachedLocation;
|
||||
|
||||
/// 초기화 여부
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// 초기화 및 권한 요청 보장
|
||||
Future<bool> ensureInitialized({bool requestPermission = false}) async {
|
||||
if (!_initialized) {
|
||||
_initialized = await initialize();
|
||||
}
|
||||
|
||||
if (!_initialized) return false;
|
||||
|
||||
if (requestPermission) {
|
||||
final alreadyGranted = await checkPermission();
|
||||
if (alreadyGranted) return true;
|
||||
return await this.requestPermission();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 알림 서비스 초기화
|
||||
Future<bool> initialize() async {
|
||||
if (_initialized) return true;
|
||||
if (kIsWeb) return false;
|
||||
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
await _ensureLocalTimezone();
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings(
|
||||
@@ -58,18 +85,25 @@ class NotificationService {
|
||||
);
|
||||
|
||||
// 알림 플러그인 초기화
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
|
||||
);
|
||||
try {
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse:
|
||||
_onBackgroundNotificationTap,
|
||||
);
|
||||
_initialized = initialized ?? false;
|
||||
} catch (e) {
|
||||
_initialized = false;
|
||||
AppLogger.debug('알림 초기화 실패: $e');
|
||||
}
|
||||
|
||||
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (_initialized && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await _createNotificationChannel();
|
||||
}
|
||||
|
||||
return initialized ?? false;
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
/// Android 알림 채널 생성
|
||||
@@ -83,34 +117,34 @@ class NotificationService {
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
try {
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
} catch (e) {
|
||||
AppLogger.debug('안드로이드 채널 생성 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림 권한 요청
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (kIsWeb) return false;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
} else if (!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
@@ -144,27 +178,86 @@ class NotificationService {
|
||||
|
||||
/// 권한 상태 확인
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (kIsWeb) return false;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final settings = await iosImplementation.checkPermissions();
|
||||
return settings?.isEnabled ?? false;
|
||||
}
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final settings = await macosImplementation.checkPermissions();
|
||||
return settings?.isEnabled ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS 외 플랫폼은 기본적으로 허용으로 간주
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 정확 알람 권한 가능 여부 확인 (Android 12+)
|
||||
Future<bool> canScheduleExactAlarms() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final canExact = await androidImplementation
|
||||
?.canScheduleExactNotifications();
|
||||
return canExact ?? true;
|
||||
} catch (e) {
|
||||
AppLogger.debug('정확 알람 권한 확인 실패: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 정확 알람 권한 요청 (Android 12+ 설정 화면 이동)
|
||||
Future<bool> requestExactAlarmsPermission() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final granted = await androidImplementation
|
||||
?.requestExactAlarmsPermission();
|
||||
return granted ?? false;
|
||||
} catch (e) {
|
||||
AppLogger.debug('정확 알람 권한 요청 실패: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 탭 콜백
|
||||
static void Function(NotificationResponse)? onNotificationTap;
|
||||
|
||||
@@ -176,9 +269,22 @@ class NotificationService {
|
||||
int? delayMinutes,
|
||||
}) async {
|
||||
try {
|
||||
final minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
|
||||
final ready = await ensureInitialized();
|
||||
if (!ready) {
|
||||
AppLogger.debug('알림 서비스가 초기화되지 않아 예약을 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final permissionGranted = await checkPermission();
|
||||
if (!permissionGranted) {
|
||||
AppLogger.debug('알림 권한이 없어 예약을 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final location = await _ensureLocalTimezone();
|
||||
final minutesToWait = max(delayMinutes ?? 90 + Random().nextInt(31), 1);
|
||||
final scheduledTime = tz.TZDateTime.now(
|
||||
tz.local,
|
||||
location,
|
||||
).add(Duration(minutes: minutesToWait));
|
||||
|
||||
// 알림 상세 설정
|
||||
@@ -215,45 +321,87 @@ class NotificationService {
|
||||
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
||||
scheduledTime,
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
androidScheduleMode: await _resolveAndroidScheduleMode(),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload:
|
||||
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
}
|
||||
AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('알림 예약 실패: $e');
|
||||
}
|
||||
AppLogger.debug('알림 예약 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 예약된 방문 확인 알림 취소
|
||||
Future<void> cancelVisitReminder() async {
|
||||
if (!await ensureInitialized()) return;
|
||||
await _notifications.cancel(_visitReminderNotificationId);
|
||||
}
|
||||
|
||||
/// 모든 알림 취소
|
||||
Future<void> cancelAllNotifications() async {
|
||||
if (!await ensureInitialized()) return;
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
/// 예약된 알림 목록 조회
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
if (!await ensureInitialized()) return [];
|
||||
return await _notifications.pendingNotificationRequests();
|
||||
}
|
||||
|
||||
/// 방문 확인 알림이 예약되어 있는지 확인
|
||||
Future<bool> hasVisitReminderScheduled() async {
|
||||
if (!await ensureInitialized()) return false;
|
||||
final pending = await getPendingNotifications();
|
||||
return pending.any((item) => item.id == _visitReminderNotificationId);
|
||||
}
|
||||
|
||||
/// 타임존을 안전하게 초기화하고 tz.local을 반환
|
||||
Future<tz.Location> _ensureLocalTimezone() async {
|
||||
if (_cachedLocation != null) return _cachedLocation!;
|
||||
if (!_timezoneReady) {
|
||||
try {
|
||||
tz.initializeTimeZones();
|
||||
} catch (_) {
|
||||
// 초기화 실패 시에도 계속 진행
|
||||
}
|
||||
_timezoneReady = true;
|
||||
}
|
||||
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
_cachedLocation = tz.local;
|
||||
} catch (_) {
|
||||
// 로컬 타임존을 가져오지 못하면 UTC로 강제 설정
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
_cachedLocation = tz.UTC;
|
||||
}
|
||||
return _cachedLocation!;
|
||||
}
|
||||
|
||||
/// 정확 알람 권한 여부에 따라 스케줄 모드 결정
|
||||
Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return AndroidScheduleMode.exactAllowWhileIdle;
|
||||
}
|
||||
|
||||
final canExact = await canScheduleExactAlarms();
|
||||
if (!canExact) {
|
||||
AppLogger.debug('정확 알람 권한 없음 → 근사 모드로 예약');
|
||||
return AndroidScheduleMode.inexactAllowWhileIdle;
|
||||
}
|
||||
|
||||
return AndroidScheduleMode.exactAllowWhileIdle;
|
||||
}
|
||||
|
||||
/// 알림 탭 이벤트 처리
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('알림 탭: ${response.payload}');
|
||||
}
|
||||
AppLogger.debug('알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,9 +411,7 @@ class NotificationService {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
AppLogger.debug('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
lib/core/utils/ad_helper.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
class AdHelper {
|
||||
static bool get isMobilePlatform {
|
||||
if (kIsWeb) return false;
|
||||
return defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
}
|
||||
|
||||
static String get interstitialAdUnitId {
|
||||
if (!isMobilePlatform) {
|
||||
throw UnsupportedError('Interstitial ads are only supported on mobile.');
|
||||
}
|
||||
return AppConstants.interstitialAdUnitId;
|
||||
}
|
||||
|
||||
static String get nativeAdUnitId {
|
||||
if (!isMobilePlatform) {
|
||||
throw UnsupportedError('Native ads are only supported on mobile.');
|
||||
}
|
||||
|
||||
final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
if (isIOS) {
|
||||
if (kDebugMode) {
|
||||
return AppConstants.testIosNativeAdUnitId;
|
||||
}
|
||||
return AppConstants.iosNativeAdUnitId;
|
||||
}
|
||||
|
||||
// Android는 디버그/릴리즈 모두 실제 광고 단위 ID 사용
|
||||
return AppConstants.androidNativeAdUnitId;
|
||||
}
|
||||
}
|
||||
28
lib/core/utils/app_logger.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 앱 전역에서 사용하는 로거.
|
||||
/// debugPrint를 감싸 경고 없이 로그를 남기며, debug 레벨은 디버그 모드에서만 출력합니다.
|
||||
class AppLogger {
|
||||
AppLogger._();
|
||||
|
||||
static void debug(String message) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(message);
|
||||
}
|
||||
}
|
||||
|
||||
static void info(String message) {
|
||||
debugPrint(message);
|
||||
}
|
||||
|
||||
static void error(String message, {Object? error, StackTrace? stackTrace}) {
|
||||
final buffer = StringBuffer(message);
|
||||
if (error != null) {
|
||||
buffer.write(' | error: $error');
|
||||
}
|
||||
if (stackTrace != null) {
|
||||
buffer.write('\n$stackTrace');
|
||||
}
|
||||
debugPrint(buffer.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
@@ -46,7 +46,11 @@ class NaverGraphQLApi {
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchGraphQL error: $e');
|
||||
AppLogger.error(
|
||||
'fetchGraphQL error: $e',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
throw ServerException(
|
||||
message: 'GraphQL 요청 중 오류가 발생했습니다',
|
||||
statusCode: e.response?.statusCode ?? 500,
|
||||
@@ -104,13 +108,17 @@ class NaverGraphQLApi {
|
||||
);
|
||||
|
||||
if (response['errors'] != null) {
|
||||
debugPrint('GraphQL errors: ${response['errors']}');
|
||||
AppLogger.error('GraphQL errors: ${response['errors']}');
|
||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchKoreanTextsFromPcmap error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'fetchKoreanTextsFromPcmap error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -150,8 +158,12 @@ class NaverGraphQLApi {
|
||||
}
|
||||
|
||||
return response['data']?['place'] ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('fetchPlaceBasicInfo error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'fetchPlaceBasicInfo error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/constants/api_keys.dart';
|
||||
import '../../../core/network/network_client.dart';
|
||||
@@ -143,9 +143,13 @@ class NaverLocalSearchApi {
|
||||
.map((item) => NaverLocalSearchResult.fromJson(item))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
||||
debugPrint('Error type: ${e.type}');
|
||||
debugPrint('Error response: ${e.response?.data}');
|
||||
AppLogger.error(
|
||||
'NaverLocalSearchApi error: ${e.message}',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
AppLogger.debug('Error type: ${e.type}');
|
||||
AppLogger.debug('Error response: ${e.response?.data}');
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
@@ -189,8 +193,12 @@ class NaverLocalSearchApi {
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 반환
|
||||
return results.first;
|
||||
} catch (e) {
|
||||
debugPrint('searchRestaurantDetails error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'searchRestaurantDetails error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
@@ -22,7 +23,7 @@ class NaverProxyClient {
|
||||
|
||||
try {
|
||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
||||
debugPrint('Using proxy URL: $proxyUrl');
|
||||
AppLogger.debug('Using proxy URL: $proxyUrl');
|
||||
|
||||
final response = await _networkClient.get<String>(
|
||||
proxyUrl,
|
||||
@@ -42,9 +43,13 @@ class NaverProxyClient {
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('Proxy fetch error: ${e.message}');
|
||||
debugPrint('Status code: ${e.response?.statusCode}');
|
||||
debugPrint('Response: ${e.response?.data}');
|
||||
AppLogger.error(
|
||||
'Proxy fetch error: ${e.message}',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
AppLogger.debug('Status code: ${e.response?.statusCode}');
|
||||
AppLogger.debug('Response: ${e.response?.data}');
|
||||
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw ServerException(
|
||||
@@ -78,8 +83,12 @@ class NaverProxyClient {
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Proxy status check failed: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Proxy status check failed: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../../core/network/network_client.dart';
|
||||
import '../../../core/network/network_config.dart';
|
||||
@@ -36,10 +38,20 @@ class NaverUrlResolver {
|
||||
return location;
|
||||
}
|
||||
|
||||
// Location이 없는 경우, http.Client로 리다이렉트를 끝까지 따라가며 최종 URL 추출 (fallback)
|
||||
final expanded = await _followRedirectsWithHttp(shortUrl);
|
||||
if (expanded != null) {
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// 리다이렉트가 없으면 원본 URL 반환
|
||||
return shortUrl;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('resolveShortUrl error: $e');
|
||||
AppLogger.error(
|
||||
'resolveShortUrl error: $e',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
|
||||
// 리다이렉트 응답인 경우 Location 헤더 확인
|
||||
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
|
||||
@@ -49,6 +61,12 @@ class NaverUrlResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// Dio 실패 시 fallback으로 http.Client 리다이렉트 추적 시도
|
||||
final expanded = await _followRedirectsWithHttp(shortUrl);
|
||||
if (expanded != null) {
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// 오류 발생 시 원본 URL 반환
|
||||
return shortUrl;
|
||||
}
|
||||
@@ -98,8 +116,12 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
return shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('_resolveShortUrlViaProxy error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'_resolveShortUrlViaProxy error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return shortUrl;
|
||||
}
|
||||
}
|
||||
@@ -139,8 +161,12 @@ class NaverUrlResolver {
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
} catch (e) {
|
||||
debugPrint('getFinalRedirectUrl error: $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'getFinalRedirectUrl error: $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -148,4 +174,26 @@ class NaverUrlResolver {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
import '../../core/network/network_client.dart';
|
||||
import '../../core/errors/network_exceptions.dart';
|
||||
@@ -88,7 +89,11 @@ class NaverApiClient {
|
||||
|
||||
return response.data!;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('fetchMapPageHtml error: $e');
|
||||
AppLogger.error(
|
||||
'fetchMapPageHtml error: $e',
|
||||
error: e,
|
||||
stackTrace: e.stackTrace,
|
||||
);
|
||||
|
||||
if (e.error is NetworkException) {
|
||||
throw e.error!;
|
||||
@@ -123,9 +128,9 @@ class NaverApiClient {
|
||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||
|
||||
try {
|
||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
debugPrint('요청 URL: $pcmapUrl');
|
||||
debugPrint('Place ID: $placeId');
|
||||
AppLogger.debug('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||
AppLogger.debug('요청 URL: $pcmapUrl');
|
||||
AppLogger.debug('Place ID: $placeId');
|
||||
|
||||
String html;
|
||||
if (kIsWeb) {
|
||||
@@ -148,7 +153,7 @@ class NaverApiClient {
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 || response.data == null) {
|
||||
debugPrint(
|
||||
AppLogger.error(
|
||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||
);
|
||||
return {
|
||||
@@ -172,11 +177,11 @@ class NaverApiClient {
|
||||
html,
|
||||
);
|
||||
|
||||
debugPrint('========== 추출 결과 ==========');
|
||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
||||
debugPrint('Apollo State 상호명: $apolloName');
|
||||
debugPrint('=====================================');
|
||||
AppLogger.debug('========== 추출 결과 ==========');
|
||||
AppLogger.debug('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||
AppLogger.debug('JSON-LD 상호명: $jsonLdName');
|
||||
AppLogger.debug('Apollo State 상호명: $apolloName');
|
||||
AppLogger.debug('=====================================');
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
@@ -187,8 +192,12 @@ class NaverApiClient {
|
||||
'apolloStateName': apolloName,
|
||||
'extractedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverApiClient: pcmap 페이지 파싱 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return {
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
|
||||
class NaverHtmlExtractor {
|
||||
@@ -323,11 +323,11 @@ class NaverHtmlExtractor {
|
||||
// 리스트로 변환하여 반환
|
||||
final resultList = uniqueTexts.toList();
|
||||
|
||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
AppLogger.debug('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||
for (int i = 0; i < resultList.length; i++) {
|
||||
debugPrint('[$i] ${resultList[i]}');
|
||||
AppLogger.debug('[$i] ${resultList[i]}');
|
||||
}
|
||||
debugPrint('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
AppLogger.debug('========== 총 ${resultList.length}개 추출됨 ==========');
|
||||
|
||||
return resultList;
|
||||
}
|
||||
@@ -377,8 +377,12 @@ class NaverHtmlExtractor {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: JSON-LD 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -418,14 +422,21 @@ class NaverHtmlExtractor {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패
|
||||
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: Apollo State 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -442,7 +453,7 @@ class NaverHtmlExtractor {
|
||||
final match = ogUrlRegex.firstMatch(html);
|
||||
if (match != null) {
|
||||
final url = match.group(1);
|
||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
AppLogger.debug('NaverHtmlExtractor: og:url 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -454,11 +465,15 @@ class NaverHtmlExtractor {
|
||||
final canonicalMatch = canonicalRegex.firstMatch(html);
|
||||
if (canonicalMatch != null) {
|
||||
final url = canonicalMatch.group(1);
|
||||
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||
AppLogger.debug('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||
return url;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlExtractor: Place Link 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
/// 네이버 지도 HTML 파서
|
||||
///
|
||||
@@ -77,8 +77,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 이름 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -97,8 +101,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 카테고리 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -115,8 +123,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 서브 카테고리 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -137,8 +149,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 설명 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -159,8 +175,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 전화번호 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -179,8 +199,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 도로명 주소 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -201,8 +225,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 지번 주소 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -238,8 +266,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 위도 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -275,8 +307,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 경도 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -297,8 +333,12 @@ class NaverHtmlParser {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverHtmlParser: 영업시간 추출 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../api/naver_api_client.dart';
|
||||
import '../../api/naver/naver_local_search_api.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
import 'naver_html_parser.dart';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../api/naver/naver_graphql_queries.dart';
|
||||
import '../../api/naver/naver_local_search_api.dart';
|
||||
import '../../api/naver_api_client.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
import 'naver_html_parser.dart';
|
||||
|
||||
/// 네이버 지도 URL 파서
|
||||
/// 네이버 지도 URL에서 식당 정보를 추출합니다.
|
||||
@@ -21,8 +23,9 @@ class NaverMapParser {
|
||||
|
||||
// 정규식 패턴
|
||||
static final RegExp _placeIdRegex = RegExp(
|
||||
r'/p/(?:restaurant|entry/place)/(\d+)',
|
||||
r'(?:/p/(?:restaurant|entry/place)/|/place/)(\d+)',
|
||||
);
|
||||
static final RegExp _pinIdRegex = RegExp(r'pinId["=](\d+)');
|
||||
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
||||
|
||||
// 기본 좌표 (서울 시청)
|
||||
@@ -60,9 +63,7 @@ class NaverMapParser {
|
||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||
}
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Starting to parse URL: $url');
|
||||
}
|
||||
AppLogger.debug('[naver_url] 원본 URL 수신: $url');
|
||||
|
||||
// URL 유효성 검증
|
||||
if (!_isValidNaverUrl(url)) {
|
||||
@@ -72,9 +73,7 @@ class NaverMapParser {
|
||||
// 짧은 URL인 경우 리다이렉트 처리
|
||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
|
||||
}
|
||||
AppLogger.debug('[naver_url] resolveShortUrl 결과: $finalUrl');
|
||||
|
||||
// Place ID 추출 (10자리 숫자)
|
||||
final String? placeId = _extractPlaceId(finalUrl);
|
||||
@@ -82,23 +81,18 @@ class NaverMapParser {
|
||||
// 짧은 URL에서 직접 ID 추출 시도
|
||||
final shortUrlId = _extractShortUrlId(url);
|
||||
if (shortUrlId != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
||||
);
|
||||
}
|
||||
AppLogger.debug('[naver_url] 단축 URL ID를 Place ID로 사용: $shortUrlId');
|
||||
return _createFallbackRestaurant(shortUrlId, url);
|
||||
}
|
||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||
}
|
||||
AppLogger.debug('[naver_url] Place ID 추출 성공: $placeId');
|
||||
|
||||
// 단축 URL인 경우 특별 처리
|
||||
final isShortUrl = url.contains('naver.me');
|
||||
|
||||
if (isShortUrl) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||
|
||||
try {
|
||||
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
||||
@@ -108,14 +102,17 @@ class NaverMapParser {
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
||||
}
|
||||
AppLogger.debug(
|
||||
'[naver_url] LocalSearch 파싱 성공: '
|
||||
'name=${restaurant.name}, road=${restaurant.roadAddress}',
|
||||
);
|
||||
return restaurant;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// 실패 시 기본 파싱으로 계속 진행
|
||||
}
|
||||
}
|
||||
@@ -126,6 +123,12 @@ class NaverMapParser {
|
||||
userLatitude: userLatitude,
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[naver_url] GraphQL/검색 파싱 결과 요약: '
|
||||
'name=${restaurantData['name']}, '
|
||||
'road=${restaurantData['roadAddress']}, '
|
||||
'phone=${restaurantData['phone']}',
|
||||
);
|
||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException) {
|
||||
@@ -156,7 +159,11 @@ class NaverMapParser {
|
||||
/// URL에서 Place ID 추출
|
||||
String? _extractPlaceId(String url) {
|
||||
final match = _placeIdRegex.firstMatch(url);
|
||||
return match?.group(1);
|
||||
if (match != null) return match.group(1);
|
||||
|
||||
// 핀 공유 형식: pinId="1234567890" 또는 pinId=1234567890
|
||||
final pinMatch = _pinIdRegex.firstMatch(url);
|
||||
return pinMatch?.group(1);
|
||||
}
|
||||
|
||||
/// 짧은 URL에서 ID 추출
|
||||
@@ -177,9 +184,7 @@ class NaverMapParser {
|
||||
}) async {
|
||||
// 심플한 접근: URL로 직접 검색
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 시작');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: URL 기반 검색 시작');
|
||||
|
||||
// 네이버 지도 URL 구성
|
||||
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||
@@ -196,32 +201,34 @@ class NaverMapParser {
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[naver_url] URL 기반 검색 응답 개수: ${searchResults.length}, '
|
||||
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
||||
);
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
// place ID가 포함된 결과 찾기
|
||||
for (final result in searchResults) {
|
||||
if (result.link.contains(placeId)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||
);
|
||||
return _convertSearchResultToData(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: URL 검색 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Place ID로 검색
|
||||
@@ -236,19 +243,23 @@ class NaverMapParser {
|
||||
longitude: userLongitude,
|
||||
display: _searchDisplayCount,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[naver_url] Place ID 검색 응답 개수: ${searchResults.length}, '
|
||||
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
||||
);
|
||||
|
||||
if (searchResults.isNotEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||
);
|
||||
return _convertSearchResultToData(searchResults.first);
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: Place ID 검색 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
@@ -258,10 +269,12 @@ class NaverMapParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: URL 기반 검색 실패 - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
// 429 에러인 경우 즉시 예외 발생
|
||||
if (e is DioException && e.response?.statusCode == 429) {
|
||||
@@ -275,14 +288,15 @@ class NaverMapParser {
|
||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||
// 첫 번째 시도: places 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Trying places query...');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: Trying places query...');
|
||||
final response = await _apiClient.fetchGraphQL(
|
||||
operationName: 'getPlaceDetail',
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.placeDetailQuery,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[naver_url] places query 응답 keys: ${response.keys.toList()}',
|
||||
);
|
||||
|
||||
// places 응답 처리 (배열일 수도 있음)
|
||||
final placesData = response['data']?['places'];
|
||||
@@ -293,22 +307,25 @@ class NaverMapParser {
|
||||
return _extractPlaceData(placesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: places query failed - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: places query failed - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// 두 번째 시도: nxPlaces 쿼리
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Trying nxPlaces query...');
|
||||
}
|
||||
AppLogger.debug('NaverMapParser: Trying nxPlaces query...');
|
||||
final response = await _apiClient.fetchGraphQL(
|
||||
operationName: 'getPlaceDetail',
|
||||
variables: {'id': placeId},
|
||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[naver_url] nxPlaces query 응답 keys: ${response.keys.toList()}',
|
||||
);
|
||||
|
||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||
final nxPlacesData = response['data']?['nxPlaces'];
|
||||
@@ -319,18 +336,18 @@ class NaverMapParser {
|
||||
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'NaverMapParser: nxPlaces query failed - $e',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||
);
|
||||
}
|
||||
AppLogger.debug(
|
||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||
);
|
||||
return await _fallbackToHtmlParsing(placeId);
|
||||
}
|
||||
|
||||
@@ -508,7 +525,7 @@ class NaverMapParser {
|
||||
double? userLongitude,
|
||||
) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
AppLogger.debug('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||
}
|
||||
|
||||
// 1. 한글 텍스트 추출
|
||||
@@ -525,17 +542,17 @@ class NaverMapParser {
|
||||
if (koreanData['jsonLdName'] != null) {
|
||||
searchQuery = koreanData['jsonLdName'] as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
||||
AppLogger.debug('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
||||
}
|
||||
} else if (koreanData['apolloStateName'] != null) {
|
||||
searchQuery = koreanData['apolloStateName'] as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
||||
AppLogger.debug('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
||||
}
|
||||
} else if (koreanTexts.isNotEmpty) {
|
||||
searchQuery = koreanTexts.first as String;
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
||||
AppLogger.debug('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
||||
}
|
||||
} else {
|
||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||
@@ -543,7 +560,7 @@ class NaverMapParser {
|
||||
|
||||
// 2. 로컬 검색 API 호출
|
||||
if (kDebugMode) {
|
||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
AppLogger.debug('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||
}
|
||||
|
||||
await Future.delayed(
|
||||
@@ -563,15 +580,15 @@ class NaverMapParser {
|
||||
|
||||
// 디버깅: 검색 결과 Place ID 분석
|
||||
if (kDebugMode) {
|
||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
AppLogger.debug('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||
for (int i = 0; i < searchResults.length; i++) {
|
||||
final result = searchResults[i];
|
||||
final extractedId = result.extractPlaceId();
|
||||
debugPrint('[$i] ${result.title}');
|
||||
debugPrint(' 링크: ${result.link}');
|
||||
debugPrint(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
||||
AppLogger.debug('[$i] ${result.title}');
|
||||
AppLogger.debug(' 링크: ${result.link}');
|
||||
AppLogger.debug(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
||||
}
|
||||
debugPrint('=====================================');
|
||||
AppLogger.debug('=====================================');
|
||||
}
|
||||
|
||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||
@@ -583,7 +600,7 @@ class NaverMapParser {
|
||||
if (extractedId == placeId) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
||||
AppLogger.debug('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -604,7 +621,7 @@ class NaverMapParser {
|
||||
exactName.contains(result.title)) {
|
||||
bestMatch = result;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
||||
AppLogger.debug('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -620,7 +637,7 @@ class NaverMapParser {
|
||||
userLongitude,
|
||||
);
|
||||
if (bestMatch != null && kDebugMode) {
|
||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
AppLogger.debug('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +645,7 @@ class NaverMapParser {
|
||||
if (bestMatch == null) {
|
||||
bestMatch = searchResults.first;
|
||||
if (kDebugMode) {
|
||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
AppLogger.debug('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,7 +687,7 @@ class NaverMapParser {
|
||||
}
|
||||
|
||||
if (kDebugMode && nearest != null) {
|
||||
debugPrint(
|
||||
AppLogger.debug(
|
||||
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../api/naver_api_client.dart';
|
||||
import '../../api/naver/naver_local_search_api.dart';
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import '../../../core/errors/network_exceptions.dart';
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
import 'naver_url_processor.dart';
|
||||
|
||||
/// 네이버 검색 서비스
|
||||
///
|
||||
@@ -12,14 +15,21 @@ import 'naver_map_parser.dart';
|
||||
class NaverSearchService {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final NaverUrlProcessor _urlProcessor;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
// 성능 최적화를 위한 정규식 캐싱
|
||||
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
|
||||
|
||||
NaverSearchService({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||
: _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
NaverSearchService({
|
||||
NaverApiClient? apiClient,
|
||||
NaverMapParser? mapParser,
|
||||
NaverUrlProcessor? urlProcessor,
|
||||
}) : _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient),
|
||||
_urlProcessor =
|
||||
urlProcessor ??
|
||||
NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser);
|
||||
|
||||
/// URL에서 식당 정보 가져오기
|
||||
///
|
||||
@@ -32,7 +42,7 @@ class NaverSearchService {
|
||||
/// - [NetworkException] 네트워크 오류 발생 시
|
||||
Future<Restaurant> getRestaurantFromUrl(String url) async {
|
||||
try {
|
||||
return await _mapParser.parseRestaurantFromUrl(url);
|
||||
return await _urlProcessor.processUrl(url);
|
||||
} catch (e) {
|
||||
if (e is NaverMapParseException || e is NetworkException) {
|
||||
rethrow;
|
||||
@@ -149,9 +159,9 @@ class NaverSearchService {
|
||||
);
|
||||
} catch (e) {
|
||||
// 상세 파싱 실패해도 기본 정보 반환
|
||||
if (kDebugMode) {
|
||||
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}');
|
||||
}
|
||||
AppLogger.debug(
|
||||
'[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
lib/data/datasources/remote/naver_url_processor.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
import '../../api/naver_api_client.dart';
|
||||
import 'naver_map_parser.dart';
|
||||
|
||||
/// 네이버 지도 URL을 처리하고 결과를 캐시하는 경량 프로세서.
|
||||
/// - 단축 URL 해석 → 지도 파서 실행
|
||||
/// - 동일 URL 재요청 시 메모리 캐시 반환
|
||||
class NaverUrlProcessor {
|
||||
final NaverApiClient _apiClient;
|
||||
final NaverMapParser _mapParser;
|
||||
final _cache = HashMap<String, Restaurant>();
|
||||
|
||||
NaverUrlProcessor({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||
: _apiClient = apiClient ?? NaverApiClient(),
|
||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||
|
||||
Future<Restaurant> processUrl(
|
||||
String url, {
|
||||
double? userLatitude,
|
||||
double? userLongitude,
|
||||
}) async {
|
||||
final normalizedUrl = url.trim();
|
||||
if (_cache.containsKey(normalizedUrl)) {
|
||||
return _cache[normalizedUrl]!;
|
||||
}
|
||||
|
||||
final resolved = await _apiClient.resolveShortUrl(normalizedUrl);
|
||||
final restaurant = await _mapParser.parseRestaurantFromUrl(
|
||||
resolved,
|
||||
userLatitude: userLatitude,
|
||||
userLongitude: userLongitude,
|
||||
);
|
||||
_cache[normalizedUrl] = restaurant;
|
||||
_cache[resolved] = restaurant;
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
void clearCache() => _cache.clear();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
static const String _boxName = 'restaurants';
|
||||
@@ -63,8 +64,17 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
@override
|
||||
Stream<List<Restaurant>> watchRestaurants() async* {
|
||||
final box = await _box;
|
||||
yield box.values.toList();
|
||||
yield* box.watch().map((_) => box.values.toList());
|
||||
final initial = box.values.toList();
|
||||
AppLogger.debug('[restaurant_repo] initial load count: ${initial.length}');
|
||||
yield initial;
|
||||
yield* box.watch().map((event) {
|
||||
final values = box.values.toList();
|
||||
AppLogger.debug(
|
||||
'[restaurant_repo] box watch event -> count: ${values.length} '
|
||||
'(key=${event.key}, deleted=${event.deleted})',
|
||||
);
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -94,6 +104,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
businessHours: restaurant.businessHours,
|
||||
lastVisited: visitDate,
|
||||
visitCount: restaurant.visitCount + 1,
|
||||
needsAddressVerification: restaurant.needsAddressVerification,
|
||||
);
|
||||
await updateRestaurant(updatedRestaurant);
|
||||
}
|
||||
@@ -224,7 +235,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,18 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
static const String _keyNotificationDelayMinutes =
|
||||
'notification_delay_minutes';
|
||||
static const String _keyNotificationEnabled = 'notification_enabled';
|
||||
static const String _keyScreenshotModeEnabled = 'screenshot_mode_enabled';
|
||||
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
||||
static const String _keyFirstRun = 'first_run';
|
||||
static const String _keyCategoryWeights = 'category_weights';
|
||||
|
||||
// Default values
|
||||
static const int _defaultDaysToExclude = 7;
|
||||
static const int _defaultDaysToExclude = 14;
|
||||
static const int _defaultMaxDistanceRainy = 500;
|
||||
static const int _defaultMaxDistanceNormal = 1000;
|
||||
static const int _defaultNotificationDelayMinutes = 90;
|
||||
static const bool _defaultNotificationEnabled = true;
|
||||
static const bool _defaultScreenshotModeEnabled = false;
|
||||
static const bool _defaultDarkModeEnabled = false;
|
||||
static const bool _defaultFirstRun = true;
|
||||
|
||||
@@ -155,6 +157,21 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
await box.put(_keyNotificationEnabled, enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isScreenshotModeEnabled() async {
|
||||
final box = await _box;
|
||||
return box.get(
|
||||
_keyScreenshotModeEnabled,
|
||||
defaultValue: _defaultScreenshotModeEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setScreenshotModeEnabled(bool enabled) async {
|
||||
final box = await _box;
|
||||
await box.put(_keyScreenshotModeEnabled, enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isDarkModeEnabled() async {
|
||||
final box = await _box;
|
||||
@@ -193,6 +210,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
_defaultNotificationDelayMinutes,
|
||||
);
|
||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||
await box.put(_keyScreenshotModeEnabled, _defaultScreenshotModeEnabled);
|
||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||
}
|
||||
@@ -215,6 +233,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
||||
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
||||
_keyNotificationEnabled: await isNotificationEnabled(),
|
||||
_keyScreenshotModeEnabled: await isScreenshotModeEnabled(),
|
||||
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
||||
_keyFirstRun: await isFirstRun(),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
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/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
|
||||
@@ -15,18 +21,32 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
// TODO: 실제 날씨 API 호출 구현
|
||||
// 여기서는 임시로 더미 데이터 반환
|
||||
final cached = await getCachedWeather();
|
||||
|
||||
final dummyWeather = WeatherInfo(
|
||||
current: WeatherData(temperature: 20, isRainy: false, description: '맑음'),
|
||||
nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'),
|
||||
);
|
||||
|
||||
// 캐시에 저장
|
||||
await cacheWeatherInfo(dummyWeather);
|
||||
|
||||
return dummyWeather;
|
||||
try {
|
||||
final weather = await _fetchWeatherFromKma(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
await cacheWeatherInfo(weather);
|
||||
return weather;
|
||||
} catch (_) {
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
return WeatherInfo(
|
||||
current: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '날씨 정보를 불러오지 못했어요',
|
||||
),
|
||||
nextHour: WeatherData(
|
||||
temperature: 20,
|
||||
isRainy: false,
|
||||
description: '날씨 정보를 불러오지 못했어요',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -48,7 +68,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
try {
|
||||
// 안전한 타입 변환
|
||||
if (cachedData is! Map) {
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
@@ -62,7 +82,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
// Map 구조 검증
|
||||
if (!weatherMap.containsKey('current') ||
|
||||
!weatherMap.containsKey('nextHour')) {
|
||||
print('WeatherCache: Missing required fields in weather data');
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Missing required fields in weather data',
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
@@ -70,7 +92,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
return _weatherInfoFromMap(weatherMap);
|
||||
} catch (e) {
|
||||
// 캐시 데이터가 손상된 경우
|
||||
print('WeatherCache: Error parsing cached weather data: $e');
|
||||
AppLogger.error(
|
||||
'WeatherCache: Error parsing cached weather data',
|
||||
error: e,
|
||||
);
|
||||
await clearWeatherCache();
|
||||
return null;
|
||||
}
|
||||
@@ -118,7 +143,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
// 날짜 파싱 시도
|
||||
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
||||
if (lastUpdateTime == null) {
|
||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
||||
AppLogger.debug(
|
||||
'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -127,7 +154,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
|
||||
return difference < _cacheValidDuration;
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error checking cache validity: $e');
|
||||
AppLogger.error('WeatherCache: Error checking cache validity', error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -150,13 +177,19 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
|
||||
try {
|
||||
// current 필드 검증
|
||||
final currentMap = map['current'] as Map<String, dynamic>?;
|
||||
final currentRaw = map['current'];
|
||||
final currentMap = currentRaw is Map
|
||||
? Map<String, dynamic>.from(currentRaw)
|
||||
: null;
|
||||
if (currentMap == null) {
|
||||
throw FormatException('Missing current weather data');
|
||||
}
|
||||
|
||||
// nextHour 필드 검증
|
||||
final nextHourMap = map['nextHour'] as Map<String, dynamic>?;
|
||||
final nextHourRaw = map['nextHour'];
|
||||
final nextHourMap = nextHourRaw is Map
|
||||
? Map<String, dynamic>.from(nextHourRaw)
|
||||
: null;
|
||||
if (nextHourMap == null) {
|
||||
throw FormatException('Missing nextHour weather data');
|
||||
}
|
||||
@@ -183,9 +216,284 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('WeatherCache: Error converting map to WeatherInfo: $e');
|
||||
print('WeatherCache: Map data: $map');
|
||||
AppLogger.error(
|
||||
'WeatherCache: Error converting map to WeatherInfo',
|
||||
error: e,
|
||||
stackTrace: StackTrace.current,
|
||||
);
|
||||
AppLogger.debug('WeatherCache: Map data: $map');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<WeatherInfo> _fetchWeatherFromKma({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
final serviceKey = _encodeServiceKey(ApiKeys.weatherServiceKey);
|
||||
if (serviceKey.isEmpty) {
|
||||
throw Exception('기상청 서비스 키가 설정되지 않았습니다.');
|
||||
}
|
||||
|
||||
final gridPoint = _latLonToGrid(latitude, longitude);
|
||||
final baseDateTime = _resolveBaseDateTime();
|
||||
|
||||
final ncstUri = Uri.https(
|
||||
'apis.data.go.kr',
|
||||
'/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst',
|
||||
{
|
||||
'serviceKey': serviceKey,
|
||||
'numOfRows': '100',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
'base_date': baseDateTime.date,
|
||||
'base_time': baseDateTime.ncstTime,
|
||||
'nx': gridPoint.x.toString(),
|
||||
'ny': gridPoint.y.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
final fcstUri = Uri.https(
|
||||
'apis.data.go.kr',
|
||||
'/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst',
|
||||
{
|
||||
'serviceKey': serviceKey,
|
||||
'numOfRows': '200',
|
||||
'pageNo': '1',
|
||||
'dataType': 'JSON',
|
||||
'base_date': baseDateTime.date,
|
||||
'base_time': baseDateTime.fcstTime,
|
||||
'nx': gridPoint.x.toString(),
|
||||
'ny': gridPoint.y.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
final ncstItems = await _requestKmaItems(ncstUri);
|
||||
final fcstItems = await _requestKmaItems(fcstUri);
|
||||
|
||||
final currentTemp = _extractLatestValue(ncstItems, 'T1H')?.round();
|
||||
final currentPty = _extractLatestValue(ncstItems, 'PTY')?.round() ?? 0;
|
||||
final currentSky = _extractLatestValue(ncstItems, 'SKY')?.round() ?? 1;
|
||||
|
||||
final now = DateTime.now();
|
||||
final nextHourData = _extractForecast(fcstItems, after: now);
|
||||
final nextTemp = nextHourData.temperature?.round();
|
||||
final nextPty = nextHourData.pty ?? 0;
|
||||
final nextSky = nextHourData.sky ?? 1;
|
||||
|
||||
final currentWeather = WeatherData(
|
||||
temperature: currentTemp ?? 20,
|
||||
isRainy: _isRainy(currentPty),
|
||||
description: _describeWeather(currentSky, currentPty),
|
||||
);
|
||||
|
||||
final nextWeather = WeatherData(
|
||||
temperature: nextTemp ?? currentTemp ?? 20,
|
||||
isRainy: _isRainy(nextPty),
|
||||
description: _describeWeather(nextSky, nextPty),
|
||||
);
|
||||
|
||||
return WeatherInfo(current: currentWeather, nextHour: nextWeather);
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _requestKmaItems(Uri uri) async {
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Weather API 호출 실패: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = body['response']?['body']?['items']?['item'];
|
||||
if (items is List<dynamic>) {
|
||||
return items;
|
||||
}
|
||||
throw Exception('Weather API 응답 파싱 실패');
|
||||
}
|
||||
|
||||
double? _extractLatestValue(List<dynamic> items, String category) {
|
||||
final filtered = items.where((item) => item['category'] == category);
|
||||
if (filtered.isEmpty) return null;
|
||||
final sorted = filtered.toList()
|
||||
..sort((a, b) {
|
||||
final dateA = a['baseDate'] as String? ?? '';
|
||||
final timeA = a['baseTime'] as String? ?? '';
|
||||
final dateB = b['baseDate'] as String? ?? '';
|
||||
final timeB = b['baseTime'] as String? ?? '';
|
||||
final dtA = _parseKmaDateTime(dateA, timeA);
|
||||
final dtB = _parseKmaDateTime(dateB, timeB);
|
||||
return dtB.compareTo(dtA);
|
||||
});
|
||||
final value = sorted.first['obsrValue'] ?? sorted.first['fcstValue'];
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
({double? temperature, int? pty, int? sky}) _extractForecast(
|
||||
List<dynamic> items, {
|
||||
required DateTime after,
|
||||
}) {
|
||||
DateTime? targetTime;
|
||||
double? temperature;
|
||||
int? pty;
|
||||
int? sky;
|
||||
|
||||
DateTime fcstDateTime(Map<String, dynamic> item) {
|
||||
final date = item['fcstDate'] as String? ?? '';
|
||||
final time = item['fcstTime'] as String? ?? '';
|
||||
return _parseKmaDateTime(date, time);
|
||||
}
|
||||
|
||||
for (final item in items) {
|
||||
final dt = fcstDateTime(item as Map<String, dynamic>);
|
||||
if (!dt.isAfter(after)) continue;
|
||||
if (targetTime == null || dt.isBefore(targetTime)) {
|
||||
targetTime = dt;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetTime == null) {
|
||||
return (temperature: null, pty: null, sky: null);
|
||||
}
|
||||
|
||||
for (final item in items) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final dt = fcstDateTime(map);
|
||||
if (dt != targetTime) continue;
|
||||
|
||||
final category = map['category'];
|
||||
final value = map['fcstValue'];
|
||||
if (value == null) continue;
|
||||
|
||||
if (category == 'T1H' && temperature == null) {
|
||||
temperature = value is num
|
||||
? value.toDouble()
|
||||
: double.tryParse('$value');
|
||||
} else if (category == 'PTY' && pty == null) {
|
||||
pty = value is num ? value.toInt() : int.tryParse('$value');
|
||||
} else if (category == 'SKY' && sky == null) {
|
||||
sky = value is num ? value.toInt() : int.tryParse('$value');
|
||||
}
|
||||
}
|
||||
|
||||
return (temperature: temperature, pty: pty, sky: sky);
|
||||
}
|
||||
|
||||
_GridPoint _latLonToGrid(double lat, double lon) {
|
||||
const re = 6371.00877;
|
||||
const grid = 5.0;
|
||||
const slat1 = 30.0 * pi / 180.0;
|
||||
const slat2 = 60.0 * pi / 180.0;
|
||||
const olon = 126.0 * pi / 180.0;
|
||||
const olat = 38.0 * pi / 180.0;
|
||||
const xo = 43.0;
|
||||
const yo = 136.0;
|
||||
|
||||
final sn =
|
||||
log(cos(slat1) / cos(slat2)) /
|
||||
log(tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5));
|
||||
final sf = pow(tan(pi * 0.25 + slat1 * 0.5), sn) * cos(slat1) / sn;
|
||||
final ro = re / grid * sf / pow(tan(pi * 0.25 + olat * 0.5), sn);
|
||||
final ra =
|
||||
re / grid * sf / pow(tan(pi * 0.25 + (lat * pi / 180.0) * 0.5), sn);
|
||||
var theta = lon * pi / 180.0 - olon;
|
||||
if (theta > pi) theta -= 2.0 * pi;
|
||||
if (theta < -pi) theta += 2.0 * pi;
|
||||
theta *= sn;
|
||||
|
||||
final x = (ra * sin(theta) + xo + 0.5).floor();
|
||||
final y = (ro - ra * cos(theta) + yo + 0.5).floor();
|
||||
return _GridPoint(x: x, y: y);
|
||||
}
|
||||
|
||||
bool _isRainy(int pty) => pty > 0;
|
||||
|
||||
String _describeWeather(int sky, int pty) {
|
||||
if (pty == 1) return '비';
|
||||
if (pty == 2) return '비/눈';
|
||||
if (pty == 3) return '눈';
|
||||
if (pty == 4) return '소나기';
|
||||
if (pty == 5) return '빗방울';
|
||||
if (pty == 6) return '빗방울/눈날림';
|
||||
if (pty == 7) return '눈날림';
|
||||
|
||||
switch (sky) {
|
||||
case 1:
|
||||
return '맑음';
|
||||
case 3:
|
||||
return '구름 많음';
|
||||
case 4:
|
||||
return '흐림';
|
||||
default:
|
||||
return '맑음';
|
||||
}
|
||||
}
|
||||
|
||||
/// 서비스 키를 안전하게 URL 인코딩한다.
|
||||
/// 이미 인코딩된 값(%)이 포함되어 있으면 그대로 사용한다.
|
||||
String _encodeServiceKey(String key) {
|
||||
if (key.isEmpty) return '';
|
||||
if (key.contains('%')) return key;
|
||||
return Uri.encodeComponent(key);
|
||||
}
|
||||
|
||||
_BaseDateTime _resolveBaseDateTime() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 초단기실황은 매시 정시 발표(정시+10분 이후 호출 권장)
|
||||
// 초단기예보는 매시 30분 발표(30분+10분 이후 호출 권장)
|
||||
final ncstAnchor = now.minute >= 10
|
||||
? DateTime(now.year, now.month, now.day, now.hour, 0)
|
||||
: DateTime(now.year, now.month, now.day, now.hour - 1, 0);
|
||||
final fcstAnchor = now.minute >= 40
|
||||
? DateTime(now.year, now.month, now.day, now.hour, 30)
|
||||
: DateTime(now.year, now.month, now.day, now.hour - 1, 30);
|
||||
|
||||
final date = _formatDate(fcstAnchor); // 둘 다 같은 날짜/시점 기준
|
||||
final ncstTime = _formatTime(ncstAnchor);
|
||||
final fcstTime = _formatTime(fcstAnchor);
|
||||
|
||||
return _BaseDateTime(date: date, ncstTime: ncstTime, fcstTime: fcstTime);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
final y = dt.year.toString().padLeft(4, '0');
|
||||
final m = dt.month.toString().padLeft(2, '0');
|
||||
final d = dt.day.toString().padLeft(2, '0');
|
||||
return '$y$m$d';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
return '$h$m';
|
||||
}
|
||||
|
||||
DateTime _parseKmaDateTime(String date, String time) {
|
||||
final year = int.parse(date.substring(0, 4));
|
||||
final month = int.parse(date.substring(4, 6));
|
||||
final day = int.parse(date.substring(6, 8));
|
||||
final hour = int.parse(time.substring(0, 2));
|
||||
final minute = int.parse(time.substring(2, 4));
|
||||
return DateTime(year, month, day, hour, minute);
|
||||
}
|
||||
}
|
||||
|
||||
class _GridPoint {
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
_GridPoint({required this.x, required this.y});
|
||||
}
|
||||
|
||||
class _BaseDateTime {
|
||||
final String date;
|
||||
final String ncstTime;
|
||||
final String fcstTime;
|
||||
|
||||
_BaseDateTime({
|
||||
required this.date,
|
||||
required this.ncstTime,
|
||||
required this.fcstTime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ import 'package:lunchpick/core/constants/app_constants.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
import 'store_dataset_seeder.dart';
|
||||
import 'manual_restaurant_samples.dart';
|
||||
|
||||
/// 초기 구동 시 샘플 데이터를 채워 넣는 도우미
|
||||
class SampleDataInitializer {
|
||||
static Future<void> seedInitialData() async {
|
||||
await StoreDatasetSeeder().seedIfNeeded();
|
||||
await seedManualRestaurantsIfNeeded();
|
||||
}
|
||||
|
||||
static Future<void> seedManualRestaurantsIfNeeded() async {
|
||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||
final visitBox = Hive.box<VisitRecord>(AppConstants.visitRecordBox);
|
||||
|
||||
243
lib/data/sample/store_dataset_seeder.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/core/constants/app_constants.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
class StoreSeedMeta {
|
||||
final String version;
|
||||
final DateTime generatedAt;
|
||||
final int itemCount;
|
||||
final StoreSeedSourceSignature? sourceSignature;
|
||||
|
||||
StoreSeedMeta({
|
||||
required this.version,
|
||||
required this.generatedAt,
|
||||
required this.itemCount,
|
||||
this.sourceSignature,
|
||||
});
|
||||
|
||||
factory StoreSeedMeta.fromJson(Map<String, dynamic> json) {
|
||||
StoreSeedSourceSignature? signature;
|
||||
if (json['sourceSignature'] != null) {
|
||||
signature = StoreSeedSourceSignature.fromJson(
|
||||
json['sourceSignature'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
return StoreSeedMeta(
|
||||
version: json['version'] as String,
|
||||
generatedAt: DateTime.parse(json['generatedAt'] as String),
|
||||
itemCount: json['itemCount'] as int,
|
||||
sourceSignature: signature,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSeedSourceSignature {
|
||||
final String hash;
|
||||
final int? size;
|
||||
|
||||
StoreSeedSourceSignature({required this.hash, this.size});
|
||||
|
||||
factory StoreSeedSourceSignature.fromJson(Map<String, dynamic> json) {
|
||||
return StoreSeedSourceSignature(
|
||||
hash: json['hash'] as String,
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSeedItem {
|
||||
final int storeId;
|
||||
final String name;
|
||||
final String title;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
|
||||
StoreSeedItem({
|
||||
required this.storeId,
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
});
|
||||
|
||||
factory StoreSeedItem.fromJson(Map<String, dynamic> json) {
|
||||
return StoreSeedItem(
|
||||
storeId: json['storeId'] as int,
|
||||
name: (json['name'] as String).trim(),
|
||||
title: (json['title'] as String).trim(),
|
||||
address: (json['address'] as String).trim(),
|
||||
roadAddress: (json['roadAddress'] as String).trim(),
|
||||
latitude: (json['latitude'] as num).toDouble(),
|
||||
longitude: (json['longitude'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreDatasetSeeder {
|
||||
Future<void> seedIfNeeded() async {
|
||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||
final settingsBox = Hive.box(AppConstants.settingsBox);
|
||||
|
||||
final meta = await _loadMeta();
|
||||
if (meta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentVersion =
|
||||
settingsBox.get(AppConstants.storeSeedVersionKey) as String?;
|
||||
final shouldSeed = restaurantBox.isEmpty || currentVersion != meta.version;
|
||||
if (!shouldSeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final seeds = await _loadSeedItems();
|
||||
if (seeds.isEmpty) {
|
||||
AppLogger.info('store_seed.json 데이터가 비어 있어 시드를 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await _applySeeds(
|
||||
restaurantBox: restaurantBox,
|
||||
seeds: seeds,
|
||||
generatedAt: meta.generatedAt,
|
||||
);
|
||||
await settingsBox.put(AppConstants.storeSeedVersionKey, meta.version);
|
||||
|
||||
AppLogger.info(
|
||||
'스토어 시드 적용 완료: version=${meta.version}, count=${meta.itemCount}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<StoreSeedMeta?> _loadMeta() async {
|
||||
try {
|
||||
final metaJson = await rootBundle.loadString(
|
||||
AppConstants.storeSeedMetaAsset,
|
||||
);
|
||||
final decoded = jsonDecode(metaJson) as Map<String, dynamic>;
|
||||
return StoreSeedMeta.fromJson(decoded);
|
||||
} catch (e, stack) {
|
||||
AppLogger.error(
|
||||
'store_seed.meta.json 로딩 실패',
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<StoreSeedItem>> _loadSeedItems() async {
|
||||
try {
|
||||
final dataJson = await rootBundle.loadString(
|
||||
AppConstants.storeSeedDataAsset,
|
||||
);
|
||||
final decoded = jsonDecode(dataJson);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('store_seed.json 포맷이 배열이 아닙니다.');
|
||||
}
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(StoreSeedItem.fromJson)
|
||||
.toList();
|
||||
} catch (e, stack) {
|
||||
AppLogger.error('store_seed.json 로딩 실패', error: e, stackTrace: stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applySeeds({
|
||||
required Box<Restaurant> restaurantBox,
|
||||
required List<StoreSeedItem> seeds,
|
||||
required DateTime generatedAt,
|
||||
}) async {
|
||||
final seedMap = {for (final seed in seeds) _buildId(seed.storeId): seed};
|
||||
int added = 0;
|
||||
int updated = 0;
|
||||
|
||||
for (final entry in seedMap.entries) {
|
||||
final id = entry.key;
|
||||
final seed = entry.value;
|
||||
final existing = restaurantBox.get(id);
|
||||
|
||||
if (existing == null) {
|
||||
final restaurant = _buildRestaurant(seed, generatedAt);
|
||||
await restaurantBox.put(id, restaurant);
|
||||
added++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.source == DataSource.PRESET) {
|
||||
final description = _buildDescription(seed, existing.description);
|
||||
final restaurant = existing.copyWith(
|
||||
name: seed.name,
|
||||
category: existing.category.isNotEmpty ? existing.category : '기타',
|
||||
subCategory: existing.subCategory.isNotEmpty
|
||||
? existing.subCategory
|
||||
: '기타',
|
||||
description: description,
|
||||
roadAddress: seed.roadAddress,
|
||||
jibunAddress: seed.address.isNotEmpty
|
||||
? seed.address
|
||||
: seed.roadAddress,
|
||||
latitude: seed.latitude,
|
||||
longitude: seed.longitude,
|
||||
updatedAt: generatedAt,
|
||||
);
|
||||
await restaurantBox.put(id, restaurant);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
final unchanged = restaurantBox.length - added - updated;
|
||||
AppLogger.debug(
|
||||
'스토어 시드 결과 - 추가: $added, 업데이트: $updated, 기존 유지: '
|
||||
'$unchanged',
|
||||
);
|
||||
}
|
||||
|
||||
Restaurant _buildRestaurant(StoreSeedItem seed, DateTime generatedAt) {
|
||||
return Restaurant(
|
||||
id: _buildId(seed.storeId),
|
||||
name: seed.name,
|
||||
category: '기타',
|
||||
subCategory: '기타',
|
||||
description: _buildDescription(seed, null),
|
||||
phoneNumber: null,
|
||||
roadAddress: seed.roadAddress,
|
||||
jibunAddress: seed.address.isNotEmpty ? seed.address : seed.roadAddress,
|
||||
latitude: seed.latitude,
|
||||
longitude: seed.longitude,
|
||||
lastVisitDate: null,
|
||||
source: DataSource.PRESET,
|
||||
createdAt: generatedAt,
|
||||
updatedAt: generatedAt,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: null,
|
||||
lastVisited: null,
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildId(int storeId) => 'store-$storeId';
|
||||
|
||||
String? _buildDescription(StoreSeedItem seed, String? existingDescription) {
|
||||
if (existingDescription != null && existingDescription.isNotEmpty) {
|
||||
return existingDescription;
|
||||
}
|
||||
|
||||
if (seed.title.isNotEmpty && seed.title != seed.name) {
|
||||
return seed.title;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ class Restaurant extends HiveObject {
|
||||
@HiveField(18)
|
||||
final int visitCount;
|
||||
|
||||
@HiveField(19)
|
||||
final bool needsAddressVerification;
|
||||
|
||||
Restaurant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -81,6 +84,7 @@ class Restaurant extends HiveObject {
|
||||
this.businessHours,
|
||||
this.lastVisited,
|
||||
this.visitCount = 0,
|
||||
this.needsAddressVerification = false,
|
||||
});
|
||||
|
||||
Restaurant copyWith({
|
||||
@@ -103,6 +107,7 @@ class Restaurant extends HiveObject {
|
||||
String? businessHours,
|
||||
DateTime? lastVisited,
|
||||
int? visitCount,
|
||||
bool? needsAddressVerification,
|
||||
}) {
|
||||
return Restaurant(
|
||||
id: id ?? this.id,
|
||||
@@ -124,6 +129,8 @@ class Restaurant extends HiveObject {
|
||||
businessHours: businessHours ?? this.businessHours,
|
||||
lastVisited: lastVisited ?? this.lastVisited,
|
||||
visitCount: visitCount ?? this.visitCount,
|
||||
needsAddressVerification:
|
||||
needsAddressVerification ?? this.needsAddressVerification,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,4 +142,7 @@ enum DataSource {
|
||||
|
||||
@HiveField(1)
|
||||
USER_INPUT,
|
||||
|
||||
@HiveField(2)
|
||||
PRESET,
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ abstract class SettingsRepository {
|
||||
/// 알림 활성화 여부를 설정합니다
|
||||
Future<void> setNotificationEnabled(bool enabled);
|
||||
|
||||
/// 스크린샷 모드 활성화 여부를 가져옵니다
|
||||
Future<bool> isScreenshotModeEnabled();
|
||||
|
||||
/// 스크린샷 모드 활성화 여부를 설정합니다
|
||||
Future<void> setScreenshotModeEnabled(bool enabled);
|
||||
|
||||
/// 다크모드 설정을 가져옵니다
|
||||
Future<bool> isDarkModeEnabled();
|
||||
|
||||
|
||||
@@ -98,9 +98,34 @@ class RecommendationEngine {
|
||||
.toSet();
|
||||
|
||||
// 최근 방문하지 않은 식당만 필터링
|
||||
return restaurants.where((restaurant) {
|
||||
final filtered = restaurants.where((restaurant) {
|
||||
return !recentlyVisitedIds.contains(restaurant.id);
|
||||
}).toList();
|
||||
|
||||
if (filtered.isNotEmpty) return filtered;
|
||||
|
||||
// 모든 식당이 제외되면 가장 오래전에 방문한 식당을 반환
|
||||
final lastVisitByRestaurant = <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;
|
||||
|
||||
// 각 식당에 대한 가중치 계산
|
||||
final weightedRestaurants = restaurants.map((restaurant) {
|
||||
double weight = 1.0;
|
||||
|
||||
// 카테고리 가중치 적용
|
||||
final categoryWeight =
|
||||
config.userSettings.categoryWeights[restaurant.category];
|
||||
if (categoryWeight != null) {
|
||||
weight *= categoryWeight;
|
||||
}
|
||||
|
||||
// 거리 가중치 적용 (가까울수록 높은 가중치)
|
||||
final distance = DistanceCalculator.calculateDistance(
|
||||
lat1: config.userLatitude,
|
||||
lon1: config.userLongitude,
|
||||
lat2: restaurant.latitude,
|
||||
lon2: restaurant.longitude,
|
||||
);
|
||||
final distanceWeight = 1.0 - (distance / config.maxDistance);
|
||||
weight *= (0.5 + distanceWeight * 0.5); // 50% ~ 100% 범위
|
||||
|
||||
// 시간대별 가중치 적용
|
||||
weight *= _getTimeBasedWeight(restaurant, config.currentTime);
|
||||
|
||||
// 날씨 기반 가중치 적용
|
||||
if (config.weather != null) {
|
||||
weight *= _getWeatherBasedWeight(restaurant, config.weather!);
|
||||
}
|
||||
|
||||
return _WeightedRestaurant(restaurant, weight);
|
||||
}).toList();
|
||||
|
||||
// 가중치 기반 랜덤 선택
|
||||
return _weightedRandomSelection(weightedRestaurants);
|
||||
}
|
||||
|
||||
/// 시간대별 가중치 계산
|
||||
double _getTimeBasedWeight(Restaurant restaurant, DateTime currentTime) {
|
||||
final hour = currentTime.hour;
|
||||
|
||||
// 아침 시간대 (7-10시)
|
||||
if (hour >= 7 && hour < 10) {
|
||||
if (restaurant.category == 'cafe' || restaurant.category == 'korean') {
|
||||
return 1.2;
|
||||
}
|
||||
if (restaurant.category == 'bar') {
|
||||
return 0.3;
|
||||
}
|
||||
}
|
||||
// 점심 시간대 (11-14시)
|
||||
else if (hour >= 11 && hour < 14) {
|
||||
if (restaurant.category == 'korean' ||
|
||||
restaurant.category == 'chinese' ||
|
||||
restaurant.category == 'japanese') {
|
||||
return 1.3;
|
||||
}
|
||||
}
|
||||
// 저녁 시간대 (17-21시)
|
||||
else if (hour >= 17 && hour < 21) {
|
||||
if (restaurant.category == 'bar' || restaurant.category == 'western') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
// 늦은 저녁 (21시 이후)
|
||||
else if (hour >= 21) {
|
||||
if (restaurant.category == 'bar' || restaurant.category == 'fastfood') {
|
||||
return 1.3;
|
||||
}
|
||||
if (restaurant.category == 'cafe') {
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// 날씨 기반 가중치 계산
|
||||
double _getWeatherBasedWeight(Restaurant restaurant, WeatherInfo weather) {
|
||||
if (weather.current.isRainy) {
|
||||
// 비가 올 때는 가까운 식당 선호
|
||||
// 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호
|
||||
if (restaurant.category == 'cafe' || restaurant.category == 'fastfood') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 더운 날씨 (25도 이상)
|
||||
if (weather.current.temperature >= 25) {
|
||||
if (restaurant.category == 'cafe' || restaurant.category == 'japanese') {
|
||||
return 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
// 추운 날씨 (10도 이하)
|
||||
if (weather.current.temperature <= 10) {
|
||||
if (restaurant.category == 'korean' || restaurant.category == 'chinese') {
|
||||
return 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// 가중치 기반 랜덤 선택
|
||||
Restaurant? _weightedRandomSelection(
|
||||
List<_WeightedRestaurant> weightedRestaurants,
|
||||
) {
|
||||
if (weightedRestaurants.isEmpty) return null;
|
||||
|
||||
// 전체 가중치 합계 계산
|
||||
final totalWeight = weightedRestaurants.fold<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;
|
||||
// 가중치 미적용: 거리/방문 필터를 통과한 식당 중 균등 무작위 선택
|
||||
return restaurants[_random.nextInt(restaurants.length)];
|
||||
}
|
||||
}
|
||||
|
||||
/// 가중치가 적용된 식당 모델
|
||||
class _WeightedRestaurant {
|
||||
final Restaurant restaurant;
|
||||
final double weight;
|
||||
|
||||
_WeightedRestaurant(this.restaurant, this.weight);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
|
||||
import 'core/constants/app_colors.dart';
|
||||
import 'core/constants/app_constants.dart';
|
||||
import 'core/services/notification_service.dart';
|
||||
import 'core/utils/ad_helper.dart';
|
||||
import 'domain/entities/restaurant.dart';
|
||||
import 'domain/entities/visit_record.dart';
|
||||
import 'domain/entities/recommendation_record.dart';
|
||||
@@ -20,6 +22,10 @@ import 'data/sample/sample_data_initializer.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (AdHelper.isMobilePlatform) {
|
||||
await MobileAds.instance.initialize();
|
||||
}
|
||||
|
||||
// Initialize timezone
|
||||
tz.initializeTimeZones();
|
||||
|
||||
@@ -39,13 +45,12 @@ void main() async {
|
||||
await Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox);
|
||||
await Hive.openBox(AppConstants.settingsBox);
|
||||
await Hive.openBox<UserSettings>('user_settings');
|
||||
await SampleDataInitializer.seedManualRestaurantsIfNeeded();
|
||||
await SampleDataInitializer.seedInitialData();
|
||||
|
||||
// Initialize Notification Service (only for non-web platforms)
|
||||
if (!kIsWeb) {
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
await notificationService.requestPermission();
|
||||
await notificationService.ensureInitialized(requestPermission: true);
|
||||
}
|
||||
|
||||
// Get saved theme mode
|
||||
|
||||
@@ -3,9 +3,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../domain/entities/restaurant.dart';
|
||||
import '../../../domain/entities/recommendation_record.dart';
|
||||
import '../../../domain/entities/visit_record.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../providers/visit_provider.dart';
|
||||
import '../../widgets/native_ad_placeholder.dart';
|
||||
import '../restaurant_list/widgets/edit_restaurant_dialog.dart';
|
||||
import 'widgets/visit_record_card.dart';
|
||||
import 'widgets/recommendation_record_card.dart';
|
||||
import 'widgets/visit_statistics.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerStatefulWidget {
|
||||
@@ -19,15 +27,22 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late DateTime _selectedDay;
|
||||
late DateTime _focusedDay;
|
||||
late DateTime _selectedMonth;
|
||||
late DateTime _firstDay;
|
||||
late DateTime _lastDay;
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
late TabController _tabController;
|
||||
Map<DateTime, List<VisitRecord>> _visitRecordEvents = {};
|
||||
Map<DateTime, List<_CalendarEvent>> _events = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDay = DateTime.now();
|
||||
_focusedDay = DateTime.now();
|
||||
final now = DateTime.now();
|
||||
_selectedDay = now;
|
||||
_focusedDay = now;
|
||||
_selectedMonth = DateTime(now.year, now.month, 1);
|
||||
_firstDay = DateTime(now.year - 1, now.month, 1);
|
||||
_lastDay = DateTime(now.year + 1, now.month, now.day);
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@@ -37,14 +52,30 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<VisitRecord> _getEventsForDay(DateTime day) {
|
||||
List<_CalendarEvent> _getEventsForDay(DateTime day) {
|
||||
final normalizedDay = DateTime(day.year, day.month, day.day);
|
||||
return _visitRecordEvents[normalizedDay] ?? [];
|
||||
return _events[normalizedDay] ?? [];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
||||
final recommendationRecordsAsync = ref.watch(recommendationRecordsProvider);
|
||||
final screenshotModeEnabled = ref
|
||||
.watch(screenshotModeEnabledProvider)
|
||||
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||
|
||||
final visits = visitRecordsAsync.value ?? <VisitRecord>[];
|
||||
final recommendations =
|
||||
recommendationRecordsAsync.valueOrNull ?? <RecommendationRecord>[];
|
||||
|
||||
if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) {
|
||||
_events = _buildEvents(visits, recommendations);
|
||||
_updateCalendarRange(visits, recommendations);
|
||||
}
|
||||
|
||||
final monthOptions = _buildMonthOptions(_firstDay, _lastDay);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
@@ -71,160 +102,196 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// 캘린더 탭
|
||||
_buildCalendarTab(isDark),
|
||||
_buildCalendarTab(isDark: isDark, adsEnabled: !screenshotModeEnabled),
|
||||
// 통계 탭
|
||||
VisitStatistics(selectedMonth: _focusedDay),
|
||||
VisitStatistics(
|
||||
selectedMonth: _selectedMonth,
|
||||
availableMonths: monthOptions,
|
||||
onMonthChanged: _onMonthChanged,
|
||||
adsEnabled: !screenshotModeEnabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCalendarTab(bool isDark) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
||||
Widget _buildCalendarTab({required bool isDark, bool adsEnabled = true}) {
|
||||
final visitColor = _visitMarkerColor(isDark);
|
||||
final recommendationColor = _recommendationMarkerColor(isDark);
|
||||
|
||||
// 방문 기록을 날짜별로 그룹화
|
||||
visitRecordsAsync.whenData((records) {
|
||||
_visitRecordEvents = {};
|
||||
for (final record in records) {
|
||||
final normalizedDate = DateTime(
|
||||
record.visitDate.year,
|
||||
record.visitDate.month,
|
||||
record.visitDate.day,
|
||||
);
|
||||
_visitRecordEvents[normalizedDate] = [
|
||||
...(_visitRecordEvents[normalizedDate] ?? []),
|
||||
record,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 캘린더
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TableCalendar(
|
||||
firstDay: DateTime.utc(2025, 1, 1),
|
||||
lastDay: DateTime.utc(2030, 12, 31),
|
||||
focusedDay: _focusedDay,
|
||||
calendarFormat: _calendarFormat,
|
||||
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
||||
onDaySelected: (selectedDay, focusedDay) {
|
||||
setState(() {
|
||||
_selectedDay = selectedDay;
|
||||
_focusedDay = focusedDay;
|
||||
});
|
||||
},
|
||||
onFormatChanged: (format) {
|
||||
setState(() {
|
||||
_calendarFormat = format;
|
||||
});
|
||||
},
|
||||
eventLoader: _getEventsForDay,
|
||||
calendarBuilders: CalendarBuilders(
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
|
||||
final visitRecords = events.cast<VisitRecord>();
|
||||
final confirmedCount = visitRecords
|
||||
.where((r) => r.isConfirmed)
|
||||
.length;
|
||||
final unconfirmedCount =
|
||||
visitRecords.length - confirmedCount;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (confirmedCount > 0)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (unconfirmedCount > 0)
|
||||
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,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
markersMaxCount: 2,
|
||||
markerDecoration: const BoxDecoration(
|
||||
color: AppColors.lightSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
weekendTextStyle: const TextStyle(
|
||||
color: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
headerStyle: HeaderStyle(
|
||||
formatButtonVisible: true,
|
||||
titleCentered: true,
|
||||
formatButtonShowsNext: false,
|
||||
formatButtonDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
color: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
formatButtonTextStyle: const TextStyle(
|
||||
color: AppColors.lightPrimary,
|
||||
child: TableCalendar(
|
||||
firstDay: _firstDay,
|
||||
lastDay: _lastDay,
|
||||
focusedDay: _focusedDay,
|
||||
calendarFormat: _calendarFormat,
|
||||
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
||||
onDaySelected: (selectedDay, focusedDay) {
|
||||
setState(() {
|
||||
_selectedDay = selectedDay;
|
||||
_focusedDay = focusedDay;
|
||||
_selectedMonth = DateTime(
|
||||
focusedDay.year,
|
||||
focusedDay.month,
|
||||
1,
|
||||
);
|
||||
});
|
||||
},
|
||||
onPageChanged: (focusedDay) {
|
||||
setState(() {
|
||||
_focusedDay = focusedDay;
|
||||
_selectedMonth = DateTime(
|
||||
focusedDay.year,
|
||||
focusedDay.month,
|
||||
1,
|
||||
);
|
||||
_selectedDay = focusedDay;
|
||||
});
|
||||
},
|
||||
onFormatChanged: (format) {
|
||||
setState(() {
|
||||
_calendarFormat = format;
|
||||
});
|
||||
},
|
||||
eventLoader: _getEventsForDay,
|
||||
calendarBuilders: CalendarBuilders(
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
|
||||
final calendarEvents = events.cast<_CalendarEvent>();
|
||||
final confirmedVisits = calendarEvents.where(
|
||||
(e) => e.visitRecord?.isConfirmed == true,
|
||||
);
|
||||
final recommendedOnly = calendarEvents.where(
|
||||
(e) => e.recommendationRecord != null,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (recommendedOnly.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: recommendationColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (confirmedVisits.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visitColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
outsideDaysVisible: false,
|
||||
selectedDecoration: const BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
markersMaxCount: 2,
|
||||
markerDecoration: BoxDecoration(
|
||||
color: visitColor.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
weekendTextStyle: const TextStyle(
|
||||
color: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
headerStyle: HeaderStyle(
|
||||
formatButtonVisible: true,
|
||||
titleCentered: true,
|
||||
formatButtonShowsNext: false,
|
||||
formatButtonDecoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
formatButtonTextStyle: const TextStyle(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegend(
|
||||
'추천받음',
|
||||
recommendationColor,
|
||||
isDark,
|
||||
tooltip: '추천 기록이 있는 날',
|
||||
// icon: Icons.star,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildLegend(
|
||||
'방문완료',
|
||||
visitColor,
|
||||
isDark,
|
||||
tooltip: '확정된 방문이 있는 날',
|
||||
// icon: Icons.check_circle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
NativeAdPlaceholder(
|
||||
enabled: adsEnabled,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
height: 360,
|
||||
),
|
||||
_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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend(String label, Color color, bool isDark) {
|
||||
return Row(
|
||||
Widget _buildLegend(
|
||||
String label,
|
||||
Color color,
|
||||
bool isDark, {
|
||||
String? tooltip,
|
||||
IconData? icon,
|
||||
}) {
|
||||
final content = Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
@@ -232,13 +299,25 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(label, style: AppTypography.body2(isDark)),
|
||||
],
|
||||
);
|
||||
|
||||
if (tooltip == null) return content;
|
||||
return Tooltip(message: tooltip, child: content);
|
||||
}
|
||||
|
||||
Widget _buildDayRecords(DateTime day, bool isDark) {
|
||||
final events = _getEventsForDay(day);
|
||||
events.sort((a, b) => b.sortDate.compareTo(a.sortDate));
|
||||
final visitCount = events.where((e) => e.visitRecord != null).length;
|
||||
final recommendationCount = events
|
||||
.where((e) => e.recommendationRecord != null)
|
||||
.length;
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
@@ -274,14 +353,14 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${day.month}월 ${day.day}일 방문 기록',
|
||||
'${day.month}월 ${day.day}일 기록',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${events.length}건',
|
||||
'${events.length}건 · 방문 $visitCount/추천 $recommendationCount',
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -290,22 +369,420 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sortedEvents = events
|
||||
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
if (event.visitRecord != null) {
|
||||
return VisitRecordCard(
|
||||
visitRecord: sortedEvents[index],
|
||||
onTap: () {
|
||||
// TODO: 맛집 상세 페이지로 이동
|
||||
visitRecord: event.visitRecord!,
|
||||
onTap: () =>
|
||||
_showRecordActions(visitRecord: event.visitRecord!),
|
||||
);
|
||||
}
|
||||
if (event.recommendationRecord != null) {
|
||||
return RecommendationRecordCard(
|
||||
recommendation: event.recommendationRecord!,
|
||||
onTap: () => _showRecordActions(
|
||||
recommendationRecord: event.recommendationRecord!,
|
||||
),
|
||||
onConfirmVisit: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.confirmVisit(event.recommendationRecord!.id);
|
||||
},
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.deleteRecommendation(event.recommendationRecord!.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Map<DateTime, List<_CalendarEvent>> _buildEvents(
|
||||
List<VisitRecord> visits,
|
||||
List<RecommendationRecord> recommendations,
|
||||
) {
|
||||
final Map<DateTime, List<_CalendarEvent>> events = {};
|
||||
|
||||
for (final visit in visits) {
|
||||
final day = DateTime(
|
||||
visit.visitDate.year,
|
||||
visit.visitDate.month,
|
||||
visit.visitDate.day,
|
||||
);
|
||||
events[day] = [
|
||||
...(events[day] ?? []),
|
||||
_CalendarEvent(visitRecord: visit),
|
||||
];
|
||||
}
|
||||
|
||||
for (final reco in recommendations.where((r) => !r.visited)) {
|
||||
final day = DateTime(
|
||||
reco.recommendationDate.year,
|
||||
reco.recommendationDate.month,
|
||||
reco.recommendationDate.day,
|
||||
);
|
||||
events[day] = [
|
||||
...(events[day] ?? []),
|
||||
_CalendarEvent(recommendationRecord: reco),
|
||||
];
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
void _updateCalendarRange(
|
||||
List<VisitRecord> visits,
|
||||
List<RecommendationRecord> recommendations,
|
||||
) {
|
||||
final range = _calculateCalendarRange(visits, recommendations);
|
||||
final clampedFocused = _clampDate(
|
||||
_focusedDay,
|
||||
range.firstDay,
|
||||
range.lastDay,
|
||||
);
|
||||
final clampedSelected = _clampDate(
|
||||
_selectedDay,
|
||||
range.firstDay,
|
||||
range.lastDay,
|
||||
);
|
||||
final updatedMonth = DateTime(clampedFocused.year, clampedFocused.month, 1);
|
||||
|
||||
final hasRangeChanged =
|
||||
!_isSameDate(_firstDay, range.firstDay) ||
|
||||
!_isSameDate(_lastDay, range.lastDay);
|
||||
final hasFocusChanged = !isSameDay(_focusedDay, clampedFocused);
|
||||
final hasSelectedChanged = !isSameDay(_selectedDay, clampedSelected);
|
||||
final hasMonthChanged = !_isSameMonth(_selectedMonth, updatedMonth);
|
||||
|
||||
if (hasRangeChanged ||
|
||||
hasFocusChanged ||
|
||||
hasSelectedChanged ||
|
||||
hasMonthChanged) {
|
||||
setState(() {
|
||||
_firstDay = range.firstDay;
|
||||
_lastDay = range.lastDay;
|
||||
_focusedDay = clampedFocused;
|
||||
_selectedDay = clampedSelected;
|
||||
_selectedMonth = updatedMonth;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
({DateTime firstDay, DateTime lastDay}) _calculateCalendarRange(
|
||||
List<VisitRecord> visits,
|
||||
List<RecommendationRecord> recommendations,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final dates = <DateTime>[
|
||||
...visits.map(
|
||||
(visit) => DateTime(
|
||||
visit.visitDate.year,
|
||||
visit.visitDate.month,
|
||||
visit.visitDate.day,
|
||||
),
|
||||
),
|
||||
...recommendations.map(
|
||||
(reco) => DateTime(
|
||||
reco.recommendationDate.year,
|
||||
reco.recommendationDate.month,
|
||||
reco.recommendationDate.day,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (dates.isEmpty) {
|
||||
return (
|
||||
firstDay: DateTime(now.year - 1, now.month, 1),
|
||||
lastDay: DateTime(now.year + 1, now.month, now.day),
|
||||
);
|
||||
}
|
||||
|
||||
final earliest = dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
final latest = dates.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
final baseLastDay = latest.isAfter(now) ? latest : now;
|
||||
|
||||
return (
|
||||
firstDay: DateTime(earliest.year, earliest.month, earliest.day),
|
||||
lastDay: DateTime(
|
||||
baseLastDay.year + 1,
|
||||
baseLastDay.month,
|
||||
baseLastDay.day,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _clampDate(DateTime date, DateTime min, DateTime max) {
|
||||
if (date.isBefore(min)) return min;
|
||||
if (date.isAfter(max)) return max;
|
||||
return date;
|
||||
}
|
||||
|
||||
bool _isSameDate(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
bool _isSameMonth(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month;
|
||||
|
||||
List<DateTime> _buildMonthOptions(DateTime firstDay, DateTime lastDay) {
|
||||
final months = <DateTime>[];
|
||||
var cursor = DateTime(firstDay.year, firstDay.month, 1);
|
||||
final end = DateTime(lastDay.year, lastDay.month, 1);
|
||||
|
||||
while (!cursor.isAfter(end)) {
|
||||
months.add(cursor);
|
||||
cursor = DateTime(cursor.year, cursor.month + 1, 1);
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
void _onMonthChanged(DateTime month) {
|
||||
final targetMonth = DateTime(month.year, month.month, 1);
|
||||
final clampedMonth = _clampDate(targetMonth, _firstDay, _lastDay);
|
||||
|
||||
setState(() {
|
||||
_focusedDay = clampedMonth;
|
||||
_selectedMonth = clampedMonth;
|
||||
_selectedDay = clampedMonth;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showRecordActions({
|
||||
VisitRecord? visitRecord,
|
||||
RecommendationRecord? recommendationRecord,
|
||||
}) async {
|
||||
final restaurantId =
|
||||
visitRecord?.restaurantId ?? recommendationRecord?.restaurantId;
|
||||
if (restaurantId == null) return;
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
builder: (sheetContext) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final restaurantAsync = ref.watch(restaurantProvider(restaurantId));
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
if (restaurant == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final visitTime = visitRecord?.visitDate;
|
||||
final recoTime = recommendationRecord?.recommendationDate;
|
||||
final isVisitConfirmed = visitRecord?.isConfirmed == true;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading2(isDark),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('주소', restaurant.roadAddress, isDark),
|
||||
if (visitTime != null)
|
||||
_buildInfoRow(
|
||||
isVisitConfirmed ? '방문 완료' : '방문 예정',
|
||||
_formatFullDateTime(visitTime),
|
||||
isDark,
|
||||
),
|
||||
if (recoTime != null)
|
||||
_buildInfoRow(
|
||||
'추천 시각',
|
||||
_formatFullDateTime(recoTime),
|
||||
isDark,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _showRestaurantDetailDialog(
|
||||
context,
|
||||
isDark,
|
||||
restaurant,
|
||||
),
|
||||
icon: const Icon(Icons.info_outline),
|
||||
label: const Text('식당 정보'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) =>
|
||||
EditRestaurantDialog(
|
||||
restaurant: restaurant,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('맛집 수정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (visitRecord != null && !isVisitConfirmed)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(visitNotifierProvider.notifier)
|
||||
.confirmVisit(visitRecord.id);
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text('방문 확인'),
|
||||
),
|
||||
),
|
||||
if (recommendationRecord != null)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.confirmVisit(recommendationRecord.id);
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
icon: const Icon(Icons.playlist_add_check),
|
||||
label: const Text('추천 방문 기록으로 저장'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 160,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (_, __) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'맛집 정보를 불러올 수 없습니다.',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value, bool isDark) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(label, style: AppTypography.caption(isDark)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(value, style: AppTypography.body2(isDark))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRestaurantDetailDialog(
|
||||
BuildContext context,
|
||||
bool isDark,
|
||||
Restaurant restaurant,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightSurface,
|
||||
title: Text(restaurant.name),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
'카테고리',
|
||||
'${restaurant.category} > ${restaurant.subCategory}',
|
||||
isDark,
|
||||
),
|
||||
if (restaurant.phoneNumber != null)
|
||||
_buildInfoRow('전화번호', restaurant.phoneNumber!, isDark),
|
||||
_buildInfoRow('도로명', restaurant.roadAddress, isDark),
|
||||
_buildInfoRow('지번', restaurant.jibunAddress, isDark),
|
||||
if (restaurant.description != null &&
|
||||
restaurant.description!.isNotEmpty)
|
||||
_buildInfoRow('메모', restaurant.description!, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFullDateTime(DateTime dateTime) {
|
||||
final month = dateTime.month.toString().padLeft(2, '0');
|
||||
final day = dateTime.day.toString().padLeft(2, '0');
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '${dateTime.year}-$month-$day $hour:$minute';
|
||||
}
|
||||
|
||||
Color _visitMarkerColor(bool isDark) =>
|
||||
isDark ? AppColors.darkPrimary : AppColors.lightPrimary;
|
||||
|
||||
Color _recommendationMarkerColor(bool isDark) =>
|
||||
isDark ? AppColors.darkWarning : AppColors.lightWarning;
|
||||
}
|
||||
|
||||
class _CalendarEvent {
|
||||
final VisitRecord? visitRecord;
|
||||
final RecommendationRecord? recommendationRecord;
|
||||
|
||||
_CalendarEvent({this.visitRecord, this.recommendationRecord});
|
||||
|
||||
DateTime get sortDate =>
|
||||
visitRecord?.visitDate ?? recommendationRecord!.recommendationDate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/presentation/providers/debug_test_data_provider.dart';
|
||||
|
||||
class DebugTestDataBanner extends ConsumerWidget {
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const DebugTestDataBanner({super.key, this.margin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!kDebugMode) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final state = ref.watch(debugTestDataNotifierProvider);
|
||||
final notifier = ref.read(debugTestDataNotifierProvider.notifier);
|
||||
|
||||
return Card(
|
||||
margin: margin ?? const EdgeInsets.all(16),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.science_outlined,
|
||||
color: AppColors.lightPrimary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'테스트 데이터 미리보기',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.isProcessing)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch.adaptive(
|
||||
value: state.isEnabled,
|
||||
onChanged: state.isProcessing
|
||||
? null
|
||||
: (value) async {
|
||||
if (value) {
|
||||
await notifier.enableTestData();
|
||||
} else {
|
||||
await notifier.disableTestData();
|
||||
}
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.isEnabled
|
||||
? '디버그 빌드에서만 적용됩니다. 기록/통계 UI를 테스트용 데이터로 확인하세요.'
|
||||
: '디버그 빌드에서만 사용 가능합니다. 스위치를 켜면 추천·방문 기록이 자동으로 채워집니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: AppColors.lightError,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
class RecommendationRecordCard extends ConsumerWidget {
|
||||
final RecommendationRecord recommendation;
|
||||
final VoidCallback onConfirmVisit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const RecommendationRecordCard({
|
||||
super.key,
|
||||
required this.recommendation,
|
||||
required this.onConfirmVisit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final restaurantAsync = ref.watch(
|
||||
restaurantProvider(recommendation.restaurantId),
|
||||
);
|
||||
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
if (restaurant == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.whatshot,
|
||||
color: Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(recommendation.recommendationDate),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
softWrap: true,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.visible,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: onConfirmVisit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: const Text('방문 확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
height: 1,
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,21 @@ import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||
|
||||
class VisitStatistics extends ConsumerWidget {
|
||||
final DateTime selectedMonth;
|
||||
final List<DateTime> availableMonths;
|
||||
final void Function(DateTime month) onMonthChanged;
|
||||
final bool adsEnabled;
|
||||
|
||||
const VisitStatistics({super.key, required this.selectedMonth});
|
||||
const VisitStatistics({
|
||||
super.key,
|
||||
required this.selectedMonth,
|
||||
required this.availableMonths,
|
||||
required this.onMonthChanged,
|
||||
this.adsEnabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -21,6 +31,12 @@ class VisitStatistics extends ConsumerWidget {
|
||||
month: selectedMonth.month,
|
||||
)),
|
||||
);
|
||||
final monthlyCategoryStatsAsync = ref.watch(
|
||||
monthlyCategoryVisitStatsProvider((
|
||||
year: selectedMonth.year,
|
||||
month: selectedMonth.month,
|
||||
)),
|
||||
);
|
||||
|
||||
// 자주 방문한 맛집
|
||||
final frequentRestaurantsAsync = ref.watch(frequentRestaurantsProvider);
|
||||
@@ -33,7 +49,14 @@ class VisitStatistics extends ConsumerWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
// 이번 달 통계
|
||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
||||
_buildMonthlyStats(
|
||||
monthlyStatsAsync,
|
||||
monthlyCategoryStatsAsync,
|
||||
isDark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
NativeAdPlaceholder(height: 360, enabled: adsEnabled),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주간 통계 차트
|
||||
@@ -49,8 +72,11 @@ class VisitStatistics extends ConsumerWidget {
|
||||
|
||||
Widget _buildMonthlyStats(
|
||||
AsyncValue<Map<String, int>> statsAsync,
|
||||
AsyncValue<Map<String, int>> categoryStatsAsync,
|
||||
bool isDark,
|
||||
) {
|
||||
final monthList = _normalizeMonths();
|
||||
|
||||
return Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
@@ -60,10 +86,7 @@ class VisitStatistics extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${selectedMonth.month}월 방문 통계',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
_buildMonthSelector(monthList, isDark),
|
||||
const SizedBox(height: 16),
|
||||
statsAsync.when(
|
||||
data: (stats) {
|
||||
@@ -71,9 +94,17 @@ class VisitStatistics extends ConsumerWidget {
|
||||
0,
|
||||
(sum, count) => sum + count,
|
||||
);
|
||||
final categoryCounts =
|
||||
stats.entries.where((e) => !e.key.contains('/')).toList()
|
||||
final categoryCounts = categoryStatsAsync.maybeWhen(
|
||||
data: (data) {
|
||||
final entries = data.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
return entries;
|
||||
},
|
||||
orElse: () => <MapEntry<String, int>>[],
|
||||
);
|
||||
final topCategory = categoryCounts.isNotEmpty
|
||||
? categoryCounts.first
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -85,12 +116,21 @@ class VisitStatistics extends ConsumerWidget {
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (categoryCounts.isNotEmpty) ...[
|
||||
if (topCategory != null) ...[
|
||||
_buildStatItem(
|
||||
icon: Icons.favorite,
|
||||
label: '가장 많이 간 카테고리',
|
||||
value:
|
||||
'${categoryCounts.first.key} (${categoryCounts.first.value}회)',
|
||||
value: '${topCategory.key} (${topCategory.value}회)',
|
||||
color: AppColors.lightSecondary,
|
||||
isDark: isDark,
|
||||
),
|
||||
] else ...[
|
||||
_buildStatItem(
|
||||
icon: Icons.favorite_border,
|
||||
label: '가장 많이 간 카테고리',
|
||||
value: categoryStatsAsync.isLoading
|
||||
? '집계 중...'
|
||||
: '데이터 없음',
|
||||
color: AppColors.lightSecondary,
|
||||
isDark: isDark,
|
||||
),
|
||||
@@ -130,7 +170,7 @@ class VisitStatistics extends ConsumerWidget {
|
||||
: stats.values.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
height: 140,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -325,4 +365,79 @@ class VisitStatistics extends ConsumerWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthSelector(List<DateTime> months, bool isDark) {
|
||||
final currentMonth = DateTime(selectedMonth.year, selectedMonth.month, 1);
|
||||
final monthIndex = months.indexWhere(
|
||||
(month) => _isSameMonth(month, currentMonth),
|
||||
);
|
||||
final resolvedIndex = monthIndex == -1 ? 0 : monthIndex;
|
||||
final hasPrevious = resolvedIndex < months.length - 1;
|
||||
final hasNext = resolvedIndex > 0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${_formatMonth(currentMonth)} 방문 통계',
|
||||
style: AppTypography.heading2(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: hasPrevious
|
||||
? () => onMonthChanged(months[resolvedIndex + 1])
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton<DateTime>(
|
||||
value: months[resolvedIndex],
|
||||
onChanged: (month) {
|
||||
if (month != null) {
|
||||
onMonthChanged(month);
|
||||
}
|
||||
},
|
||||
items: months
|
||||
.map(
|
||||
(month) => DropdownMenuItem(
|
||||
value: month,
|
||||
child: Text(_formatMonth(month)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: hasNext
|
||||
? () => onMonthChanged(months[resolvedIndex - 1])
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<DateTime> _normalizeMonths() {
|
||||
final normalized =
|
||||
availableMonths
|
||||
.map((month) => DateTime(month.year, month.month, 1))
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort((a, b) => b.compareTo(a));
|
||||
|
||||
final currentMonth = DateTime(selectedMonth.year, selectedMonth.month, 1);
|
||||
final exists = normalized.any((month) => _isSameMonth(month, currentMonth));
|
||||
if (!exists) {
|
||||
normalized.insert(0, currentMonth);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
bool _isSameMonth(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month;
|
||||
|
||||
String _formatMonth(DateTime month) =>
|
||||
'${month.year}.${month.month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -39,6 +39,17 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MainScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialTab != widget.initialTab &&
|
||||
_selectedIndex != widget.initialTab) {
|
||||
setState(() {
|
||||
_selectedIndex = widget.initialTab;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NotificationService.onNotificationTap = null;
|
||||
|
||||
@@ -1,215 +1,306 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
class RecommendationResultDialog extends StatelessWidget {
|
||||
enum RecommendationDialogResult { confirm, reroll, autoConfirm }
|
||||
|
||||
class RecommendationResultDialog extends StatefulWidget {
|
||||
final Restaurant restaurant;
|
||||
final Future<void> Function() onReroll;
|
||||
final Future<void> Function() onClose;
|
||||
final Duration autoConfirmDuration;
|
||||
final double? currentLatitude;
|
||||
final double? currentLongitude;
|
||||
|
||||
const RecommendationResultDialog({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
required this.onReroll,
|
||||
required this.onClose,
|
||||
this.autoConfirmDuration = const Duration(seconds: 12),
|
||||
this.currentLatitude,
|
||||
this.currentLongitude,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecommendationResultDialog> createState() =>
|
||||
_RecommendationResultDialogState();
|
||||
}
|
||||
|
||||
class _RecommendationResultDialogState
|
||||
extends State<RecommendationResultDialog> {
|
||||
Timer? _autoConfirmTimer;
|
||||
bool _didComplete = false;
|
||||
double? _distanceKm;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calculateDistance();
|
||||
_startAutoConfirmTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoConfirmTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoConfirmTimer() {
|
||||
_autoConfirmTimer = Timer(widget.autoConfirmDuration, () {
|
||||
if (!mounted || _didComplete) return;
|
||||
_didComplete = true;
|
||||
Navigator.of(context).pop(RecommendationDialogResult.autoConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
void _calculateDistance() {
|
||||
final lat = widget.currentLatitude;
|
||||
final lon = widget.currentLongitude;
|
||||
if (lat == null || lon == null) return;
|
||||
|
||||
_distanceKm = DistanceCalculator.calculateDistance(
|
||||
lat1: lat,
|
||||
lon1: lon,
|
||||
lat2: widget.restaurant.latitude,
|
||||
lon2: widget.restaurant.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDistance(double distanceKm) {
|
||||
final meters = distanceKm * 1000;
|
||||
if (meters >= 1000) {
|
||||
return '${distanceKm.toStringAsFixed(1)} km';
|
||||
}
|
||||
return '${meters.round()} m';
|
||||
}
|
||||
|
||||
Future<void> _handleResult(RecommendationDialogResult result) async {
|
||||
if (_didComplete) return;
|
||||
_didComplete = true;
|
||||
_autoConfirmTimer?.cancel();
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 상단 이미지 영역
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
await _handleResult(RecommendationDialogResult.confirm);
|
||||
return true;
|
||||
},
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 맛집 정보
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 가게 이름
|
||||
Center(
|
||||
child: Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 카테고리
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${restaurant.category} > ${restaurant.subCategory}',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.confirm,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주소
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
widget.restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
'${widget.restaurant.category} > ${widget.restaurant.subCategory}',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 버튼들
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await onReroll();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(
|
||||
if (_distanceKm != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.place,
|
||||
size: 20,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatDistance(_distanceKm!),
|
||||
style: AppTypography.body2(isDark).copyWith(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (widget.restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.reroll,
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.confirm,
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('오늘의 선택!'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'앱을 종료하면 자동으로 선택이 확정됩니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../view_models/add_restaurant_view_model.dart';
|
||||
import 'widgets/add_restaurant_form.dart';
|
||||
|
||||
@@ -121,6 +122,12 @@ class _ManualRestaurantInputScreenState
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final state = ref.watch(addRestaurantViewModelProvider);
|
||||
final categories = ref
|
||||
.watch(categoriesProvider)
|
||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
||||
final subCategories = ref
|
||||
.watch(subCategoriesProvider)
|
||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -150,6 +157,9 @@ class _ManualRestaurantInputScreenState
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
onFieldChanged: _onFieldChanged,
|
||||
categories: categories,
|
||||
subCategories: subCategories,
|
||||
geocodingStatus: state.geocodingStatus,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
|
||||
@@ -2,8 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../core/utils/category_mapper.dart';
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../widgets/category_selector.dart';
|
||||
import '../../widgets/native_ad_placeholder.dart';
|
||||
import 'manual_restaurant_input_screen.dart';
|
||||
import 'widgets/restaurant_card.dart';
|
||||
import 'widgets/add_restaurant_dialog.dart';
|
||||
@@ -31,11 +35,11 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final restaurantsAsync = ref.watch(
|
||||
searchQuery.isNotEmpty || selectedCategory != null
|
||||
? filteredRestaurantsProvider
|
||||
: restaurantListProvider,
|
||||
);
|
||||
final screenshotModeEnabled = ref
|
||||
.watch(screenshotModeEnabledProvider)
|
||||
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
@@ -103,21 +107,91 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
// 맛집 목록
|
||||
Expanded(
|
||||
child: restaurantsAsync.when(
|
||||
data: (restaurants) {
|
||||
if (restaurants.isEmpty) {
|
||||
data: (restaurantsData) {
|
||||
AppLogger.debug(
|
||||
'[restaurant_list_ui] data received, filtered=$isFiltered',
|
||||
);
|
||||
var items = restaurantsData;
|
||||
|
||||
if (isFiltered) {
|
||||
// 검색 필터
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final lowercaseQuery = searchQuery.toLowerCase();
|
||||
items = items.where((item) {
|
||||
final r = item.restaurant;
|
||||
return r.name.toLowerCase().contains(lowercaseQuery) ||
|
||||
(r.description?.toLowerCase().contains(
|
||||
lowercaseQuery,
|
||||
) ??
|
||||
false) ||
|
||||
r.category.toLowerCase().contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (selectedCategory != null) {
|
||||
items = items.where((item) {
|
||||
final r = item.restaurant;
|
||||
return r.category == selectedCategory ||
|
||||
r.category.contains(selectedCategory) ||
|
||||
CategoryMapper.normalizeNaverCategory(
|
||||
r.category,
|
||||
r.subCategory,
|
||||
) ==
|
||||
selectedCategory ||
|
||||
CategoryMapper.getDisplayName(r.category) ==
|
||||
selectedCategory;
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
return _buildEmptyState(isDark);
|
||||
}
|
||||
|
||||
const adInterval = 6; // 5리스트 후 1광고
|
||||
const adOffset = 5; // 1~5 리스트 이후 6 광고 시작
|
||||
final adCount = (items.length ~/ adOffset);
|
||||
final totalCount = items.length + adCount;
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: restaurants.length,
|
||||
itemCount: totalCount,
|
||||
itemBuilder: (context, index) {
|
||||
return RestaurantCard(restaurant: restaurants[index]);
|
||||
final isAdIndex =
|
||||
index >= adOffset &&
|
||||
(index - adOffset) % adInterval == 0;
|
||||
if (isAdIndex) {
|
||||
return NativeAdPlaceholder(
|
||||
enabled: !screenshotModeEnabled,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
height: 200, // 카드 높이와 비슷한 중간 사이즈
|
||||
);
|
||||
}
|
||||
|
||||
final adsBefore = index < adOffset
|
||||
? 0
|
||||
: ((index - adOffset) ~/ adInterval) + 1;
|
||||
final itemIndex = index - adsBefore;
|
||||
final item = items[itemIndex];
|
||||
|
||||
return RestaurantCard(
|
||||
restaurant: item.restaurant,
|
||||
distanceKm: item.distanceKm,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.lightPrimary),
|
||||
),
|
||||
loading: () {
|
||||
AppLogger.debug('[restaurant_list_ui] loading...');
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -241,25 +315,6 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
_addByNaverLink();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
title: const Text('상호명으로 검색'),
|
||||
subtitle: const Text('가게 이름으로 검색하여 추가'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addBySearch();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -292,14 +347,6 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBySearch() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const AddRestaurantDialog(mode: AddRestaurantDialogMode.search),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addManually() async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ManualRestaurantInputScreen()),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../services/restaurant_form_validator.dart';
|
||||
|
||||
/// 식당 추가 폼 위젯
|
||||
class AddRestaurantForm extends StatelessWidget {
|
||||
class AddRestaurantForm extends StatefulWidget {
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController categoryController;
|
||||
@@ -14,6 +15,9 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
final TextEditingController latitudeController;
|
||||
final TextEditingController longitudeController;
|
||||
final Function(String) onFieldChanged;
|
||||
final List<String> categories;
|
||||
final List<String> subCategories;
|
||||
final String geocodingStatus;
|
||||
|
||||
const AddRestaurantForm({
|
||||
super.key,
|
||||
@@ -28,18 +32,76 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
required this.latitudeController,
|
||||
required this.longitudeController,
|
||||
required this.onFieldChanged,
|
||||
this.categories = const <String>[],
|
||||
this.subCategories = const <String>[],
|
||||
this.geocodingStatus = '',
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddRestaurantForm> createState() => _AddRestaurantFormState();
|
||||
}
|
||||
|
||||
class _AddRestaurantFormState extends State<AddRestaurantForm> {
|
||||
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 = {...widget.categories};
|
||||
_availableSubCategories = {...widget.subCategories};
|
||||
final currentCategory = widget.categoryController.text.trim();
|
||||
if (currentCategory.isNotEmpty) {
|
||||
_availableCategories.add(currentCategory);
|
||||
}
|
||||
final currentSubCategory = widget.subCategoryController.text.trim();
|
||||
if (currentSubCategory.isNotEmpty) {
|
||||
_availableSubCategories.add(currentSubCategory);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AddRestaurantForm oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!setEquals(oldWidget.categories.toSet(), widget.categories.toSet())) {
|
||||
setState(() {
|
||||
_availableCategories = {...widget.categories, ..._availableCategories};
|
||||
});
|
||||
}
|
||||
if (!setEquals(
|
||||
oldWidget.subCategories.toSet(),
|
||||
widget.subCategories.toSet(),
|
||||
)) {
|
||||
setState(() {
|
||||
_availableSubCategories = {
|
||||
...widget.subCategories,
|
||||
..._availableSubCategories,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_categoryFocusNode.dispose();
|
||||
_subCategoryFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: formKey,
|
||||
key: widget.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 가게 이름
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
controller: widget.nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '가게 이름 *',
|
||||
hintText: '예: 맛있는 한식당',
|
||||
@@ -48,7 +110,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '가게 이름을 입력해주세요';
|
||||
@@ -61,43 +123,16 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
// 카테고리
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: categoryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '카테고리 *',
|
||||
hintText: '예: 한식',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
validator: (value) =>
|
||||
RestaurantFormValidator.validateCategory(value),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildCategoryField(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: subCategoryController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '세부 카테고리',
|
||||
hintText: '예: 갈비',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildSubCategoryField(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 설명
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
controller: widget.descriptionController,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: '설명',
|
||||
@@ -107,13 +142,13 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 전화번호
|
||||
TextFormField(
|
||||
controller: phoneController,
|
||||
controller: widget.phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: '전화번호',
|
||||
@@ -123,7 +158,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) =>
|
||||
RestaurantFormValidator.validatePhoneNumber(value),
|
||||
),
|
||||
@@ -131,7 +166,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
|
||||
// 도로명 주소
|
||||
TextFormField(
|
||||
controller: roadAddressController,
|
||||
controller: widget.roadAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '도로명 주소 *',
|
||||
hintText: '예: 서울시 중구 세종대로 110',
|
||||
@@ -140,7 +175,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) =>
|
||||
RestaurantFormValidator.validateAddress(value),
|
||||
),
|
||||
@@ -148,7 +183,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
|
||||
// 지번 주소
|
||||
TextFormField(
|
||||
controller: jibunAddressController,
|
||||
controller: widget.jibunAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '지번 주소',
|
||||
hintText: '예: 서울시 중구 태평로1가 31',
|
||||
@@ -157,7 +192,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -166,7 +201,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: latitudeController,
|
||||
controller: widget.latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
@@ -178,7 +213,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final latitude = double.tryParse(value);
|
||||
@@ -193,7 +228,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: longitudeController,
|
||||
controller: widget.longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
@@ -205,7 +240,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final longitude = double.tryParse(value);
|
||||
@@ -222,15 +257,206 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.geocodingStatus.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.geocodingStatus,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.blueGrey,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 hasExactMatch = _availableCategories.any(
|
||||
(c) => c.toLowerCase() == lowerQuery,
|
||||
);
|
||||
if (!hasExactMatch) {
|
||||
matches.insert(0, query);
|
||||
}
|
||||
|
||||
return matches;
|
||||
},
|
||||
displayStringForOption: (option) => option,
|
||||
onSelected: (option) {
|
||||
final normalized = option.trim();
|
||||
widget.categoryController.text = normalized;
|
||||
if (normalized.isNotEmpty) {
|
||||
setState(() {
|
||||
_availableCategories.add(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: (value) =>
|
||||
RestaurantFormValidator.validateCategory(value),
|
||||
);
|
||||
},
|
||||
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 isNewEntry = !_availableCategories.contains(option);
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
isNewEntry ? '새 카테고리 추가: $option' : option,
|
||||
style: TextStyle(
|
||||
fontWeight: isNewEntry ? 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 hasExactMatch = _availableSubCategories.any(
|
||||
(c) => c.toLowerCase() == lowerQuery,
|
||||
);
|
||||
if (!hasExactMatch && query.isNotEmpty) {
|
||||
matches.insert(0, query);
|
||||
}
|
||||
|
||||
return matches;
|
||||
},
|
||||
displayStringForOption: (option) => option,
|
||||
onSelected: (option) {
|
||||
final normalized = option.trim();
|
||||
widget.subCategoryController.text = normalized;
|
||||
if (normalized.isNotEmpty) {
|
||||
setState(() {
|
||||
_availableSubCategories.add(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 isNewEntry = !_availableSubCategories.contains(option);
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
isNewEntry ? '새 세부 카테고리 추가: $option' : option,
|
||||
style: TextStyle(
|
||||
fontWeight: isNewEntry ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
onTap: () => onSelected(option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkPrimary
|
||||
: AppColors.lightPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'네이버 지도에서 맛집 정보 가져오기',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Row(
|
||||
// children: [
|
||||
// Icon(
|
||||
// Icons.info_outline,
|
||||
// size: 20,
|
||||
// color: isDark
|
||||
// ? AppColors.darkPrimary
|
||||
// : AppColors.lightPrimary,
|
||||
// ),
|
||||
// const SizedBox(width: 8),
|
||||
// Text(
|
||||
// '네이버 지도에서 맛집 정보 가져오기',
|
||||
// style: AppTypography.body1(
|
||||
// isDark,
|
||||
// ).copyWith(fontWeight: FontWeight.bold),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
||||
@@ -71,6 +71,9 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
// URL 입력 필드
|
||||
TextField(
|
||||
controller: urlController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: '네이버 지도 URL',
|
||||
hintText: kIsWeb
|
||||
@@ -79,6 +82,7 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
errorText: errorMessage,
|
||||
errorMaxLines: 8,
|
||||
),
|
||||
onSubmitted: (_) => onFetchPressed(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
|
||||
import 'add_restaurant_form.dart';
|
||||
|
||||
/// 기존 맛집 정보를 편집하는 다이얼로그
|
||||
class EditRestaurantDialog extends ConsumerStatefulWidget {
|
||||
final Restaurant restaurant;
|
||||
|
||||
const EditRestaurantDialog({super.key, required this.restaurant});
|
||||
|
||||
@override
|
||||
ConsumerState<EditRestaurantDialog> createState() =>
|
||||
_EditRestaurantDialogState();
|
||||
}
|
||||
|
||||
class _EditRestaurantDialogState extends ConsumerState<EditRestaurantDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _categoryController;
|
||||
late final TextEditingController _subCategoryController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _roadAddressController;
|
||||
late final TextEditingController _jibunAddressController;
|
||||
late final TextEditingController _latitudeController;
|
||||
late final TextEditingController _longitudeController;
|
||||
|
||||
bool _isSaving = false;
|
||||
late final String _originalRoadAddress;
|
||||
late final String _originalJibunAddress;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final restaurant = widget.restaurant;
|
||||
_nameController = TextEditingController(text: restaurant.name);
|
||||
_categoryController = TextEditingController(text: restaurant.category);
|
||||
_subCategoryController = TextEditingController(
|
||||
text: restaurant.subCategory,
|
||||
);
|
||||
_descriptionController = TextEditingController(
|
||||
text: restaurant.description ?? '',
|
||||
);
|
||||
_phoneController = TextEditingController(
|
||||
text: restaurant.phoneNumber ?? '',
|
||||
);
|
||||
_roadAddressController = TextEditingController(
|
||||
text: restaurant.roadAddress,
|
||||
);
|
||||
_jibunAddressController = TextEditingController(
|
||||
text: restaurant.jibunAddress,
|
||||
);
|
||||
_latitudeController = TextEditingController(
|
||||
text: restaurant.latitude.toString(),
|
||||
);
|
||||
_longitudeController = TextEditingController(
|
||||
text: restaurant.longitude.toString(),
|
||||
);
|
||||
_originalRoadAddress = restaurant.roadAddress;
|
||||
_originalJibunAddress = restaurant.jibunAddress;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_categoryController.dispose();
|
||||
_subCategoryController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_phoneController.dispose();
|
||||
_roadAddressController.dispose();
|
||||
_jibunAddressController.dispose();
|
||||
_latitudeController.dispose();
|
||||
_longitudeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFieldChanged(String _) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
final addressChanged =
|
||||
_roadAddressController.text.trim() != _originalRoadAddress ||
|
||||
_jibunAddressController.text.trim() != _originalJibunAddress;
|
||||
final coords = await _resolveCoordinates(
|
||||
latitudeText: _latitudeController.text.trim(),
|
||||
longitudeText: _longitudeController.text.trim(),
|
||||
roadAddress: _roadAddressController.text.trim(),
|
||||
jibunAddress: _jibunAddressController.text.trim(),
|
||||
forceRecalculate: addressChanged,
|
||||
);
|
||||
|
||||
_latitudeController.text = coords.latitude.toString();
|
||||
_longitudeController.text = coords.longitude.toString();
|
||||
|
||||
final updatedRestaurant = widget.restaurant.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
category: _categoryController.text.trim(),
|
||||
subCategory: _subCategoryController.text.trim().isEmpty
|
||||
? _categoryController.text.trim()
|
||||
: _subCategoryController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
phoneNumber: _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim(),
|
||||
roadAddress: _roadAddressController.text.trim(),
|
||||
jibunAddress: _jibunAddressController.text.trim().isEmpty
|
||||
? _roadAddressController.text.trim()
|
||||
: _jibunAddressController.text.trim(),
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
updatedAt: DateTime.now(),
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
);
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.updateRestaurant(updatedRestaurant);
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop(true);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: const [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('맛집 정보가 업데이트되었습니다'),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('수정에 실패했습니다: $e'),
|
||||
backgroundColor: AppColors.lightError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final categories = ref
|
||||
.watch(categoriesProvider)
|
||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
||||
final subCategories = ref
|
||||
.watch(subCategoriesProvider)
|
||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'맛집 정보 수정',
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AddRestaurantForm(
|
||||
formKey: _formKey,
|
||||
nameController: _nameController,
|
||||
categoryController: _categoryController,
|
||||
subCategoryController: _subCategoryController,
|
||||
descriptionController: _descriptionController,
|
||||
phoneController: _phoneController,
|
||||
roadAddressController: _roadAddressController,
|
||||
jibunAddressController: _jibunAddressController,
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
onFieldChanged: _onFieldChanged,
|
||||
categories: categories,
|
||||
subCategories: subCategories,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<({double latitude, double longitude, bool usedCurrentLocation})>
|
||||
_resolveCoordinates({
|
||||
required String latitudeText,
|
||||
required String longitudeText,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
bool forceRecalculate = false,
|
||||
}) async {
|
||||
if (!forceRecalculate) {
|
||||
final parsedLat = double.tryParse(latitudeText);
|
||||
final parsedLon = double.tryParse(longitudeText);
|
||||
if (parsedLat != null && parsedLon != null) {
|
||||
return (
|
||||
latitude: parsedLat,
|
||||
longitude: parsedLon,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final geocodingService = ref.read(geocodingServiceProvider);
|
||||
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
|
||||
if (address.isNotEmpty) {
|
||||
final result = await geocodingService.geocode(address);
|
||||
if (result != null) {
|
||||
return (
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final position = await ref.read(currentLocationProvider.future);
|
||||
if (position != null) {
|
||||
return (
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final fallback = geocodingService.defaultCoordinates();
|
||||
return (
|
||||
latitude: fallback.latitude,
|
||||
longitude: fallback.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_typography.dart';
|
||||
import '../../../services/restaurant_form_validator.dart';
|
||||
|
||||
class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
class FetchedRestaurantJsonView extends StatefulWidget {
|
||||
final bool isDark;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController categoryController;
|
||||
@@ -34,17 +34,59 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
required this.onFieldChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<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
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
color: widget.isDark
|
||||
? AppColors.darkBackground
|
||||
: AppColors.lightBackground.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -57,78 +99,55 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
Text(
|
||||
'가져온 정보',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
widget.isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'{',
|
||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'name',
|
||||
controller: nameController,
|
||||
label: '상호',
|
||||
controller: widget.nameController,
|
||||
icon: Icons.store,
|
||||
validator: (value) =>
|
||||
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'category',
|
||||
controller: categoryController,
|
||||
icon: Icons.category,
|
||||
validator: RestaurantFormValidator.validateCategory,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'subCategory',
|
||||
controller: subCategoryController,
|
||||
icon: Icons.label_outline,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'description',
|
||||
controller: descriptionController,
|
||||
icon: Icons.description,
|
||||
maxLines: 2,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'phoneNumber',
|
||||
controller: phoneController,
|
||||
icon: Icons.phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'roadAddress',
|
||||
controller: roadAddressController,
|
||||
label: '도로명 주소',
|
||||
controller: widget.roadAddressController,
|
||||
icon: Icons.location_on,
|
||||
validator: RestaurantFormValidator.validateAddress,
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'jibunAddress',
|
||||
controller: jibunAddressController,
|
||||
label: '지번 주소',
|
||||
controller: widget.jibunAddressController,
|
||||
icon: Icons.map,
|
||||
),
|
||||
_buildCoordinateFields(context),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: 'naverUrl',
|
||||
controller: naverUrlController,
|
||||
icon: Icons.link,
|
||||
monospace: true,
|
||||
label: '전화번호',
|
||||
controller: widget.phoneController,
|
||||
icon: Icons.phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'}',
|
||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildCategoryField(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _buildSubCategoryField(context)),
|
||||
],
|
||||
),
|
||||
_buildJsonField(
|
||||
context,
|
||||
label: '설명',
|
||||
controller: widget.descriptionController,
|
||||
icon: Icons.description,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -145,7 +164,7 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
children: const [
|
||||
Icon(Icons.my_location, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('coordinates'),
|
||||
Text('좌표'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -153,16 +172,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: latitudeController,
|
||||
controller: widget.latitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'latitude',
|
||||
labelText: '위도',
|
||||
border: border,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '위도를 입력해주세요';
|
||||
@@ -178,16 +197,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: longitudeController,
|
||||
controller: widget.longitudeController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'longitude',
|
||||
labelText: '경도',
|
||||
border: border,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '경도를 입력해주세요';
|
||||
@@ -209,6 +228,170 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryField(BuildContext context) {
|
||||
return RawAutocomplete<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(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
@@ -236,17 +419,18 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onFieldChanged,
|
||||
onChanged: widget.onFieldChanged,
|
||||
validator: validator,
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
|
||||
: null,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,11 +5,13 @@ import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
import 'edit_restaurant_dialog.dart';
|
||||
|
||||
class RestaurantCard extends ConsumerWidget {
|
||||
final Restaurant restaurant;
|
||||
final double? distanceKm;
|
||||
|
||||
const RestaurantCard({super.key, required this.restaurant});
|
||||
const RestaurantCard({super.key, required this.restaurant, this.distanceKm});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -49,41 +51,94 @@ class RestaurantCard extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading2(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory !=
|
||||
restaurant.category) ...[
|
||||
Text(' • ', style: AppTypography.body2(isDark)),
|
||||
Text(
|
||||
restaurant.subCategory,
|
||||
style: AppTypography.body2(isDark),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading2(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory !=
|
||||
restaurant.category) ...[
|
||||
Text(
|
||||
' • ',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
Text(
|
||||
restaurant.subCategory,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 더보기 버튼
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
onPressed: () => _showOptions(context, ref, isDark),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_BadgesRow(
|
||||
distanceKm: distanceKm,
|
||||
needsAddressVerification:
|
||||
restaurant.needsAddressVerification,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 더보기 버튼
|
||||
PopupMenuButton<_RestaurantMenuAction>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
offset: const Offset(0, 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onSelected: (action) =>
|
||||
_handleMenuAction(action, context, ref),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: _RestaurantMenuAction.edit,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, color: AppColors.lightPrimary),
|
||||
SizedBox(width: 8),
|
||||
Text('수정'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _RestaurantMenuAction.delete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: AppColors.lightError),
|
||||
SizedBox(width: 8),
|
||||
Text('삭제'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -240,75 +295,172 @@ class RestaurantCard extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptions(BuildContext context, WidgetRef ref, bool isDark) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
void _handleMenuAction(
|
||||
_RestaurantMenuAction action,
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
switch (action) {
|
||||
case _RestaurantMenuAction.edit:
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => EditRestaurantDialog(restaurant: restaurant),
|
||||
);
|
||||
break;
|
||||
case _RestaurantMenuAction.delete:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('맛집 삭제'),
|
||||
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'삭제',
|
||||
style: TextStyle(color: AppColors.lightError),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit, color: AppColors.lightPrimary),
|
||||
title: const Text('수정'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: 수정 기능 구현
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppColors.lightError),
|
||||
title: const Text('삭제'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('맛집 삭제'),
|
||||
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'삭제',
|
||||
style: TextStyle(color: AppColors.lightError),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DistanceBadge extends StatelessWidget {
|
||||
final double distanceKm;
|
||||
final bool isDark;
|
||||
|
||||
const _DistanceBadge({required this.distanceKm, required this.isDark});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = _formatDistance(distanceKm);
|
||||
final isFar = distanceKm * 1000 >= 2000;
|
||||
final Color bgColor;
|
||||
final Color textColor;
|
||||
|
||||
if (isFar) {
|
||||
bgColor = isDark
|
||||
? AppColors.darkError.withOpacity(0.15)
|
||||
: AppColors.lightError.withOpacity(0.15);
|
||||
textColor = AppColors.lightError;
|
||||
} else {
|
||||
bgColor = isDark
|
||||
? AppColors.darkPrimary.withOpacity(0.12)
|
||||
: AppColors.lightPrimary.withOpacity(0.12);
|
||||
textColor = AppColors.lightPrimary;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.place, size: 16, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.caption(
|
||||
isDark,
|
||||
).copyWith(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDistance(double distanceKm) {
|
||||
final meters = distanceKm * 1000;
|
||||
if (meters >= 2000) {
|
||||
return '2.0km 이상';
|
||||
}
|
||||
if (meters >= 1000) {
|
||||
return '${distanceKm.toStringAsFixed(1)}km';
|
||||
}
|
||||
return '${meters.round()}m';
|
||||
}
|
||||
}
|
||||
|
||||
enum _RestaurantMenuAction { edit, delete }
|
||||
|
||||
class _BadgesRow extends StatelessWidget {
|
||||
final double? distanceKm;
|
||||
final bool needsAddressVerification;
|
||||
final bool isDark;
|
||||
|
||||
const _BadgesRow({
|
||||
required this.distanceKm,
|
||||
required this.needsAddressVerification,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final badges = <Widget>[];
|
||||
if (needsAddressVerification) {
|
||||
badges.add(_AddressVerificationChip(isDark: isDark));
|
||||
}
|
||||
if (distanceKm != null) {
|
||||
badges.add(_DistanceBadge(distanceKm: distanceKm!, isDark: isDark));
|
||||
}
|
||||
|
||||
if (badges.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.end,
|
||||
children: badges,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressVerificationChip extends StatelessWidget {
|
||||
final bool isDark;
|
||||
|
||||
const _AddressVerificationChip({required this.isDark});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isDark
|
||||
? AppColors.darkError.withOpacity(0.12)
|
||||
: AppColors.lightError.withOpacity(0.12);
|
||||
final textColor = isDark ? AppColors.darkError : AppColors.lightError;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 16, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'주소확인',
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../providers/debug_share_preview_provider.dart';
|
||||
import '../../providers/debug_test_data_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -23,6 +26,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
if (kDebugMode) {
|
||||
Future.microtask(
|
||||
() => ref.read(debugTestDataNotifierProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
@@ -46,6 +54,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final screenshotModeAsync = ref.watch(screenshotModeEnabledProvider);
|
||||
final screenshotModeEnabled = screenshotModeAsync.valueOrNull ?? false;
|
||||
final isUpdatingSettings = ref.watch(settingsNotifierProvider).isLoading;
|
||||
final showScreenshotTools = !kReleaseMode;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
@@ -72,49 +84,52 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: ListTile(
|
||||
title: const Text('중복 방문 제외 기간'),
|
||||
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: _daysToExclude > 1
|
||||
? () async {
|
||||
setState(() => _daysToExclude--);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setDaysToExclude(_daysToExclude);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
trailing: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: _daysToExclude > 1
|
||||
? () async {
|
||||
setState(() => _daysToExclude--);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setDaysToExclude(_daysToExclude);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$_daysToExclude일',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.lightPrimary,
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$_daysToExclude일',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () async {
|
||||
setState(() => _daysToExclude++);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setDaysToExclude(_daysToExclude);
|
||||
},
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () async {
|
||||
setState(() => _daysToExclude++);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setDaysToExclude(_daysToExclude);
|
||||
},
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -171,6 +186,29 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
|
||||
FutureBuilder<bool>(
|
||||
future: ref
|
||||
.read(notificationServiceProvider)
|
||||
.canScheduleExactAlarms(),
|
||||
builder: (context, snapshot) {
|
||||
final canExact = snapshot.data;
|
||||
|
||||
// 권한이 이미 허용된 경우 UI 생략
|
||||
if (canExact == true) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _buildPermissionTile(
|
||||
icon: Icons.alarm,
|
||||
title: '정확 알람 권한',
|
||||
subtitle: '정확한 예약 알림을 위해 필요합니다',
|
||||
isGranted: canExact ?? false,
|
||||
onRequest: _requestExactAlarmPermission,
|
||||
isDark: isDark,
|
||||
);
|
||||
},
|
||||
),
|
||||
], isDark),
|
||||
|
||||
// 알림 설정
|
||||
@@ -204,59 +242,62 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
enabled: _notificationEnabled,
|
||||
title: const Text('방문 확인 알림 시간'),
|
||||
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed:
|
||||
_notificationEnabled && _notificationMinutes > 60
|
||||
? () async {
|
||||
setState(() => _notificationMinutes -= 30);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationDelayMinutes(
|
||||
_notificationMinutes,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
trailing: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed:
|
||||
_notificationEnabled && _notificationMinutes > 60
|
||||
? () async {
|
||||
setState(() => _notificationMinutes -= 30);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationDelayMinutes(
|
||||
_notificationMinutes,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}분',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _notificationEnabled
|
||||
? AppColors.lightPrimary
|
||||
: Colors.grey,
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}분',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _notificationEnabled
|
||||
? AppColors.lightPrimary
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed:
|
||||
_notificationEnabled && _notificationMinutes < 360
|
||||
? () async {
|
||||
setState(() => _notificationMinutes += 30);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationDelayMinutes(
|
||||
_notificationMinutes,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed:
|
||||
_notificationEnabled && _notificationMinutes < 360
|
||||
? () async {
|
||||
setState(() => _notificationMinutes += 30);
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setNotificationDelayMinutes(
|
||||
_notificationMinutes,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -310,33 +351,33 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
title: Text('버전'),
|
||||
subtitle: Text('1.0.0'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const ListTile(
|
||||
leading: Icon(
|
||||
Icons.person_outline,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
title: Text('개발자'),
|
||||
subtitle: Text('NatureBridgeAI'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.description_outlined,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
title: const Text('오픈소스 라이센스'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: '오늘 뭐 먹Z?',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationLegalese: '© 2025 NatureBridgeAI',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showScreenshotTools)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile.adaptive(
|
||||
title: const Text('스크린샷 모드'),
|
||||
subtitle: const Text('네이티브 광고를 숨기고 가상의 추천 결과를 표시합니다'),
|
||||
value: screenshotModeEnabled,
|
||||
onChanged:
|
||||
(isUpdatingSettings || screenshotModeAsync.isLoading)
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(settingsNotifierProvider.notifier)
|
||||
.setScreenshotModeEnabled(value);
|
||||
ref.invalidate(screenshotModeEnabledProvider);
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
if (kDebugMode) _buildDebugToolsCard(isDark),
|
||||
], isDark),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
@@ -425,6 +466,94 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestExactAlarmPermission() async {
|
||||
final notificationService = ref.read(notificationServiceProvider);
|
||||
final granted = await notificationService.requestExactAlarmsPermission();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {});
|
||||
|
||||
if (!granted) {
|
||||
_showPermissionDialog('정확 알람');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDebugToolsCard(bool isDark) {
|
||||
final sharePreviewEnabled = ref.watch(debugSharePreviewProvider);
|
||||
final testDataState = ref.watch(debugTestDataNotifierProvider);
|
||||
final testDataNotifier = ref.read(debugTestDataNotifierProvider.notifier);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.wifi_tethering,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
title: const Text('공유 테스트 모드'),
|
||||
subtitle: const Text('광고·권한 없이 디버그 샘플 코드/기기를 표시'),
|
||||
trailing: Switch.adaptive(
|
||||
value: sharePreviewEnabled,
|
||||
onChanged: (value) {
|
||||
ref.read(debugSharePreviewProvider.notifier).state = value;
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.science_outlined,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
title: const Text('기록/통계 테스트 데이터'),
|
||||
subtitle: Text(
|
||||
testDataState.isEnabled
|
||||
? '테스트 데이터가 적용되었습니다 (디버그 전용)'
|
||||
: '디버그 빌드에서만 사용 가능합니다.',
|
||||
),
|
||||
trailing: testDataState.isProcessing
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Switch.adaptive(
|
||||
value: testDataState.isEnabled,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
await testDataNotifier.enableTestData();
|
||||
} else {
|
||||
await testDataNotifier.disableTestData();
|
||||
}
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
if (testDataState.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
testDataState.errorMessage!,
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: AppColors.lightError,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPermissionDialog(String permissionName) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
@@ -11,7 +12,10 @@ import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/share_device.dart';
|
||||
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/debug_share_preview_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/settings_provider.dart';
|
||||
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ShareScreen extends ConsumerStatefulWidget {
|
||||
@@ -21,12 +25,72 @@ class ShareScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ShareScreen> createState() => _ShareScreenState();
|
||||
}
|
||||
|
||||
class _ShareCard extends StatelessWidget {
|
||||
final bool isDark;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBgColor;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
const _ShareCard({
|
||||
required this.isDark,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBgColor,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: iconBgColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 48, color: iconColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
String? _shareCode;
|
||||
bool _isScanning = false;
|
||||
List<ShareDevice>? _nearbyDevices;
|
||||
StreamSubscription<String>? _dataSubscription;
|
||||
ProviderSubscription<bool>? _debugPreviewSub;
|
||||
final _uuid = const Uuid();
|
||||
bool _debugPreviewEnabled = false;
|
||||
Timer? _debugPreviewTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,18 +99,36 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
_dataSubscription = bluetoothService.onDataReceived.listen((payload) {
|
||||
_handleIncomingData(payload);
|
||||
});
|
||||
_debugPreviewEnabled = ref.read(debugSharePreviewProvider);
|
||||
if (_debugPreviewEnabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleDebugToggleChange(true);
|
||||
});
|
||||
}
|
||||
_debugPreviewSub = ref.listenManual<bool>(debugSharePreviewProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
if (previous == next) return;
|
||||
_handleDebugToggleChange(next);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dataSubscription?.cancel();
|
||||
_debugPreviewSub?.close();
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
_debugPreviewTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final screenshotModeEnabled = ref
|
||||
.watch(screenshotModeEnabledProvider)
|
||||
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
@@ -62,254 +144,237 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 공유받기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.download_rounded,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('리스트 공유받기', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'다른 사람의 맛집 리스트를 받아보세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_shareCode != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_shareCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 6,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'이 코드를 상대방에게 알려주세요',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('취소'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_generateShareCode();
|
||||
},
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('공유 코드 생성'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ShareCard(
|
||||
isDark: isDark,
|
||||
icon: Icons.upload_rounded,
|
||||
iconColor: AppColors.lightSecondary,
|
||||
iconBgColor: AppColors.lightSecondary.withOpacity(0.1),
|
||||
title: '내 리스트 공유하기',
|
||||
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
child: _buildSendSection(isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 공유하기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightSecondary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.upload_rounded,
|
||||
size: 48,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('내 리스트 공유하기', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isScanning && _nearbyDevices != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: _nearbyDevices!.isEmpty
|
||||
? Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _nearbyDevices!.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _nearbyDevices![index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.phone_android,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
title: Text(
|
||||
device.code,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'기기 ID: ${device.deviceId}',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.send,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
onTap: () {
|
||||
_sendList(device.code);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('스캔 중지'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_scanDevices();
|
||||
},
|
||||
icon: const Icon(Icons.radar),
|
||||
label: const Text('주변 기기 스캔'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightSecondary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
NativeAdPlaceholder(
|
||||
height: 220,
|
||||
enabled: !screenshotModeEnabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ShareCard(
|
||||
isDark: isDark,
|
||||
icon: Icons.download_rounded,
|
||||
iconColor: AppColors.lightPrimary,
|
||||
iconBgColor: AppColors.lightPrimary.withOpacity(0.1),
|
||||
title: '리스트 공유받기',
|
||||
subtitle: '다른 사람의 맛집 리스트를 받아보세요',
|
||||
child: _buildReceiveSection(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceiveSection(bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_shareCode != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_shareCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 6,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('이 코드를 상대방에게 알려주세요', style: AppTypography.caption(isDark)),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('취소'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.lightError),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_generateShareCode();
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: const Text('공유 코드 생성'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSendSection(bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_isScanning && _nearbyDevices != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: _nearbyDevices!.isEmpty
|
||||
? Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _nearbyDevices!.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _nearbyDevices![index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.phone_android,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
title: Text(
|
||||
device.code,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text('기기 ID: ${device.deviceId}'),
|
||||
trailing: const Icon(
|
||||
Icons.send,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
onTap: () {
|
||||
_sendList(device.code);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('스캔 중지'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.lightError),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_scanDevices();
|
||||
},
|
||||
icon: const Icon(Icons.radar),
|
||||
label: const Text('주변 기기 스캔'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightSecondary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _generateShareCode() async {
|
||||
final adService = ref.read(adServiceProvider);
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kDebugMode && _debugPreviewEnabled) {
|
||||
setState(() {
|
||||
_shareCode = _shareCode ?? _buildDebugShareCode();
|
||||
});
|
||||
_showSuccessSnackBar('디버그 공유 코드가 준비되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('블루투스 권한을 허용해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final random = Random();
|
||||
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
||||
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
});
|
||||
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
try {
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
});
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanDevices() async {
|
||||
if (kDebugMode && _debugPreviewEnabled) {
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
_nearbyDevices = _buildDebugDevices();
|
||||
});
|
||||
_scheduleDebugReceive();
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
@@ -341,6 +406,28 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
|
||||
Future<void> _sendList(String targetCode) async {
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 끝까지 시청해야 리스트를 전송할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kDebugMode && _debugPreviewEnabled) {
|
||||
_showLoadingDialog('리스트 전송 중...');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
_showSuccessSnackBar('디버그 전송 완료! (실제 전송 없음)');
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final restaurants = await ref.read(restaurantListProvider.future);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -363,15 +450,20 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleIncomingData(String payload) async {
|
||||
Future<void> _handleIncomingData(
|
||||
String payload, {
|
||||
bool skipAd = false,
|
||||
}) async {
|
||||
if (!mounted) return;
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
||||
return;
|
||||
if (!skipAd) {
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -502,4 +594,158 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
SnackBar(content: Text(message), backgroundColor: AppColors.lightError),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDebugToggleChange(bool enabled) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_debugPreviewEnabled = enabled;
|
||||
});
|
||||
if (enabled) {
|
||||
await _startDebugPreviewFlow();
|
||||
} else {
|
||||
_stopDebugPreviewFlow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startDebugPreviewFlow() async {
|
||||
_debugPreviewTimer?.cancel();
|
||||
final code = _buildDebugShareCode();
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
_isScanning = true;
|
||||
_nearbyDevices = _buildDebugDevices();
|
||||
});
|
||||
_scheduleDebugReceive();
|
||||
}
|
||||
|
||||
void _stopDebugPreviewFlow() {
|
||||
_debugPreviewTimer?.cancel();
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleDebugReceive() {
|
||||
_debugPreviewTimer?.cancel();
|
||||
_debugPreviewTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (!mounted || !_debugPreviewEnabled) return;
|
||||
final payload = _buildDebugPayload();
|
||||
_handleIncomingData(payload, skipAd: true);
|
||||
});
|
||||
}
|
||||
|
||||
String _buildDebugShareCode() => 'DBG${Random().nextInt(900000) + 100000}';
|
||||
|
||||
List<ShareDevice> _buildDebugDevices() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
ShareDevice(code: 'DBG-ALPHA', deviceId: 'LP-DEBUG-1', discoveredAt: now),
|
||||
ShareDevice(
|
||||
code: 'DBG-BETA',
|
||||
deviceId: 'LP-DEBUG-2',
|
||||
discoveredAt: now.subtract(const Duration(seconds: 10)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _buildDebugPayload() {
|
||||
final samples = _buildDebugRestaurants();
|
||||
final list = samples
|
||||
.map(
|
||||
(restaurant) => {
|
||||
'id': restaurant.id,
|
||||
'name': restaurant.name,
|
||||
'category': restaurant.category,
|
||||
'subCategory': restaurant.subCategory,
|
||||
'description': restaurant.description,
|
||||
'phoneNumber': restaurant.phoneNumber,
|
||||
'roadAddress': restaurant.roadAddress,
|
||||
'jibunAddress': restaurant.jibunAddress,
|
||||
'latitude': restaurant.latitude,
|
||||
'longitude': restaurant.longitude,
|
||||
'lastVisitDate': restaurant.lastVisitDate?.toIso8601String(),
|
||||
'source': restaurant.source.name,
|
||||
'createdAt': restaurant.createdAt.toIso8601String(),
|
||||
'updatedAt': restaurant.updatedAt.toIso8601String(),
|
||||
'naverPlaceId': restaurant.naverPlaceId,
|
||||
'naverUrl': restaurant.naverUrl,
|
||||
'businessHours': restaurant.businessHours,
|
||||
'lastVisited': restaurant.lastVisited?.toIso8601String(),
|
||||
'visitCount': restaurant.visitCount,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
return jsonEncode(list);
|
||||
}
|
||||
|
||||
List<Restaurant> _buildDebugRestaurants() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
Restaurant(
|
||||
id: 'debug-share-ramen',
|
||||
name: '디버그 라멘바',
|
||||
category: 'Japanese',
|
||||
subCategory: 'Ramen',
|
||||
description: '테스트용 라멘 바. 실제 전송 없이 미리보기 용도입니다.',
|
||||
phoneNumber: '02-111-1111',
|
||||
roadAddress: '서울 특별시 테스트로 1',
|
||||
jibunAddress: '서울 테스트동 1-1',
|
||||
latitude: 37.566,
|
||||
longitude: 126.9784,
|
||||
lastVisitDate: now.subtract(const Duration(days: 2)),
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: '11:00 - 21:00',
|
||||
lastVisited: now.subtract(const Duration(days: 2)),
|
||||
visitCount: 3,
|
||||
),
|
||||
Restaurant(
|
||||
id: 'debug-share-burger',
|
||||
name: '샘플 버거샵',
|
||||
category: 'Fastfood',
|
||||
subCategory: 'Burger',
|
||||
description: '광고·권한 없이 교환 흐름을 확인하는 샘플 버거 가게.',
|
||||
phoneNumber: '02-222-2222',
|
||||
roadAddress: '서울 특별시 디버그길 22',
|
||||
jibunAddress: '서울 디버그동 22-2',
|
||||
latitude: 37.57,
|
||||
longitude: 126.982,
|
||||
lastVisitDate: now.subtract(const Duration(days: 5)),
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: '10:00 - 23:00',
|
||||
lastVisited: now.subtract(const Duration(days: 5)),
|
||||
visitCount: 1,
|
||||
),
|
||||
Restaurant(
|
||||
id: 'debug-share-brunch',
|
||||
name: '프리뷰 브런치 카페',
|
||||
category: 'Cafe',
|
||||
subCategory: 'Brunch',
|
||||
description: '리스트 공유 수신 UI를 확인하기 위한 브런치 카페 샘플.',
|
||||
phoneNumber: '02-333-3333',
|
||||
roadAddress: '서울 특별시 미리보기로 33',
|
||||
jibunAddress: '서울 미리보기동 33-3',
|
||||
latitude: 37.561,
|
||||
longitude: 126.99,
|
||||
lastVisitDate: now.subtract(const Duration(days: 1)),
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: '09:00 - 18:00',
|
||||
lastVisited: now.subtract(const Duration(days: 1)),
|
||||
visitCount: 4,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/services/permission_service.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
@@ -17,6 +19,8 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
late List<AnimationController> _foodControllers;
|
||||
late AnimationController _questionMarkController;
|
||||
late AnimationController _centerIconController;
|
||||
List<Offset>? _iconPositions;
|
||||
Size? _lastScreenSize;
|
||||
|
||||
final List<IconData> foodIcons = [
|
||||
Icons.rice_bowl,
|
||||
@@ -60,6 +64,64 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
List<Offset> _generateIconPositions(Size screenSize) {
|
||||
final random = math.Random();
|
||||
const iconSize = 40.0;
|
||||
const maxScale = 1.5;
|
||||
const margin = iconSize * maxScale;
|
||||
const overlapThreshold = 0.3;
|
||||
final effectiveSize = iconSize * maxScale;
|
||||
final center = Offset(screenSize.width / 2, screenSize.height / 2);
|
||||
final centerSafeRadius =
|
||||
math.min(screenSize.width, screenSize.height) * 0.18;
|
||||
|
||||
Offset randomPosition() {
|
||||
final x =
|
||||
margin +
|
||||
random.nextDouble() * (screenSize.width - margin * 2).clamp(1, 9999);
|
||||
final y =
|
||||
margin +
|
||||
random.nextDouble() * (screenSize.height - margin * 2).clamp(1, 9999);
|
||||
return Offset(x, y);
|
||||
}
|
||||
|
||||
final positions = <Offset>[];
|
||||
var attempts = 0;
|
||||
const maxAttempts = 500;
|
||||
|
||||
while (positions.length < foodIcons.length && attempts < maxAttempts) {
|
||||
attempts++;
|
||||
final candidate = randomPosition();
|
||||
if ((candidate - center).distance < centerSafeRadius) {
|
||||
continue;
|
||||
}
|
||||
final hasHeavyOverlap = positions.any(
|
||||
(p) => _isOverlapTooHigh(p, candidate, effectiveSize, overlapThreshold),
|
||||
);
|
||||
if (hasHeavyOverlap) {
|
||||
continue;
|
||||
}
|
||||
positions.add(candidate);
|
||||
}
|
||||
|
||||
while (positions.length < foodIcons.length) {
|
||||
positions.add(randomPosition());
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
bool _isOverlapTooHigh(Offset a, Offset b, double size, double maxRatio) {
|
||||
final dx = (a.dx - b.dx).abs();
|
||||
final dy = (a.dy - b.dy).abs();
|
||||
final overlapX = math.max(0.0, size - dx);
|
||||
final overlapY = math.max(0.0, size - dy);
|
||||
final overlapArea = overlapX * overlapY;
|
||||
final maxArea = size * size;
|
||||
if (maxArea == 0) return false;
|
||||
return overlapArea / maxArea > maxRatio;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -143,15 +205,22 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
}
|
||||
|
||||
List<Widget> _buildFoodIcons() {
|
||||
final random = math.Random();
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final sameSize =
|
||||
_lastScreenSize != null &&
|
||||
(_lastScreenSize!.width - screenSize.width).abs() < 1 &&
|
||||
(_lastScreenSize!.height - screenSize.height).abs() < 1;
|
||||
if (_iconPositions == null || !sameSize) {
|
||||
_iconPositions = _generateIconPositions(screenSize);
|
||||
_lastScreenSize = screenSize;
|
||||
}
|
||||
|
||||
return List.generate(foodIcons.length, (index) {
|
||||
final left = random.nextDouble() * 0.8 + 0.1;
|
||||
final top = random.nextDouble() * 0.7 + 0.1;
|
||||
final position = _iconPositions![index];
|
||||
|
||||
return Positioned(
|
||||
left: MediaQuery.of(context).size.width * left,
|
||||
top: MediaQuery.of(context).size.height * top,
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FadeTransition(
|
||||
opacity: Tween(begin: 0.2, end: 0.8).animate(
|
||||
CurvedAnimation(
|
||||
@@ -178,13 +247,26 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
}
|
||||
|
||||
void _navigateToHome() {
|
||||
Future.delayed(AppConstants.splashAnimationDuration, () {
|
||||
Future.wait([
|
||||
_ensurePermissions(),
|
||||
Future.delayed(AppConstants.splashAnimationDuration),
|
||||
]).then((_) {
|
||||
if (mounted) {
|
||||
context.go('/home');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ensurePermissions() async {
|
||||
try {
|
||||
await Permission.notification.request();
|
||||
await Permission.location.request();
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
} catch (_) {
|
||||
// 권한 요청 중 예외가 발생해도 앱 흐름을 막지 않는다.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _foodControllers) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final debugSharePreviewProvider = StateProvider<bool>((ref) => false);
|
||||
388
lib/presentation/providers/debug_test_data_provider.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
|
||||
class DebugTestDataState {
|
||||
final bool isEnabled;
|
||||
final bool isProcessing;
|
||||
final String? errorMessage;
|
||||
|
||||
const DebugTestDataState({
|
||||
this.isEnabled = false,
|
||||
this.isProcessing = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DebugTestDataState copyWith({
|
||||
bool? isEnabled,
|
||||
bool? isProcessing,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DebugTestDataState(
|
||||
isEnabled: isEnabled ?? this.isEnabled,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DebugTestDataNotifier extends StateNotifier<DebugTestDataState> {
|
||||
DebugTestDataNotifier(this._ref) : super(const DebugTestDataState());
|
||||
|
||||
final Ref _ref;
|
||||
static const String _idPrefix = 'debug-preview-';
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (state.isProcessing) return;
|
||||
state = state.copyWith(isProcessing: true, errorMessage: null);
|
||||
|
||||
try {
|
||||
final hasDebugData = await _hasExistingDebugData();
|
||||
state = state.copyWith(isEnabled: hasDebugData, isProcessing: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enableTestData() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true, errorMessage: null);
|
||||
try {
|
||||
await _clearDebugData();
|
||||
await _seedDebugData();
|
||||
state = state.copyWith(isEnabled: true, isProcessing: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableTestData() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true, errorMessage: null);
|
||||
try {
|
||||
await _clearDebugData();
|
||||
state = state.copyWith(isEnabled: false, isProcessing: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedDebugData() async {
|
||||
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
|
||||
final visitRepo = _ref.read(visitRepositoryProvider);
|
||||
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
|
||||
|
||||
final samples = _buildDebugSamples();
|
||||
|
||||
for (final sample in samples) {
|
||||
await restaurantRepo.addRestaurant(sample.restaurant);
|
||||
}
|
||||
|
||||
for (final sample in samples) {
|
||||
for (final visit in sample.visits) {
|
||||
await visitRepo.addVisitRecord(visit);
|
||||
}
|
||||
for (final reco in sample.recommendations) {
|
||||
await recommendationRepo.addRecommendationRecord(reco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearDebugData() async {
|
||||
final visitRepo = _ref.read(visitRepositoryProvider);
|
||||
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
|
||||
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
|
||||
|
||||
final visits = await visitRepo.getAllVisitRecords();
|
||||
for (final visit in visits.where((v) => _isDebugId(v.id))) {
|
||||
await visitRepo.deleteVisitRecord(visit.id);
|
||||
}
|
||||
|
||||
final recos = await recommendationRepo.getAllRecommendationRecords();
|
||||
for (final reco in recos.where((r) => _isDebugId(r.id))) {
|
||||
await recommendationRepo.deleteRecommendationRecord(reco.id);
|
||||
}
|
||||
|
||||
final restaurants = await restaurantRepo.getAllRestaurants();
|
||||
for (final restaurant in restaurants.where((r) => _isDebugId(r.id))) {
|
||||
await restaurantRepo.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _hasExistingDebugData() async {
|
||||
final visitRepo = _ref.read(visitRepositoryProvider);
|
||||
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
|
||||
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
|
||||
|
||||
final visits = await visitRepo.getAllVisitRecords();
|
||||
final recos = await recommendationRepo.getAllRecommendationRecords();
|
||||
final restaurants = await restaurantRepo.getAllRestaurants();
|
||||
|
||||
return visits.any((v) => _isDebugId(v.id)) ||
|
||||
recos.any((r) => _isDebugId(r.id)) ||
|
||||
restaurants.any((r) => _isDebugId(r.id));
|
||||
}
|
||||
|
||||
List<_DebugSample> _buildDebugSamples() {
|
||||
final today = DateTime.now();
|
||||
final baseDay = DateTime(today.year, today.month, today.day);
|
||||
|
||||
DateTime atDayOffset(int daysAgo, {int hour = 12, int minute = 0}) {
|
||||
return baseDay
|
||||
.subtract(Duration(days: daysAgo))
|
||||
.add(Duration(hours: hour, minutes: minute));
|
||||
}
|
||||
|
||||
VisitRecord buildVisit({
|
||||
required String id,
|
||||
required String restaurantId,
|
||||
required DateTime visitDate,
|
||||
required bool isConfirmed,
|
||||
}) {
|
||||
return VisitRecord(
|
||||
id: id,
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitDate,
|
||||
isConfirmed: isConfirmed,
|
||||
createdAt: visitDate,
|
||||
);
|
||||
}
|
||||
|
||||
RecommendationRecord buildRecommendation({
|
||||
required String id,
|
||||
required String restaurantId,
|
||||
required DateTime recommendationDate,
|
||||
}) {
|
||||
return RecommendationRecord(
|
||||
id: id,
|
||||
restaurantId: restaurantId,
|
||||
recommendationDate: recommendationDate,
|
||||
visited: false,
|
||||
createdAt: recommendationDate,
|
||||
);
|
||||
}
|
||||
|
||||
Restaurant buildRestaurant({
|
||||
required String id,
|
||||
required String name,
|
||||
required String category,
|
||||
required String subCategory,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required String description,
|
||||
required String phoneNumber,
|
||||
required List<VisitRecord> visits,
|
||||
}) {
|
||||
final latestVisit = visits
|
||||
.map((v) => v.visitDate)
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
|
||||
return Restaurant(
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
subCategory: subCategory,
|
||||
description: description,
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: roadAddress,
|
||||
jibunAddress: jibunAddress,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastVisitDate: latestVisit,
|
||||
source: DataSource.PRESET,
|
||||
createdAt: baseDay,
|
||||
updatedAt: baseDay,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: null,
|
||||
lastVisited: latestVisit,
|
||||
visitCount: visits.length,
|
||||
needsAddressVerification: false,
|
||||
);
|
||||
}
|
||||
|
||||
final bistroId = _withPrefix('bistro');
|
||||
final sushiId = _withPrefix('sushi');
|
||||
final coffeeId = _withPrefix('coffee');
|
||||
|
||||
final bistroVisits = [
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-bistro-0'),
|
||||
restaurantId: bistroId,
|
||||
visitDate: atDayOffset(0, hour: 12, minute: 10),
|
||||
isConfirmed: false,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-bistro-1'),
|
||||
restaurantId: bistroId,
|
||||
visitDate: atDayOffset(2, hour: 19, minute: 0),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-bistro-2'),
|
||||
restaurantId: bistroId,
|
||||
visitDate: atDayOffset(5, hour: 13, minute: 15),
|
||||
isConfirmed: true,
|
||||
),
|
||||
];
|
||||
|
||||
final sushiVisits = [
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-sushi-0'),
|
||||
restaurantId: sushiId,
|
||||
visitDate: atDayOffset(1, hour: 12, minute: 40),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-sushi-1'),
|
||||
restaurantId: sushiId,
|
||||
visitDate: atDayOffset(3, hour: 18, minute: 30),
|
||||
isConfirmed: false,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-sushi-2'),
|
||||
restaurantId: sushiId,
|
||||
visitDate: atDayOffset(6, hour: 20, minute: 10),
|
||||
isConfirmed: true,
|
||||
),
|
||||
];
|
||||
|
||||
final coffeeVisits = [
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-coffee-0'),
|
||||
restaurantId: coffeeId,
|
||||
visitDate: atDayOffset(2, hour: 9, minute: 30),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-coffee-1'),
|
||||
restaurantId: coffeeId,
|
||||
visitDate: atDayOffset(4, hour: 15, minute: 15),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-coffee-2'),
|
||||
restaurantId: coffeeId,
|
||||
visitDate: atDayOffset(7, hour: 11, minute: 50),
|
||||
isConfirmed: true,
|
||||
),
|
||||
];
|
||||
|
||||
final samples = <_DebugSample>[
|
||||
_DebugSample(
|
||||
restaurant: buildRestaurant(
|
||||
id: bistroId,
|
||||
name: 'Debug Bistro',
|
||||
category: 'Fusion',
|
||||
subCategory: 'Brunch',
|
||||
description:
|
||||
'Sample data to preview the record and statistics experience.',
|
||||
phoneNumber: '02-100-0001',
|
||||
roadAddress: '서울 테스트로 12',
|
||||
jibunAddress: '서울 테스트동 12-1',
|
||||
latitude: 37.5665,
|
||||
longitude: 126.9780,
|
||||
visits: bistroVisits,
|
||||
),
|
||||
visits: bistroVisits,
|
||||
recommendations: [
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-bistro-0'),
|
||||
restaurantId: bistroId,
|
||||
recommendationDate: atDayOffset(1, hour: 11, minute: 20),
|
||||
),
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-bistro-1'),
|
||||
restaurantId: bistroId,
|
||||
recommendationDate: atDayOffset(4, hour: 18, minute: 40),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DebugSample(
|
||||
restaurant: buildRestaurant(
|
||||
id: sushiId,
|
||||
name: 'Sample Sushi Bar',
|
||||
category: 'Japanese',
|
||||
subCategory: 'Sushi',
|
||||
description: 'Rotating omakase picks to mimic real visit timelines.',
|
||||
phoneNumber: '02-200-0002',
|
||||
roadAddress: '서울 샘플로 21',
|
||||
jibunAddress: '서울 샘플동 21-3',
|
||||
latitude: 37.5559,
|
||||
longitude: 126.9363,
|
||||
visits: sushiVisits,
|
||||
),
|
||||
visits: sushiVisits,
|
||||
recommendations: [
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-sushi-0'),
|
||||
restaurantId: sushiId,
|
||||
recommendationDate: atDayOffset(3, hour: 12, minute: 0),
|
||||
),
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-sushi-1'),
|
||||
restaurantId: sushiId,
|
||||
recommendationDate: atDayOffset(7, hour: 19, minute: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DebugSample(
|
||||
restaurant: buildRestaurant(
|
||||
id: coffeeId,
|
||||
name: 'Test Coffee Lab',
|
||||
category: 'Cafe',
|
||||
subCategory: 'Dessert',
|
||||
description: 'Morning cafe stops added so charts render immediately.',
|
||||
phoneNumber: '02-300-0003',
|
||||
roadAddress: '서울 예제길 5',
|
||||
jibunAddress: '서울 예제동 5-2',
|
||||
latitude: 37.5412,
|
||||
longitude: 126.986,
|
||||
visits: coffeeVisits,
|
||||
),
|
||||
visits: coffeeVisits,
|
||||
recommendations: [
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-coffee-0'),
|
||||
restaurantId: coffeeId,
|
||||
recommendationDate: atDayOffset(0, hour: 8, minute: 50),
|
||||
),
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-coffee-1'),
|
||||
restaurantId: coffeeId,
|
||||
recommendationDate: atDayOffset(5, hour: 16, minute: 30),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
bool _isDebugId(String id) => id.startsWith(_idPrefix);
|
||||
|
||||
String _withPrefix(String rawId) => '$_idPrefix$rawId';
|
||||
}
|
||||
|
||||
final debugTestDataNotifierProvider =
|
||||
StateNotifierProvider<DebugTestDataNotifier, DebugTestDataState>((ref) {
|
||||
return DebugTestDataNotifier(ref);
|
||||
});
|
||||
|
||||
class _DebugSample {
|
||||
final Restaurant restaurant;
|
||||
final List<VisitRecord> visits;
|
||||
final List<RecommendationRecord> recommendations;
|
||||
|
||||
_DebugSample({
|
||||
required this.restaurant,
|
||||
required this.visits,
|
||||
required this.recommendations,
|
||||
});
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import 'package:lunchpick/data/repositories/visit_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/settings_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/weather_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/recommendation_repository_impl.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_url_processor.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/data/api/naver_api_client.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
import 'package:lunchpick/core/services/geocoding_service.dart';
|
||||
|
||||
/// RestaurantRepository Provider
|
||||
final restaurantRepositoryProvider = Provider<RestaurantRepository>((ref) {
|
||||
@@ -36,3 +40,26 @@ final recommendationRepositoryProvider = Provider<RecommendationRepository>((
|
||||
) {
|
||||
return RecommendationRepositoryImpl();
|
||||
});
|
||||
|
||||
/// NaverApiClient Provider
|
||||
final naverApiClientProvider = Provider<NaverApiClient>((ref) {
|
||||
return NaverApiClient();
|
||||
});
|
||||
|
||||
/// NaverMapParser Provider
|
||||
final naverMapParserProvider = Provider<NaverMapParser>((ref) {
|
||||
final apiClient = ref.watch(naverApiClientProvider);
|
||||
return NaverMapParser(apiClient: apiClient);
|
||||
});
|
||||
|
||||
/// NaverUrlProcessor Provider
|
||||
final naverUrlProcessorProvider = Provider<NaverUrlProcessor>((ref) {
|
||||
final apiClient = ref.watch(naverApiClientProvider);
|
||||
final parser = ref.watch(naverMapParserProvider);
|
||||
return NaverUrlProcessor(apiClient: apiClient, mapParser: parser);
|
||||
});
|
||||
|
||||
/// GeocodingService Provider
|
||||
final geocodingServiceProvider = Provider<GeocodingService>((ref) {
|
||||
return GeocodingService();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
const double _defaultLatitude = 37.5666805;
|
||||
const double _defaultLongitude = 126.9784147;
|
||||
|
||||
/// 위치 정보를 사용할 수 없을 때 활용하는 기본 좌표(서울 시청).
|
||||
Position defaultPosition() {
|
||||
return Position(
|
||||
latitude: _defaultLatitude,
|
||||
longitude: _defaultLongitude,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0,
|
||||
altitude: 0,
|
||||
altitudeAccuracy: 0,
|
||||
heading: 0,
|
||||
headingAccuracy: 0,
|
||||
speed: 0,
|
||||
speedAccuracy: 0,
|
||||
isMocked: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 위치 권한 상태 Provider
|
||||
final locationPermissionProvider = FutureProvider<PermissionStatus>((
|
||||
ref,
|
||||
@@ -18,14 +40,16 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
// 권한이 없으면 요청
|
||||
final result = await Permission.location.request();
|
||||
if (!result.isGranted) {
|
||||
return null;
|
||||
AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청) 사용');
|
||||
return defaultPosition();
|
||||
}
|
||||
}
|
||||
|
||||
// 위치 서비스 활성화 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
throw Exception('위치 서비스가 비활성화되어 있습니다');
|
||||
AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청) 사용');
|
||||
return defaultPosition();
|
||||
}
|
||||
|
||||
// 현재 위치 가져오기
|
||||
@@ -36,18 +60,73 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
);
|
||||
} catch (e) {
|
||||
// 타임아웃이나 오류 발생 시 마지막 알려진 위치 반환
|
||||
return await Geolocator.getLastKnownPosition();
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
if (lastPosition != null) {
|
||||
return lastPosition;
|
||||
}
|
||||
AppLogger.debug('현재 위치를 가져오지 못해 기본 좌표(서울 시청)를 반환');
|
||||
return defaultPosition();
|
||||
}
|
||||
});
|
||||
|
||||
/// 위치 스트림 Provider
|
||||
final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 10, // 10미터 이상 이동 시 업데이트
|
||||
),
|
||||
);
|
||||
final locationStreamProvider = StreamProvider<Position>((ref) async* {
|
||||
if (kIsWeb) {
|
||||
AppLogger.debug('[location] web detected, emit fallback immediately');
|
||||
yield defaultPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
final status = await Permission.location.status;
|
||||
if (!status.isGranted) {
|
||||
AppLogger.debug('[location] permission not granted, emit fallback');
|
||||
yield defaultPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
yield* Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 10, // 10미터 이상 이동 시 업데이트
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
AppLogger.error('[location] position stream failed, emit fallback');
|
||||
yield defaultPosition();
|
||||
}
|
||||
});
|
||||
|
||||
/// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고,
|
||||
/// 이후 실제 위치 스트림이 들어오면 업데이트하는 Provider.
|
||||
final currentLocationWithFallbackProvider = StreamProvider<Position>((
|
||||
ref,
|
||||
) async* {
|
||||
AppLogger.debug('[location] emit fallback immediately (safe start)');
|
||||
// 웹/권한 거부 상황에서는 즉시 기본 좌표를 먼저 흘려보내 리스트 로딩을 막는다.
|
||||
final fallback = defaultPosition();
|
||||
yield fallback;
|
||||
|
||||
final initial = await Future.any([
|
||||
ref.watch(currentLocationProvider.future).then((pos) => pos ?? fallback),
|
||||
Future<Position>.delayed(const Duration(seconds: 3), () => fallback),
|
||||
]).catchError((_) => fallback);
|
||||
|
||||
if (initial.latitude != fallback.latitude ||
|
||||
initial.longitude != fallback.longitude) {
|
||||
AppLogger.debug(
|
||||
'[location] resolved initial position: '
|
||||
'${initial.latitude}, ${initial.longitude}',
|
||||
);
|
||||
yield initial;
|
||||
} else {
|
||||
AppLogger.debug('[location] initial resolved to fallback');
|
||||
}
|
||||
|
||||
yield* ref.watch(locationStreamProvider.stream).handleError((_) {
|
||||
// 스트림 오류는 무시하고 마지막 위치를 유지
|
||||
AppLogger.error('[location] stream error, keeping last position');
|
||||
});
|
||||
});
|
||||
|
||||
/// 위치 관리 StateNotifier
|
||||
@@ -83,7 +162,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
if (!permissionStatus.isGranted) {
|
||||
final granted = await requestLocationPermission();
|
||||
if (!granted) {
|
||||
state = const AsyncValue.data(null);
|
||||
AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +171,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
// 위치 서비스 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
state = AsyncValue.error('위치 서비스가 비활성화되어 있습니다', StackTrace.current);
|
||||
AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,13 +183,18 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
);
|
||||
|
||||
state = AsyncValue.data(position);
|
||||
} catch (e, stack) {
|
||||
} catch (e) {
|
||||
// 오류 발생 시 마지막 알려진 위치 시도
|
||||
try {
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
state = AsyncValue.data(lastPosition);
|
||||
if (lastPosition != null) {
|
||||
state = AsyncValue.data(lastPosition);
|
||||
} else {
|
||||
AppLogger.debug('마지막 위치도 없어 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
}
|
||||
} catch (_) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/presentation/pages/calendar/widgets/visit_confirmation_dialog.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
@@ -54,8 +55,8 @@ class NotificationPayload {
|
||||
);
|
||||
} catch (e) {
|
||||
// 더 상세한 오류 정보 제공
|
||||
print('NotificationPayload parsing error: $e');
|
||||
print('Original payload: $payload');
|
||||
AppLogger.error('NotificationPayload parsing error', error: e);
|
||||
AppLogger.debug('Original payload: $payload');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -77,17 +78,17 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
String? payload,
|
||||
) async {
|
||||
if (payload == null || payload.isEmpty) {
|
||||
print('Notification payload is null or empty');
|
||||
AppLogger.debug('Notification payload is null or empty');
|
||||
return;
|
||||
}
|
||||
|
||||
print('Handling notification with payload: $payload');
|
||||
AppLogger.debug('Handling notification with payload: $payload');
|
||||
|
||||
try {
|
||||
// 기존 형식 (visit_reminder:restaurantName) 처리
|
||||
if (payload.startsWith('visit_reminder:')) {
|
||||
final restaurantName = payload.substring(15);
|
||||
print('Legacy format - Restaurant name: $restaurantName');
|
||||
AppLogger.debug('Legacy format - Restaurant name: $restaurantName');
|
||||
|
||||
// 맛집 이름으로 ID 찾기
|
||||
final restaurantsAsync = await _ref.read(restaurantListProvider.future);
|
||||
@@ -110,11 +111,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
} else {
|
||||
// 새로운 형식의 payload 처리
|
||||
print('Attempting to parse new format payload');
|
||||
AppLogger.debug('Attempting to parse new format payload');
|
||||
|
||||
try {
|
||||
final notificationPayload = NotificationPayload.fromString(payload);
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}',
|
||||
);
|
||||
|
||||
@@ -135,8 +136,10 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
print('Failed to parse new format, attempting fallback parsing');
|
||||
print('Parse error: $parseError');
|
||||
AppLogger.debug(
|
||||
'Failed to parse new format, attempting fallback parsing',
|
||||
);
|
||||
AppLogger.debug('Parse error: $parseError');
|
||||
|
||||
// Fallback: 간단한 파싱 시도
|
||||
if (payload.contains('|')) {
|
||||
@@ -158,8 +161,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print('Error handling notification: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
AppLogger.error(
|
||||
'Error handling notification',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
|
||||
// 에러 발생 시 기본적으로 캘린더 화면으로 이동
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
@@ -25,6 +27,76 @@ final todayRecommendationCountProvider = FutureProvider<int>((ref) async {
|
||||
return repository.getTodayRecommendationCount();
|
||||
});
|
||||
|
||||
Future<bool> _isScreenshotModeEnabled(Ref ref) async {
|
||||
try {
|
||||
return await ref.read(screenshotModeEnabledProvider.future);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Restaurant _buildScreenshotRestaurant() {
|
||||
final now = DateTime.now();
|
||||
final random = Random();
|
||||
final templates = [
|
||||
(
|
||||
id: 'screenshot-basil-bistro',
|
||||
name: 'Basil Breeze Bistro',
|
||||
category: '양식',
|
||||
subCategory: '파스타 · 그릴',
|
||||
description: '바질 향이 가득한 파스타와 스테이크를 즐길 수 있는 다이닝.',
|
||||
phoneNumber: '02-1234-5678',
|
||||
roadAddress: '서울 중구 세종대로 110',
|
||||
jibunAddress: '서울 중구 태평로1가 31',
|
||||
latitude: 37.5665,
|
||||
longitude: 126.9780,
|
||||
),
|
||||
(
|
||||
id: 'screenshot-komorebi-sushi',
|
||||
name: 'Komorebi Sushi',
|
||||
category: '일식',
|
||||
subCategory: '스시 · 사시미',
|
||||
description: '제철 재료로 선보이는 오마카세 콘셉트 스시 바.',
|
||||
phoneNumber: '02-2468-1357',
|
||||
roadAddress: '서울 강남구 테헤란로 311',
|
||||
jibunAddress: '서울 강남구 역삼동 647-9',
|
||||
latitude: 37.5009,
|
||||
longitude: 127.0365,
|
||||
),
|
||||
(
|
||||
id: 'screenshot-brunch-lab',
|
||||
name: 'Sunny Brunch Lab',
|
||||
category: '카페/디저트',
|
||||
subCategory: '브런치 · 디저트',
|
||||
description: '스크램블 에그와 시그니처 라떼가 인기인 브런치 카페.',
|
||||
phoneNumber: '02-9753-8642',
|
||||
roadAddress: '서울 마포구 독막로 12길 5',
|
||||
jibunAddress: '서울 마포구 합정동 374-6',
|
||||
latitude: 37.5509,
|
||||
longitude: 126.9143,
|
||||
),
|
||||
];
|
||||
|
||||
final template = templates[random.nextInt(templates.length)];
|
||||
|
||||
return Restaurant(
|
||||
id: '${template.id}-${now.millisecondsSinceEpoch}',
|
||||
name: template.name,
|
||||
category: template.category,
|
||||
subCategory: template.subCategory,
|
||||
description: template.description,
|
||||
phoneNumber: template.phoneNumber,
|
||||
roadAddress: template.roadAddress,
|
||||
jibunAddress: template.jibunAddress,
|
||||
latitude: template.latitude,
|
||||
longitude: template.longitude,
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
needsAddressVerification: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 추천 설정 모델
|
||||
class RecommendationSettings {
|
||||
final int daysToExclude;
|
||||
@@ -50,74 +122,128 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
: super(const AsyncValue.data(null));
|
||||
|
||||
/// 랜덤 추천 실행
|
||||
Future<void> getRandomRecommendation({
|
||||
Future<Restaurant?> getRandomRecommendation({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
bool shouldSaveRecord = true,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
|
||||
if (screenshotModeEnabled) {
|
||||
final mock = _buildScreenshotRestaurant();
|
||||
state = AsyncValue.data(mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
final selectedRestaurant = await _generateCandidate(
|
||||
maxDistance: maxDistance,
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
excludedRestaurantIds: excludedRestaurantIds,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
final selectedRestaurant = await _recommendationEngine
|
||||
.generateRecommendation(
|
||||
allRestaurants: allRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
if (selectedRestaurant == null) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추천 기록 저장
|
||||
await _saveRecommendationRecord(selectedRestaurant);
|
||||
if (shouldSaveRecord) {
|
||||
await saveRecommendationRecord(selectedRestaurant);
|
||||
}
|
||||
|
||||
state = AsyncValue.data(selectedRestaurant);
|
||||
return selectedRestaurant;
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Restaurant?> _generateCandidate({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
}) async {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
// 제외된 식당 제거
|
||||
final availableRestaurants = excludedRestaurantIds.isEmpty
|
||||
? allRestaurants
|
||||
: allRestaurants
|
||||
.where(
|
||||
(restaurant) => !excludedRestaurantIds.contains(restaurant.id),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (availableRestaurants.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
maxDistance: maxDistance / 1000, // 미터 입력을 km 단위로 변환
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
return _recommendationEngine.generateRecommendation(
|
||||
allRestaurants: availableRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
}
|
||||
|
||||
/// 추천 기록 저장
|
||||
Future<void> _saveRecommendationRecord(Restaurant restaurant) async {
|
||||
Future<RecommendationRecord> saveRecommendationRecord(
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendationTime,
|
||||
bool visited = false,
|
||||
}) async {
|
||||
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (screenshotModeEnabled) {
|
||||
return RecommendationRecord(
|
||||
id: 'screenshot-${now.millisecondsSinceEpoch}',
|
||||
restaurantId: restaurant.id,
|
||||
recommendationDate: recommendationTime ?? now,
|
||||
visited: visited,
|
||||
createdAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
final record = RecommendationRecord(
|
||||
id: const Uuid().v4(),
|
||||
restaurantId: restaurant.id,
|
||||
recommendationDate: DateTime.now(),
|
||||
visited: false,
|
||||
createdAt: DateTime.now(),
|
||||
recommendationDate: recommendationTime ?? now,
|
||||
visited: visited,
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
await _repository.addRecommendationRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/// 추천 후 방문 확인
|
||||
@@ -137,7 +263,11 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
await visitNotifier.createVisitFromRecommendation(
|
||||
restaurantId: recommendation.restaurantId,
|
||||
recommendationTime: recommendation.recommendationDate,
|
||||
isConfirmed: true,
|
||||
);
|
||||
|
||||
// 방문 기록을 만들었으므로 추천 기록은 숨김 처리
|
||||
await _repository.deleteRecommendationRecord(recommendationId);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
@@ -218,6 +348,11 @@ class EnhancedRecommendationNotifier
|
||||
Future<void> rerollRecommendation() async {
|
||||
if (state.currentRecommendation == null) return;
|
||||
|
||||
if (await _isScreenshotModeEnabled(_ref)) {
|
||||
await generateRecommendation();
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 추천을 제외 목록에 추가
|
||||
final excluded = [
|
||||
...state.excludedRestaurants,
|
||||
@@ -236,6 +371,17 @@ class EnhancedRecommendationNotifier
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
if (await _isScreenshotModeEnabled(_ref)) {
|
||||
final mock = _buildScreenshotRestaurant();
|
||||
state = state.copyWith(
|
||||
currentRecommendation: mock,
|
||||
excludedRestaurants: const [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
@@ -267,7 +413,7 @@ class EnhancedRecommendationNotifier
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
maxDistance: maxDistanceNormal.toDouble(),
|
||||
maxDistance: maxDistanceNormal.toDouble() / 1000, // 미터 입력을 km 단위로 변환
|
||||
selectedCategories: categories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/utils/category_mapper.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 맛집 목록 Provider
|
||||
@@ -11,6 +14,51 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
|
||||
return repository.watchRestaurants();
|
||||
});
|
||||
|
||||
/// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반)
|
||||
/// StreamProvider 의존으로 초기 이벤트를 놓치는 문제를 피하기 위해
|
||||
/// 기존 리스트 스트림의 AsyncValue를 그대로 전달하며 정렬만 적용한다.
|
||||
final sortedRestaurantsByDistanceProvider =
|
||||
Provider<AsyncValue<List<({Restaurant restaurant, double? distanceKm})>>>((
|
||||
ref,
|
||||
) {
|
||||
final restaurantsAsync = ref.watch(restaurantListProvider);
|
||||
final positionAsync = ref.watch(currentLocationWithFallbackProvider);
|
||||
final position = positionAsync.maybeWhen(
|
||||
data: (pos) => pos,
|
||||
orElse: () => defaultPosition(),
|
||||
);
|
||||
|
||||
AppLogger.debug(
|
||||
'[restaurant_list] position ready for sorting: '
|
||||
'${position.latitude}, ${position.longitude}',
|
||||
);
|
||||
|
||||
return restaurantsAsync.whenData((restaurants) {
|
||||
AppLogger.debug(
|
||||
'[restaurant_list] incoming restaurants: ${restaurants.length}',
|
||||
);
|
||||
final sorted =
|
||||
restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) {
|
||||
final distanceKm = DistanceCalculator.calculateDistance(
|
||||
lat1: position.latitude,
|
||||
lon1: position.longitude,
|
||||
lat2: r.latitude,
|
||||
lon2: r.longitude,
|
||||
);
|
||||
return (restaurant: r, distanceKm: distanceKm);
|
||||
}).toList()..sort(
|
||||
(a, b) => (a.distanceKm ?? double.infinity).compareTo(
|
||||
b.distanceKm ?? double.infinity,
|
||||
),
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[restaurant_list] sorted list emitted, first distanceKm: '
|
||||
'${sorted.isNotEmpty ? sorted.first.distanceKm?.toStringAsFixed(3) : 'none'}',
|
||||
);
|
||||
return sorted;
|
||||
});
|
||||
});
|
||||
|
||||
/// 특정 맛집 Provider
|
||||
final restaurantProvider = FutureProvider.family<Restaurant?, String>((
|
||||
ref,
|
||||
@@ -20,10 +68,29 @@ final restaurantProvider = FutureProvider.family<Restaurant?, String>((
|
||||
return repository.getRestaurantById(id);
|
||||
});
|
||||
|
||||
/// 카테고리 목록 Provider
|
||||
final categoriesProvider = FutureProvider<List<String>>((ref) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getAllCategories();
|
||||
/// 카테고리 목록 Provider (맛집 스트림을 구독해 즉시 갱신)
|
||||
final categoriesProvider = StreamProvider<List<String>>((ref) {
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
return restaurantsStream.map((restaurants) {
|
||||
final categories = restaurants.map((r) => r.category).toSet().toList()
|
||||
..sort();
|
||||
return categories;
|
||||
});
|
||||
});
|
||||
|
||||
/// 세부 카테고리 목록 Provider
|
||||
final subCategoriesProvider = StreamProvider<List<String>>((ref) {
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
return restaurantsStream.map((restaurants) {
|
||||
final subCategories =
|
||||
restaurants
|
||||
.map((r) => r.subCategory)
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
return subCategories;
|
||||
});
|
||||
});
|
||||
|
||||
/// 맛집 관리 StateNotifier
|
||||
@@ -76,24 +143,12 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
final updated = Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: restaurant.description,
|
||||
phoneNumber: restaurant.phoneNumber,
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
lastVisitDate: restaurant.lastVisitDate,
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
final nextSource = restaurant.source == DataSource.PRESET
|
||||
? DataSource.USER_INPUT
|
||||
: restaurant.source;
|
||||
await _repository.updateRestaurant(
|
||||
restaurant.copyWith(source: nextSource, updatedAt: DateTime.now()),
|
||||
);
|
||||
|
||||
await _repository.updateRestaurant(updated);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
@@ -201,14 +256,14 @@ final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 필터링된 맛집 목록 Provider (검색 + 카테고리)
|
||||
final filteredRestaurantsProvider = StreamProvider<List<Restaurant>>((
|
||||
final filteredRestaurantsProvider = Provider<AsyncValue<List<Restaurant>>>((
|
||||
ref,
|
||||
) async* {
|
||||
) {
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
final restaurantsAsync = ref.watch(restaurantListProvider);
|
||||
|
||||
await for (final restaurants in restaurantsStream) {
|
||||
return restaurantsAsync.whenData((restaurants) {
|
||||
var filtered = restaurants;
|
||||
|
||||
// 검색 필터 적용
|
||||
@@ -240,6 +295,6 @@ final filteredRestaurantsProvider = StreamProvider<List<Restaurant>>((
|
||||
}).toList();
|
||||
}
|
||||
|
||||
yield filtered;
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,12 @@ final notificationEnabledProvider = FutureProvider<bool>((ref) async {
|
||||
return repository.isNotificationEnabled();
|
||||
});
|
||||
|
||||
/// 스크린샷 모드 활성화 여부 Provider
|
||||
final screenshotModeEnabledProvider = FutureProvider<bool>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return repository.isScreenshotModeEnabled();
|
||||
});
|
||||
|
||||
/// 다크모드 활성화 여부 Provider
|
||||
final darkModeEnabledProvider = FutureProvider<bool>((ref) async {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
@@ -124,6 +130,17 @@ class SettingsNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 스크린샷 모드 설정
|
||||
Future<void> setScreenshotModeEnabled(bool enabled) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await _repository.setScreenshotModeEnabled(enabled);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 다크모드 설정
|
||||
Future<void> setDarkModeEnabled(bool enabled) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
@@ -35,6 +35,34 @@ final monthlyVisitStatsProvider =
|
||||
return repository.getMonthlyVisitStats(params.year, params.month);
|
||||
});
|
||||
|
||||
/// 월별 카테고리별 방문 통계 Provider
|
||||
final monthlyCategoryVisitStatsProvider =
|
||||
FutureProvider.family<Map<String, int>, ({int year, int month})>((
|
||||
ref,
|
||||
params,
|
||||
) async {
|
||||
final repository = ref.watch(visitRepositoryProvider);
|
||||
final restaurants = await ref.watch(restaurantListProvider.future);
|
||||
|
||||
final records = await repository.getVisitRecordsByDateRange(
|
||||
startDate: DateTime(params.year, params.month, 1),
|
||||
endDate: DateTime(params.year, params.month + 1, 0),
|
||||
);
|
||||
|
||||
final categoryCount = <String, int>{};
|
||||
for (final record in records) {
|
||||
final restaurant = restaurants
|
||||
.where((r) => r.id == record.restaurantId)
|
||||
.firstOrNull;
|
||||
if (restaurant == null) continue;
|
||||
|
||||
categoryCount[restaurant.category] =
|
||||
(categoryCount[restaurant.category] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return categoryCount;
|
||||
});
|
||||
|
||||
/// 방문 기록 관리 StateNotifier
|
||||
class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
final VisitRepository _repository;
|
||||
@@ -100,14 +128,15 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
Future<void> createVisitFromRecommendation({
|
||||
required String restaurantId,
|
||||
required DateTime recommendationTime,
|
||||
bool isConfirmed = false,
|
||||
}) async {
|
||||
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
|
||||
final visitTime = recommendationTime.add(const Duration(minutes: 90));
|
||||
// 추천 확인 시점으로 방문 시간을 기록
|
||||
final visitTime = DateTime.now();
|
||||
|
||||
await addVisitRecord(
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitTime,
|
||||
isConfirmed: false, // 나중에 확인 필요
|
||||
isConfirmed: isConfirmed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
@@ -7,12 +8,17 @@ import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
/// 현재 날씨 Provider
|
||||
final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
final location = await ref.watch(currentLocationProvider.future);
|
||||
Position? location;
|
||||
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
try {
|
||||
location = await ref.watch(currentLocationProvider.future);
|
||||
} catch (_) {
|
||||
// 위치 호출 실패 시 기본 좌표 사용
|
||||
location = defaultPosition();
|
||||
}
|
||||
|
||||
location ??= defaultPosition();
|
||||
|
||||
// 캐시된 날씨 정보 확인
|
||||
final cached = await repository.getCachedWeather();
|
||||
if (cached != null) {
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import '../../data/datasources/remote/naver_map_parser.dart';
|
||||
import '../providers/di_providers.dart';
|
||||
import '../providers/restaurant_provider.dart';
|
||||
import '../providers/location_provider.dart';
|
||||
|
||||
/// 식당 추가 화면의 상태 모델
|
||||
class AddRestaurantState {
|
||||
@@ -14,6 +16,7 @@ class AddRestaurantState {
|
||||
final Restaurant? fetchedRestaurantData;
|
||||
final RestaurantFormData formData;
|
||||
final List<Restaurant> searchResults;
|
||||
final String geocodingStatus;
|
||||
|
||||
const AddRestaurantState({
|
||||
this.isLoading = false,
|
||||
@@ -22,6 +25,7 @@ class AddRestaurantState {
|
||||
this.fetchedRestaurantData,
|
||||
required this.formData,
|
||||
this.searchResults = const [],
|
||||
this.geocodingStatus = '',
|
||||
});
|
||||
|
||||
AddRestaurantState copyWith({
|
||||
@@ -33,6 +37,7 @@ class AddRestaurantState {
|
||||
List<Restaurant>? searchResults,
|
||||
bool clearFetchedRestaurant = false,
|
||||
bool clearError = false,
|
||||
String? geocodingStatus,
|
||||
}) {
|
||||
return AddRestaurantState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
@@ -43,6 +48,7 @@ class AddRestaurantState {
|
||||
: (fetchedRestaurantData ?? this.fetchedRestaurantData),
|
||||
formData: formData ?? this.formData,
|
||||
searchResults: searchResults ?? this.searchResults,
|
||||
geocodingStatus: geocodingStatus ?? this.geocodingStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,24 +184,61 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
|
||||
/// 네이버 URL로부터 식당 정보 가져오기
|
||||
Future<void> fetchFromNaverUrl(String url) async {
|
||||
if (url.trim().isEmpty) {
|
||||
final parsed = _parseSharedNaverContent(url);
|
||||
|
||||
if (parsed.url.trim().isEmpty) {
|
||||
state = state.copyWith(errorMessage: 'URL을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 공유 텍스트에 포함된 상호명/도로명주소를 미리 채워 넣는다.
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final repository = _ref.read(restaurantRepositoryProvider);
|
||||
final restaurant = await repository.previewRestaurantFromUrl(url);
|
||||
final normalizedUrl = _normalizeUrl(parsed.url);
|
||||
state = state.copyWith(
|
||||
geocodingStatus: '지오코딩 시도: ${parsed.roadAddress ?? ''}',
|
||||
);
|
||||
final coords = await _tryGeocode(parsed.roadAddress ?? '');
|
||||
if (coords != null) {
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'지오코딩 성공: ${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)}',
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
geocodingStatus: '지오코딩 실패: 현재 위치/기본 좌표를 사용할 수 있습니다.',
|
||||
);
|
||||
}
|
||||
final newForm = state.formData.copyWith(
|
||||
name: parsed.name ?? state.formData.name,
|
||||
roadAddress: parsed.roadAddress ?? state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress,
|
||||
latitude: coords != null
|
||||
? coords.latitude.toString()
|
||||
: state.formData.latitude,
|
||||
longitude: coords != null
|
||||
? coords.longitude.toString()
|
||||
: state.formData.longitude,
|
||||
category: '기타',
|
||||
subCategory: '기타',
|
||||
naverUrl: normalizedUrl,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
fetchedRestaurantData: restaurant,
|
||||
formData: RestaurantFormData.fromRestaurant(restaurant),
|
||||
fetchedRestaurantData: newForm.toRestaurant(),
|
||||
formData: newForm,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
||||
final message = e is NaverMapParseException
|
||||
? '네이버 지도 파싱 실패: ${e.message}'
|
||||
: e.toString();
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: message,
|
||||
geocodingStatus: '지오코딩 실패: ${parsed.roadAddress ?? '주소 없음'}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +283,12 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
/// 식당 정보 저장
|
||||
Future<bool> saveRestaurant() async {
|
||||
final notifier = _ref.read(restaurantNotifierProvider.notifier);
|
||||
final fallbackCategory = state.formData.category.isNotEmpty
|
||||
? state.formData.category
|
||||
: '기타';
|
||||
final fallbackSubCategory = state.formData.subCategory.isNotEmpty
|
||||
? state.formData.subCategory
|
||||
: fallbackCategory;
|
||||
|
||||
try {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
@@ -248,12 +297,19 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
// 네이버에서 가져온 데이터가 있으면 업데이트
|
||||
final fetchedData = state.fetchedRestaurantData;
|
||||
if (fetchedData != null) {
|
||||
final coords = await _resolveCoordinates(
|
||||
latitudeText: state.formData.latitude,
|
||||
longitudeText: state.formData.longitude,
|
||||
roadAddress: state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress,
|
||||
fallbackLatitude: fetchedData.latitude,
|
||||
fallbackLongitude: fetchedData.longitude,
|
||||
);
|
||||
|
||||
restaurantToSave = fetchedData.copyWith(
|
||||
name: state.formData.name,
|
||||
category: state.formData.category,
|
||||
subCategory: state.formData.subCategory.isEmpty
|
||||
? state.formData.category
|
||||
: state.formData.subCategory,
|
||||
category: fallbackCategory,
|
||||
subCategory: fallbackSubCategory,
|
||||
description: state.formData.description.isEmpty
|
||||
? null
|
||||
: state.formData.description,
|
||||
@@ -264,19 +320,30 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
jibunAddress: state.formData.jibunAddress.isEmpty
|
||||
? state.formData.roadAddress
|
||||
: state.formData.jibunAddress,
|
||||
latitude:
|
||||
double.tryParse(state.formData.latitude) ?? fetchedData.latitude,
|
||||
longitude:
|
||||
double.tryParse(state.formData.longitude) ??
|
||||
fetchedData.longitude,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
naverUrl: state.formData.naverUrl.isEmpty
|
||||
? null
|
||||
: state.formData.naverUrl,
|
||||
updatedAt: DateTime.now(),
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
);
|
||||
} else {
|
||||
// 직접 입력한 경우
|
||||
restaurantToSave = state.formData.toRestaurant();
|
||||
final coords = await _resolveCoordinates(
|
||||
latitudeText: state.formData.latitude,
|
||||
longitudeText: state.formData.longitude,
|
||||
roadAddress: state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress,
|
||||
);
|
||||
|
||||
restaurantToSave = state.formData.toRestaurant().copyWith(
|
||||
category: fallbackCategory,
|
||||
subCategory: fallbackSubCategory,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
);
|
||||
}
|
||||
|
||||
await notifier.addRestaurantDirect(restaurantToSave);
|
||||
@@ -297,6 +364,164 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
|
||||
/// 네이버 지도 공유 텍스트에서 URL/상호명/도로명주소를 추출한다.
|
||||
_ParsedNaverShare _parseSharedNaverContent(String raw) {
|
||||
final normalized = raw.replaceAll('\r\n', '\n').trim();
|
||||
|
||||
// URL 추출
|
||||
final urlRegex = RegExp(
|
||||
r'(https?://(?:map\.naver\.com|naver\.me)[^\s]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final urlMatch = urlRegex.firstMatch(normalized);
|
||||
final url = urlMatch?.group(0) ?? normalized;
|
||||
|
||||
// 패턴: [네이버지도]\n상호명\n도로명주소\nURL
|
||||
final lines = normalized.split('\n').map((e) => e.trim()).toList();
|
||||
String? name;
|
||||
String? roadAddress;
|
||||
if (lines.length >= 4 && lines.first.contains('네이버지도')) {
|
||||
name = lines[1].isNotEmpty ? lines[1] : null;
|
||||
roadAddress = lines[2].isNotEmpty ? lines[2] : null;
|
||||
} else {
|
||||
// 줄바꿈이 없거나 공백만 있는 경우: URL 앞 부분에서 이름/주소를 분리
|
||||
final prefix = normalized.substring(0, urlMatch?.start ?? 0).trim();
|
||||
if (prefix.isNotEmpty) {
|
||||
final cleaned = prefix.replaceFirst('[네이버지도]', '').trim();
|
||||
// 주소 패턴(시/도/구/로/길 등)을 먼저 찾는다.
|
||||
final addressRegex = RegExp(
|
||||
r'(서울|부산|대구|인천|광주|대전|울산|세종|제주|경기|강원|충북|충남|전북|전남|경북|경남)[^\n]*',
|
||||
);
|
||||
final addrMatch = addressRegex.firstMatch(cleaned);
|
||||
if (addrMatch != null) {
|
||||
roadAddress = addrMatch.group(0)?.trim();
|
||||
final extractedName = cleaned.substring(0, addrMatch.start).trim();
|
||||
name = extractedName.isNotEmpty ? extractedName : null;
|
||||
} else {
|
||||
// 주소 패턴이 없으면 첫 단어가 아닌 전체를 이름으로 유지해 공백이 있어도 깨지지 않게 함
|
||||
name = cleaned.isNotEmpty ? cleaned : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _ParsedNaverShare(url: url, name: name, roadAddress: roadAddress);
|
||||
}
|
||||
|
||||
Future<({double latitude, double longitude})?> _tryGeocode(
|
||||
String roadAddress,
|
||||
) async {
|
||||
if (roadAddress.isEmpty) return null;
|
||||
try {
|
||||
final geocodingService = _ref.read(geocodingServiceProvider);
|
||||
final result = await geocodingService.geocode(roadAddress);
|
||||
if (result == null) return null;
|
||||
return (latitude: result.latitude, longitude: result.longitude);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _normalizeUrl(String rawUrl) {
|
||||
final trimmed = rawUrl.trim();
|
||||
if (trimmed.startsWith('http')) return trimmed;
|
||||
return 'https://$trimmed';
|
||||
}
|
||||
|
||||
Future<({double latitude, double longitude, bool usedCurrentLocation})>
|
||||
_resolveCoordinates({
|
||||
required String latitudeText,
|
||||
required String longitudeText,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
double? fallbackLatitude,
|
||||
double? fallbackLongitude,
|
||||
}) async {
|
||||
final parsedLat = double.tryParse(latitudeText);
|
||||
final parsedLon = double.tryParse(longitudeText);
|
||||
if (parsedLat != null && parsedLon != null) {
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'사용자 입력 좌표 사용: ${parsedLat.toStringAsFixed(6)}, ${parsedLon.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: parsedLat,
|
||||
longitude: parsedLon,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
|
||||
final geocodingService = _ref.read(geocodingServiceProvider);
|
||||
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
|
||||
if (address.isNotEmpty) {
|
||||
state = state.copyWith(geocodingStatus: '지오코딩 시도: $address');
|
||||
final result = await geocodingService.geocode(address);
|
||||
if (result != null) {
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'지오코딩 성공: ${result.latitude.toStringAsFixed(6)}, ${result.longitude.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
geocodingStatus: '지오코딩 실패: $address, 현재 위치/기본 좌표로 대체',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 주소로 좌표를 얻지 못하면 현재 위치를 활용한다.
|
||||
try {
|
||||
final position = await _ref.read(currentLocationProvider.future);
|
||||
if (position != null) {
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'현재 위치 사용: ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 위치 권한 거부/오류 시 fallback 사용
|
||||
}
|
||||
|
||||
if (fallbackLatitude != null && fallbackLongitude != null) {
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'네이버 데이터 좌표 사용: ${fallbackLatitude.toStringAsFixed(6)}, ${fallbackLongitude.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: fallbackLatitude,
|
||||
longitude: fallbackLongitude,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
|
||||
final defaultCoords = geocodingService.defaultCoordinates();
|
||||
state = state.copyWith(
|
||||
geocodingStatus:
|
||||
'기본 좌표 사용: ${defaultCoords.latitude.toStringAsFixed(6)}, ${defaultCoords.longitude.toStringAsFixed(6)}',
|
||||
);
|
||||
return (
|
||||
latitude: defaultCoords.latitude,
|
||||
longitude: defaultCoords.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParsedNaverShare {
|
||||
final String url;
|
||||
final String? name;
|
||||
final String? roadAddress;
|
||||
|
||||
_ParsedNaverShare({required this.url, this.name, this.roadAddress});
|
||||
}
|
||||
|
||||
/// AddRestaurantViewModel Provider
|
||||
|
||||
@@ -33,6 +33,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
height: 50,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
if (showAllOption && !multiSelect) ...[
|
||||
_buildCategoryChip(
|
||||
@@ -108,6 +109,8 @@ class CategorySelector extends ConsumerWidget {
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final activeColor = isDark ? AppColors.darkPrimary : AppColors.lightPrimary;
|
||||
final accentColor = isSelected ? activeColor : color;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
@@ -119,13 +122,13 @@ class CategorySelector extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? color.withOpacity(0.2)
|
||||
? accentColor.withOpacity(0.2)
|
||||
: isDark
|
||||
? AppColors.darkSurface
|
||||
: AppColors.lightBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
color: isSelected ? accentColor : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
@@ -136,7 +139,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? color
|
||||
? accentColor
|
||||
: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
@@ -146,7 +149,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? color
|
||||
? accentColor
|
||||
: isDark
|
||||
? AppColors.darkText
|
||||
: AppColors.lightText,
|
||||
|
||||
224
lib/presentation/widgets/native_ad_placeholder.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/core/utils/ad_helper.dart';
|
||||
|
||||
/// 실제 네이티브 광고(Native Ad)를 표시하는 영역.
|
||||
/// 광고 미지원 플랫폼이나 로드 실패 시 이전 플레이스홀더 스타일을 유지한다.
|
||||
class NativeAdPlaceholder extends StatefulWidget {
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double height;
|
||||
final Duration refreshInterval;
|
||||
final bool enabled;
|
||||
|
||||
const NativeAdPlaceholder({
|
||||
super.key,
|
||||
this.margin,
|
||||
this.height = 200,
|
||||
this.refreshInterval = const Duration(minutes: 2),
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NativeAdPlaceholder> createState() => _NativeAdPlaceholderState();
|
||||
}
|
||||
|
||||
class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
||||
NativeAd? _nativeAd;
|
||||
Timer? _refreshTimer;
|
||||
bool _isLoading = false;
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !widget.enabled) return;
|
||||
_loadAd();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NativeAdPlaceholder oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!widget.enabled) {
|
||||
_disposeAd();
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oldWidget.enabled && widget.enabled) {
|
||||
_loadAd();
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.refreshInterval != oldWidget.refreshInterval && _isLoaded) {
|
||||
_scheduleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeAd();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeAd() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = null;
|
||||
_nativeAd?.dispose();
|
||||
_nativeAd = null;
|
||||
}
|
||||
|
||||
void _loadAd() {
|
||||
if (!widget.enabled) return;
|
||||
if (!AdHelper.isMobilePlatform) return;
|
||||
if (!mounted) return;
|
||||
|
||||
_refreshTimer?.cancel();
|
||||
_nativeAd?.dispose();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoaded = false;
|
||||
});
|
||||
|
||||
final adUnitId = AdHelper.nativeAdUnitId;
|
||||
_nativeAd = NativeAd(
|
||||
adUnitId: adUnitId,
|
||||
request: const AdRequest(),
|
||||
nativeTemplateStyle: _buildTemplateStyle(),
|
||||
listener: NativeAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
if (!mounted) {
|
||||
ad.dispose();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoaded = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
_scheduleRefresh();
|
||||
},
|
||||
onAdFailedToLoad: (ad, error) {
|
||||
ad.dispose();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
_scheduleRefresh(retry: true);
|
||||
},
|
||||
onAdClicked: (ad) => _scheduleRefresh(),
|
||||
onAdOpened: (ad) => _scheduleRefresh(),
|
||||
),
|
||||
)..load();
|
||||
}
|
||||
|
||||
void _scheduleRefresh({bool retry = false}) {
|
||||
_refreshTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
if (!widget.enabled) return;
|
||||
final delay = retry ? const Duration(seconds: 30) : widget.refreshInterval;
|
||||
_refreshTimer = Timer(delay, _loadAd);
|
||||
}
|
||||
|
||||
NativeTemplateStyle _buildTemplateStyle() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return NativeTemplateStyle(
|
||||
templateType: TemplateType.medium,
|
||||
mainBackgroundColor: isDark ? AppColors.darkSurface : Colors.white,
|
||||
cornerRadius: 0,
|
||||
callToActionTextStyle: NativeTemplateTextStyle(
|
||||
textColor: Colors.white,
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
style: NativeTemplateFontStyle.bold,
|
||||
),
|
||||
primaryTextStyle: NativeTemplateTextStyle(
|
||||
textColor: isDark ? Colors.white : Colors.black87,
|
||||
style: NativeTemplateFontStyle.bold,
|
||||
),
|
||||
secondaryTextStyle: NativeTemplateTextStyle(
|
||||
textColor: isDark ? Colors.white70 : Colors.black54,
|
||||
style: NativeTemplateFontStyle.normal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
if (!AdHelper.isMobilePlatform || !widget.enabled) {
|
||||
return _buildPlaceholder(isDark, isLoading: false);
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: _isLoaded && _nativeAd != null
|
||||
? _buildAdView(isDark)
|
||||
: _buildPlaceholder(isDark, isLoading: _isLoading),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdView(bool isDark) {
|
||||
final containerHeight = max(widget.height, 200.0);
|
||||
return Container(
|
||||
key: const ValueKey('nativeAdLoaded'),
|
||||
margin: widget.margin ?? EdgeInsets.zero,
|
||||
height: containerHeight,
|
||||
width: double.infinity,
|
||||
decoration: _decoration(isDark),
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(bool isDark, {required bool isLoading}) {
|
||||
final containerHeight = max(widget.height, 200.0);
|
||||
return Container(
|
||||
key: const ValueKey('nativeAdPlaceholder'),
|
||||
margin: widget.margin ?? EdgeInsets.zero,
|
||||
padding: const EdgeInsets.all(16),
|
||||
height: containerHeight,
|
||||
width: double.infinity,
|
||||
decoration: _decoration(isDark),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.ad_units, color: AppColors.lightPrimary, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isLoading ? '광고 불러오는 중...' : '광고 영역',
|
||||
style: AppTypography.heading2(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration _decoration(bool isDark) {
|
||||
return BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : Colors.white,
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "lunchpick");
|
||||
gtk_header_bar_set_title(header_bar, "오늘뭐먹Z");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "lunchpick");
|
||||
gtk_window_set_title(window, "오늘뭐먹Z");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -12,6 +12,7 @@ import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||
@@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||
}
|
||||
|
||||