Compare commits

21 Commits

Author SHA1 Message Date
JiWoong Sul
bcc26f5e79 fix(ad): 스크린샷 모드에서 네이티브 광고 비활성화 2025-12-04 16:29:32 +09:00
JiWoong Sul
04b1c3e987 fix(splash): 배경 아이콘 겹침 최소화 2025-12-03 18:55:49 +09:00
JiWoong Sul
095222ef61 style(ad): 네이티브 광고 라운드 제거 2025-12-03 18:36:23 +09:00
JiWoong Sul
637507f02a fix(calendar): 월별 방문 카테고리 집계 반영 2025-12-03 18:31:37 +09:00
JiWoong Sul
3f659432e9 fix(ad): 전면 광고 몰입 모드 적용 2025-12-03 18:26:34 +09:00
JiWoong Sul
a4c7f55fc0 chore(branding): 앱 표시 이름을 오늘뭐먹Z로 변경 2025-12-03 17:51:22 +09:00
JiWoong Sul
d733bf664b feat(ads): 네이티브 광고 적용 및 디버그 스위치 이동 2025-12-03 17:25:00 +09:00
JiWoong Sul
5cae033977 chore(icon): 기본 아이콘을 appicon.png로 재생성 2025-12-03 15:51:19 +09:00
JiWoong Sul
4b0e2b4e28 chore(icon): 앱 아이콘 교체 2025-12-03 15:10:42 +09:00
JiWoong Sul
4cfff7252e docs(agents): git 코멘트 한글 규칙 추가 2025-12-03 14:51:55 +09:00
JiWoong Sul
e4c5fa7356 fix(notification): harden local alerts 2025-12-03 14:48:21 +09:00
JiWoong Sul
3ff9e5f837 feat(app): add vworld geocoding and native ads placeholders 2025-12-03 14:30:20 +09:00
JiWoong Sul
d101f7d0dc docs(guidelines): translate AGENTS to English
Converted AGENTS.md to English wording while keeping Korean requirements as examples (Korean responses/comments remain enforced).
2025-12-02 15:28:16 +09:00
JiWoong Sul
9f82a0cfda docs(guidelines): restate comment language in English
AGENTS.md note about comment/PR summaries is now written in English while still requiring Korean content with optional English terms.
2025-12-02 15:26:43 +09:00
JiWoong Sul
df4c34194c feat(debug): add preview toggles and auto-close ads
- 기록/통계 탭에 디버그 토글 배너 추가 및 테스트 데이터 주입 로직 상태화\n- 리스트 공유 화면에 디버그 프리뷰 토글, 광고 관문, 디버그 전송 흐름 반영\n- 모의 전면 광고는 대기 시간 종료 시 자동 완료되도록 변경\n- AGENTS.md에 코멘트는 한국어로 작성 규칙 명시\n\n테스트: flutter analyze; flutter test
2025-12-02 15:24:34 +09:00
JiWoong Sul
69902bbc30 feat(category): add autocomplete for subcategories 2025-12-01 18:16:28 +09:00
JiWoong Sul
0e75a23ade feat(category): add autocomplete for category inputs 2025-12-01 18:02:09 +09:00
JiWoong Sul
c1aa16c521 feat(app): stabilize recommendation flow 2025-12-01 17:22:21 +09:00
JiWoong Sul
d05e378569 test(app): add geocoding and restaurant card coverage 2025-11-26 19:16:27 +09:00
JiWoong Sul
0e8c06bade feat(app): seed restaurants, geocode addresses, refresh sharing 2025-11-26 19:01:00 +09:00
JiWoong Sul
2a01fa50c6 feat(app): finalize ad gated flows and weather
- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
2025-11-22 00:10:51 +09:00
121 changed files with 24310 additions and 1929 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 33 KiB

BIN
assets/appicon/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

16535
assets/data/store_seed.json Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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

View File

@@ -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 추가/미리보기 흐름을 검증하는 단위 테스트를 복구했습니다.

View File

@@ -30,4 +30,12 @@
- [개발 가이드](01_requirements/오늘%20뭐%20먹Z%3F%20완전한%20개발%20가이드.md)
- [아키텍처 개요](03_architecture/architecture_overview.md)
- [코드 컨벤션](03_architecture/code_convention.md)
- [네이버 URL 처리 가이드](04_api/naver_short_url_guide.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
빌드시 키값을 포함해야 함.

Binary file not shown.

View 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

View File

@@ -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

View File

@@ -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])
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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>

View 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

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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,
);
// 캐시 실패해도 계속 진행
}
}

View File

@@ -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();
}
}

View 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;
}
}
}

View File

@@ -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}');
}
}

View 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;
}
}

View 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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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)',
);
}

View File

@@ -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()}',
);
}
}

View 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();
}

View File

@@ -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');
}
}

View File

@@ -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(),
};

View File

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

View File

@@ -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);

View 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;
}
}

View File

@@ -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,
}

View File

@@ -37,6 +37,12 @@ abstract class SettingsRepository {
/// 알림 활성화 여부를 설정합니다
Future<void> setNotificationEnabled(bool enabled);
/// 스크린샷 모드 활성화 여부를 가져옵니다
Future<bool> isScreenshotModeEnabled();
/// 스크린샷 모드 활성화 여부를 설정합니다
Future<void> setScreenshotModeEnabled(bool enabled);
/// 다크모드 설정을 가져옵니다
Future<bool> isDarkModeEnabled();

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;
}

View File

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

View File

@@ -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(),
);
}
}

View File

@@ -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')}';
}

View File

@@ -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;

View File

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

View File

@@ -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(

View File

@@ -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()),

View File

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

View File

@@ -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(),
),

View File

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

View File

@@ -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,
),
],
),

View File

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

View File

@@ -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;

View File

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

View File

@@ -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) {

View File

@@ -0,0 +1,3 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
final debugSharePreviewProvider = StateProvider<bool>((ref) => false);

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

View File

@@ -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();
});

View File

@@ -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());
}
}
}

View File

@@ -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);
// 에러 발생 시 기본적으로 캘린더 화면으로 이동

View File

@@ -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,

View File

@@ -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;
});
});

View File

@@ -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();

View File

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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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,

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

View File

@@ -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);

View File

@@ -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"))
}

Some files were not shown because too many files have changed in this diff Show More