Compare commits

36 Commits

Author SHA1 Message Date
JiWoong Sul
f5a02f581e docs(guide): 개발 가이드 문서 업데이트 2026-01-21 17:03:37 +09:00
JiWoong Sul
1cbf9ca82c chore(app): 저작권 표시에 cclabs 추가
- © 2025. NatureBridgeAI & cclabs. All rights reserved.
2026-01-21 17:03:31 +09:00
JiWoong Sul
a9fb5695fb chore(version): 1.0.2+3 버전 업데이트
- versionName: 1.0.2
- versionCode: 3
2026-01-12 15:16:11 +09:00
JiWoong Sul
6f45c7b456 perf(app): 초기화 병렬 처리 및 UI 개선
## 성능 최적화

### main.dart
- 앱 초기화 병렬 처리 (Future.wait 활용)
- 광고 SDK, Hive 초기화 동시 실행
- Hive Box 오픈 병렬 처리
- 코드 구조화 (_initializeHive, _initializeNotifications)

### visit_provider.dart
- allLastVisitDatesProvider 추가
- 리스트 화면에서 N+1 쿼리 방지
- 모든 맛집의 마지막 방문일 일괄 조회

## UI 개선

### 각 화면 리팩토링
- AppDimensions 상수 적용
- 스켈레톤 로더 적용
- 코드 정리 및 일관성 개선
2026-01-12 15:16:05 +09:00
JiWoong Sul
21941443ee feat(core): 공통 UI 컴포넌트 및 상수 추가
## 새 파일

### AppDimensions (app_dimensions.dart)
- UI 관련 상수 중앙 집중화
- 패딩, 마진, 보더 레디우스, 아이콘 크기 등 정의
- 하드코딩된 값을 상수로 대체하여 일관성 확보

### InfoRow (info_row.dart)
- 레이블-값 쌍을 표시하는 공통 위젯
- 수평/수직 배치 지원

### SkeletonLoader (skeleton_loader.dart)
- Shimmer 효과를 가진 스켈레톤 로더
- RestaurantCardSkeleton, RestaurantListSkeleton 포함
- 로딩 상태 UX 개선
2026-01-12 15:15:56 +09:00
JiWoong Sul
32e25aeb07 refactor(cleanup): 사용하지 않는 코드 정리
## 삭제된 파일

### 미사용 예외 클래스
- lib/core/errors/app_exceptions.dart
- lib/core/errors/data_exceptions.dart
  - 정의만 되어 있고 실제 코드에서 사용되지 않음
  - network_exceptions.dart는 활발히 사용 중이므로 유지

### 미사용 공통 위젯
- lib/core/widgets/empty_state_widget.dart
- lib/core/widgets/loading_indicator.dart
  - 정의만 되어 있고 다른 화면에서 import하지 않음
  - error_widget.dart는 share_screen에서 사용 중이므로 유지

### 미사용 디버그 위젯
- lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart
  - 테스트 데이터 배너 위젯이지만 calendar_screen에서 사용하지 않음

### 백업 파일
- lib/data/api/naver_api_client.dart.backup
- lib/presentation/pages/restaurant_list/widgets/add_restaurant_dialog.dart.backup
  - 불필요한 백업 파일 정리

## 검증
- flutter analyze 통과
2025-12-22 19:45:22 +09:00
JiWoong Sul
42c609c57a chore(config): 프로젝트 설정 및 문서 개선
## CLAUDE.md 전면 개편
- 글로벌 규칙(~/.claude/CLAUDE.md)과 중복되는 일반 개발 가이드라인 제거
- 프로젝트 특화 정보만 유지 (331줄 → 100줄, 약 70% 감소)
- 추가된 내용:
  - 앱 이름, 패키지명, SDK 버전 명시
  - 핵심 기술 스택 테이블 (Riverpod, Hive, go_router, Dio 등)
  - 실제 프로젝트 디렉토리 구조 문서화
  - 주요 도메인 엔티티 설명 (Restaurant, VisitRecord 등)
  - 프로젝트 전용 빌드 명령어
- AGENTS.md 참조 링크 추가

## Android 릴리즈 서명 설정
- build.gradle.kts에 릴리즈 signingConfig 추가
- 키스토어: doc/key/lunchpick-release.keystore
- release 빌드 타입에 릴리즈 서명 적용
- 불필요한 TODO 주석 제거

## 개인정보 처리방침 문구 수정
- "네이버 지도 연동" → "네이버 검색 연동" (실제 동작 반영)
- "네이버 지도에서 가져온" → "네이버 URL에서 가져온" (정확한 표현)
- 미사용 Open API 관련 문구 제거
2025-12-08 18:15:22 +09:00
JiWoong Sul
cf7e187985 fix(privacy): 개인정보 처리방침 및 지오코딩 동작 정리
- 스토어 설명에 네이버 지도앱 공유 링크를 수정하지 않고 그대로 붙여넣어야 한다는 안내를 추가하고, 실제 동작과 맞는 URL 사용 조건을 명시했습니다.
- doc/store_desc/privacy_policy.md에 현재 구현 기준(키 기반 네이버 로컬 검색 미사용, 네이버 지도 웹/GraphQL 파싱, VWorld+Nominatim 지오코딩, 기상청 Open API, Google AdMob)을 반영한 개인정보 처리방침을 추가/정리했습니다.
- lib/data/api/naver_api_client.dart에서 searchLocal 구현을 변경하여 네이버 로컬 검색 Open API를 더 이상 호출하지 않고, 항상 빈 결과를 반환하면서 디버그 로그만 남기도록 비활성화했습니다.
- 네이버 URL/검색으로 가져온 식당 정보를 편집하는 뷰에서 위도/경도 필드를 선택 입력으로 완화하여, 지오코딩 실패 시에도 폼 검증만으로 저장이 막히지 않도록 조정했습니다.
- AddRestaurantViewModel._resolveCoordinates에 allowFallbackWhenGeocodingFails 플래그를 추가하고, 네이버 URL 기반 추가 시에는 지오코딩 실패를 현재 위치/기본 좌표로 자동 대체하지 않고 명시적인 오류로 처리하여, 잘못된 주소로 저장되지 않도록 했습니다.
2025-12-05 19:26:11 +09:00
JiWoong Sul
0c6b10d4f6 fix(ad): 화면별 네이티브 광고 높이 조정 2025-12-05 16:13:54 +09:00
JiWoong Sul
753f578504 fix(ad): 높이에 따라 템플릿/최소높이 자동 조정 2025-12-05 16:06:30 +09:00
JiWoong Sul
887f1ad6fe fix(ad): 네이티브 템플릿 높이 보정 2025-12-05 15:52:16 +09:00
JiWoong Sul
0e45616dfd chore(app): 스플래시 권한 대기 시간을 3초로 축소
- 권한 요청 Future 타임아웃을 5초에서 3초로 줄여 스플래시 체류 시간을 단축
2025-12-04 16:40:37 +09:00
JiWoong Sul
99ad8a3bd5 chore(app): 추천 기록 탭 대응과 스플래시 대기 제한
- RecommendationRecordCard에 onTap 콜백을 추가하고 카드 전체를 InkWell로 감싸 탭 제스처를 받을 수 있게 함\n- _navigateToHome에서 권한 요청 Future를 5초 타임아웃으로 감싸 스플래시에서 무한 대기를 막고, 완료 여부와 관계없이 홈으로 이동하도록 정리\n- 변경 의도 주석을 추가해 동작 맥락을 명시
2025-12-04 16:35:27 +09:00
JiWoong Sul
2857fe1cb6 chore(repo): keystore 아티팩트 무시
- doc/key/ 디렉터리와 keystore 확장자를 무시해 인증서/비밀번호 노출을 방지
2025-12-04 16:35:15 +09:00
JiWoong Sul
6a3e8f30d8 docs(store): 스토어 설명과 캘린더 UX 계획 추가
- 앱 스토어용 한 줄/상세 설명 초안을 정리\n- 캘린더·통계/추천 화면 개선 범위와 검증 항목을 문서화
2025-12-04 16:35:10 +09:00
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
135 changed files with 25145 additions and 4422 deletions

5
.gitignore vendored
View File

@@ -52,6 +52,11 @@ local.properties
/android/local.properties
/ios/Flutter/ephemeral/
# Keystore & secrets
*.jks
*.keystore
doc/key/
# Test Hive files
test_hive/

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.

387
CLAUDE.md
View File

@@ -1,331 +1,100 @@
# Claude Code Global Development Rules
# LunchPick - 점심 메뉴 추천 앱
## 🌐 Language Settings
- **All answers and explanations must be provided in Korean**
- **Variable and function names in code should use English**
- **Error messages should be explained in Korean**
> 글로벌 규칙(~/.claude/CLAUDE.md) 상속. 상세 가이드는 [AGENTS.md](AGENTS.md) 참조.
## 🤖 Agent Selection Rules
- **Always select and use a specialized agent appropriate for the task**
## 프로젝트 개요
## 🎯 Mandatory Response Format
- **앱 이름**: 오늘 뭐 먹Z?
- **패키지**: `com.naturebridgeai.lunchpick`
- **SDK**: Flutter 3.8.1+ / Dart 3.8.1+
Before starting any task, you MUST respond in the following format:
## 핵심 기술 스택
```
[Model Name] - [Agent Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master!
| 분류 | 패키지 |
|------|--------|
| 상태관리 | Riverpod + riverpod_generator |
| 로컬저장 | Hive + hive_generator |
| 네비게이션 | go_router |
| 네트워크 | Dio + dio_cache_interceptor |
| 위치/권한 | geolocator, permission_handler |
| 광고 | google_mobile_ads |
## 프로젝트 구조
```text
lib/
├── core/
│ ├── constants/ # app_constants, app_colors, api_keys
│ ├── network/ # network_client, interceptors
│ ├── services/ # permission, geocoding, ad, bluetooth, notification
│ ├── errors/ # app_exceptions, network_exceptions
│ └── widgets/ # 공통 위젯 (loading, error, empty_state)
├── data/
│ ├── api/ # naver_api_client, naver GraphQL/LocalSearch
│ ├── datasources/ # local, remote (naver_html_parser 등)
│ ├── repositories/ # *_repository_impl
│ └── models/ # DTO, Hive adapters
├── domain/
│ ├── entities/ # Restaurant, VisitRecord, UserSettings, WeatherInfo
│ ├── repositories/ # 인터페이스 정의
│ └── usecases/ # 비즈니스 로직
└── presentation/
├── providers/ # Riverpod providers
├── view_models/ # 화면 상태 관리
└── pages/ # splash, main, random_selection, restaurant_list,
# calendar, settings, share
```
**Agent Names:**
- **Direct Implementation**: Perform direct implementation tasks
- **Master Manager**: Overall project management and coordination
- **flutter-ui-designer**: Flutter UI/UX design
- **flutter-architecture-designer**: Flutter architecture design
- **flutter-offline-developer**: Flutter offline functionality development
- **flutter-network-engineer**: Flutter network implementation
- **flutter-qa-engineer**: Flutter QA/testing
- **app-launch-validator**: App launch validation
- **aso-optimization-expert**: ASO optimization
- **mobile-growth-hacker**: Mobile growth strategy
- **Idea Analysis**: Idea analysis
- **mobile app mvp planner**: MVP planning
## 주요 도메인 엔티티
**Examples:**
- `Claude Opus 4 - Direct Implementation. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!`
- `Claude Opus 4 - flutter-network-engineer. I have reviewed all the following rules: API integration, error handling, network optimization. Proceeding with the task. Master!`
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary)
- `Restaurant`: 음식점 정보 (이름, 카테고리, 위치, 영업시간 등)
- `VisitRecord`: 방문 기록
- `RecommendationRecord`: 추천 기록
- `UserSettings`: 사용자 설정 (반경, 카테고리 필터 등)
- `WeatherInfo`: 날씨 정보 (추천 알고리즘 활용)
## 필수 명령어
```bash
# 의존성 설치
flutter pub get
## 🚀 Mandatory 3-Phase Task Process
# 코드 생성 (Hive adapters, Riverpod, JSON)
dart run build_runner build --delete-conflicting-outputs
### Phase 1: Codebase Exploration & Analysis
**Required Actions:**
- Systematically discover ALL relevant files, directories, modules
- Search for related keywords, functions, classes, patterns
- Thoroughly examine each identified file
- Document coding conventions and style guidelines
- Identify framework/library usage patterns
- Map dependencies and architectural structure
# 개발 중 자동 생성
dart run build_runner watch --delete-conflicting-outputs
### Phase 2: Implementation Planning
**Required Actions:**
- Create detailed implementation roadmap based on Phase 1 findings
- Define specific task lists and acceptance criteria per module
- Specify performance/quality requirements
- Plan test strategy and coverage
- Identify potential risks and edge cases
# 분석 & 테스트
flutter analyze
flutter test
flutter test test_hive # Hive 변경 시
### Phase 3: Implementation Execution
**Required Actions:**
- Implement each module following Phase 2 plan
- Verify ALL acceptance criteria before proceeding
- Ensure adherence to conventions identified in Phase 1
- Write tests alongside implementation
- Document complex logic and design decisions
## ✅ Core Development Principles
### Language & Documentation Rules
- **Code, variables, and identifiers**: Always in English
- **Comments and documentation**: Use project's primary spoken language
- **Commit messages**: Use project's primary spoken language
- **Error messages**: Bilingual when appropriate (technical term + native explanation)
### Type Safety Rules
- **Always declare types explicitly** for variables, parameters, and return values
- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary)
- Define **custom types/interfaces** for complex data structures
- Use **enums** for fixed sets of values
- Extract magic numbers and literals into named constants
### Naming Conventions
|Element|Style|Example|
|---|---|---|
|Classes/Interfaces|`PascalCase`|`UserService`, `DataRepository`|
|Variables/Methods|`camelCase`|`userName`, `calculateTotal`|
|Constants|`UPPERCASE` or `PascalCase`|`MAX_RETRY_COUNT`, `DefaultTimeout`|
|Files (varies by language)|Follow language convention|`user_service.py`, `UserService.java`|
|Boolean variables|Verb-based|`isReady`, `hasError`, `canDelete`|
|Functions/Methods|Start with verbs|`executeLogin`, `saveUser`, `validateInput`|
**Critical Rules:**
- Use meaningful, descriptive names
- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL`
- Name length should reflect scope (longer names for wider scope)
## 🔧 Function & Method Design
### Function Structure Principles
- **Keep functions short and focused** (≤20 lines recommended)
- **Follow Single Responsibility Principle (SRP)**
- **Minimize parameters** (≤3 ideal, use objects for more)
- **Avoid deeply nested logic** (≤3 levels)
- **Use early returns** to reduce complexity
- **Extract complex conditions** into well-named functions
### Function Optimization Techniques
- Prefer **pure functions** without side effects
- Use **default parameters** to reduce overloading
- Apply **RO-RO pattern** (Receive Object Return Object) for complex APIs
- **Cache expensive computations** when appropriate
- **Avoid premature optimization** - profile first
## 📦 Data & Class Design
### Class Design Principles
- **Single Responsibility Principle (SRP)**: One class, one purpose
- **Favor composition over inheritance**
- **Program to interfaces**, not implementations
- **Keep classes cohesive** - high internal, low external coupling
- **Prefer immutability** when possible
### File Size Management
**Guidelines (not hard limits):**
- Classes: ≤200 lines
- Functions: ≤20 lines
- Files: ≤300 lines
**Split when:**
- Multiple responsibilities exist
- Excessive scrolling required
- Pattern duplication occurs
- Testing becomes complex
### Data Model Design
- **Encapsulate validation** within data models
- **Use Value Objects** for complex primitives
- **Apply Builder pattern** for complex object construction
- **Implement proper equals/hashCode** for data classes
## ❗ Exception Handling
### Exception Usage Principles
- Use exceptions for **exceptional circumstances only**
- **Fail fast** at system boundaries
- **Catch exceptions only when you can handle them**
- **Add context** when re-throwing
- **Use custom exceptions** for domain-specific errors
- **Document thrown exceptions**
### Error Handling Strategies
- Return **Result/Option types** for expected failures
- Use **error codes** for performance-critical paths
- Implement **circuit breakers** for external dependencies
- **Log errors appropriately** (error level, context, stack trace)
## 🧪 Testing Strategy
### Test Structure
- Follow **Arrange-Act-Assert (AAA)** pattern
- Use **descriptive test names** that explain what and why
- **One assertion per test** (when practical)
- **Test behavior, not implementation**
### Test Coverage Guidelines
- **Unit tests**: All public methods and edge cases
- **Integration tests**: Critical paths and external integrations
- **End-to-end tests**: Key user journeys
- Aim for **80%+ code coverage** (quality over quantity)
### Test Best Practices
- **Use test doubles** (mocks, stubs, fakes) appropriately
- **Keep tests independent** and idempotent
- **Test data builders** for complex test setups
- **Parameterized tests** for multiple scenarios
- **Performance tests** for critical paths
## 📝 Version Control Guidelines
### Commit Best Practices
- **Atomic commits**: One logical change per commit
- **Frequent commits**: Small, incremental changes
- **Clean history**: Use interactive rebase when needed
- **Branch strategy**: Follow project's branching model
### Commit Message Format
```
type(scope): brief description
Detailed explanation if needed
- Bullet points for multiple changes
- Reference issue numbers: #123
BREAKING CHANGE: description (if applicable)
# 릴리즈 빌드
flutter build appbundle --release
```
### Commit Types
- `feat`: New feature
- `fix`: Bug fix
- `refactor`: Code refactoring
- `perf`: Performance improvement
- `test`: Test changes
- `docs`: Documentation
- `style`: Code formatting
- `chore`: Build/tooling changes
## Agent 응답 형식
## 🏗️ Architecture Guidelines
### Clean Architecture Principles
- **Dependency Rule**: Dependencies point inward
- **Layer Independence**: Each layer has single responsibility
- **Testability**: Business logic independent of frameworks
- **Framework Agnostic**: Core logic doesn't depend on external tools
### Common Architectural Patterns
- **Repository Pattern**: Abstract data access
- **Service Layer**: Business logic coordination
- **Dependency Injection**: Loose coupling
- **Event-Driven**: For asynchronous workflows
- **CQRS**: When read/write separation needed
### Module Organization
```
src/
├── domain/ # Business entities and rules
├── application/ # Use cases and workflows
├── infrastructure/ # External dependencies
├── presentation/ # UI/API layer
└── shared/ # Cross-cutting concerns
```text
[Model Name] - [Agent Name]. I have reviewed all the following rules: [categories]. Proceeding with the task. Master!
```
## 🔄 Safe Refactoring Practices
### Available Agents
### Preventing Side Effects During Refactoring
- **Run all tests before and after** every refactoring step
- **Make incremental changes**: One small refactoring at a time
- **Use automated refactoring tools** when available (IDE support)
- **Preserve existing behavior**: Refactoring should not change functionality
- **Create characterization tests** for legacy code before refactoring
- **Use feature flags** for large-scale refactorings
- **Monitor production metrics** after deployment
| Agent | 용도 |
|-------|------|
| Direct Implementation | 직접 구현 |
| flutter-ui-designer | UI/UX 디자인 |
| flutter-architecture-designer | 아키텍처 설계 |
| flutter-network-engineer | 네트워크/API |
| flutter-qa-engineer | QA/테스트 |
| app-launch-validator | 출시 검증 |
| aso-optimization-expert | ASO 최적화 |
### Refactoring Checklist
1. **Before Starting**:
- [ ] All tests passing
- [ ] Understand current behavior completely
- [ ] Create backup branch
- [ ] Document intended changes
## 주의사항
2. **During Refactoring**:
- [ ] Keep commits atomic and reversible
- [ ] Run tests after each change
- [ ] Verify no behavior changes
- [ ] Check for performance impacts
3. **After Completion**:
- [ ] All tests still passing
- [ ] Code coverage maintained or improved
- [ ] Performance benchmarks verified
- [ ] Peer review completed
### Common Refactoring Patterns
- **Extract Method**: Break large functions into smaller ones
- **Rename**: Improve clarity with better names
- **Move**: Relocate code to appropriate modules
- **Extract Variable**: Make complex expressions readable
- **Inline**: Remove unnecessary indirection
- **Extract Interface**: Decouple implementations
## 🧠 Continuous Improvement
### Code Review Focus Areas
- **Correctness**: Does it work as intended?
- **Clarity**: Is it easy to understand?
- **Consistency**: Does it follow conventions?
- **Completeness**: Are edge cases handled?
- **Performance**: Are there obvious bottlenecks?
- **Security**: Are there vulnerabilities?
- **Side Effects**: Are there unintended consequences?
### Knowledge Sharing
- **Document decisions** in ADRs (Architecture Decision Records)
- **Create runbooks** for operational procedures
- **Maintain README** files for each module
- **Share learnings** through team discussions
- **Update rules** based on team consensus
## ✅ Quality Validation Checklist
Before completing any task, confirm:
### Phase Completion
- [ ] Phase 1: Comprehensive analysis completed
- [ ] Phase 2: Detailed plan with acceptance criteria
- [ ] Phase 3: Implementation meets all criteria
### Code Quality
- [ ] Follows naming conventions
- [ ] Type safety enforced
- [ ] Single Responsibility maintained
- [ ] Proper error handling
- [ ] Adequate test coverage
- [ ] Documentation complete
### Best Practices
- [ ] No code smells or anti-patterns
- [ ] Performance considerations addressed
- [ ] Security vulnerabilities checked
- [ ] Accessibility requirements met
- [ ] Internationalization ready (if applicable)
## 🎯 Success Metrics
### Code Quality Indicators
- **Low cyclomatic complexity** (≤10 per function)
- **High cohesion**, low coupling
- **Minimal code duplication** (<5%)
- **Clear separation of concerns**
- **Consistent style throughout**
### Professional Standards
- **Readable**: New developers understand quickly
- **Maintainable**: Changes are easy to make
- **Testable**: Components tested in isolation
- **Scalable**: Handles growth gracefully
- **Reliable**: Fails gracefully with clear errors
---
**Remember**: These are guidelines, not rigid rules. Use professional judgment and adapt to project needs while maintaining high quality standards.
- `api_keys.dart`는 커밋 금지 (로컬 생성)
- Android/iOS 빌드 설정 변경 시 승인 필요
- Hive 스키마 변경 시 마이그레이션 고려
- 네이버 API 호출 시 캐시 정책 준수

View File

@@ -20,22 +20,30 @@ android {
jvmTarget = JavaVersion.VERSION_11.toString()
}
signingConfigs {
create("release") {
storeFile = file("../../doc/key/lunchpick-release.keystore")
storePassword = "lunchpick"
keyAlias = "lunchpick"
keyPassword = "lunchpick"
}
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
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 {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

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

@@ -520,7 +520,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
left: 0,
right: 0,
child: Text(
'© 2025. NatureBridgeAI. All rights reserved.',
'© 2025. NatureBridgeAI & cclabs. All rights reserved.',
style: AppTypography.caption(isDark).copyWith(
color: (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary)
.withOpacity(0.5),

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

@@ -0,0 +1,190 @@
# 오늘 뭐 먹Z? 개인정보 처리방침
오늘 뭐 먹Z? (이하 “앱”)는 사용자의 개인정보 보호를 최우선 가치로 삼습니다.
앱은 점심 메뉴 추천 및 맛집 관리 기능 제공을 위해 최소한의 정보만을 사용하며, **개발자가 직접 운영하는 별도 서버를 두지 않습니다.**
다만, 위치 기반 추천, 날씨 정보 제공, 네이버 검색 연동, 광고 노출 및 좌표 확인을 위해 제3자 서비스와 통신할 수 있습니다.
본 개인정보 처리방침은 앱이 어떤 정보를 어떤 목적과 방식으로 처리하는지 설명합니다.
---
## 1. 수집하는 개인정보
### 1-1. 직접 식별 가능한 정보
앱은 다음과 같은 의미에서 사용자를 직접 식별할 수 있는 개인정보(이름, 이메일, 전화번호 등)를 **직접 수집하지 않습니다.**
- 회원가입, 로그인 기능이 없습니다.
- 주민등록번호, 이름, 이메일, 전화번호, 주소 등 사용자를 직접 식별할 수 있는 정보를 요구하지 않습니다.
- 개발자가 운영하는 자체 서버로 사용자의 개인정보를 수집·저장하지 않습니다.
### 1-2. 서비스 제공을 위해 처리되는 정보
앱 기능 제공을 위해 다음과 같은 정보가 기기 내에서 처리되거나 제3자 서비스로 전송될 수 있습니다.
1) **위치 정보**
- 내용: 현재 위치 좌표(위도, 경도)
- 사용 목적
- 주변 맛집 추천 및 거리 계산
- 날씨(기상청 Open API) 조회
- 현재 위치 반경 내 추천 후보를 제한하는 데 활용
- 처리 방식
- 위치 정보는 주로 **실시간 계산 및 API 호출**에 사용되며, 사용자의 “위치 이력”을 장기적으로 별도 저장하지 않습니다.
- 앱에서 저장하는 위치 정보는 주로 “식당 좌표(맛집 위치)”이며, 이는 사용자의 거주지나 신원과 직접 연결되지 않습니다.
- 위치 정보는 기상청 Open API 등 날씨 서비스에 한해, 격자 좌표(nx, ny) 형태로 전송될 수 있습니다(자세한 내용은 아래 3절 참조).
2) **맛집 및 방문 기록 정보**
- 내용
- 사용자가 직접 입력하거나 네이버 URL에서 가져온 식당 정보
- 식당 이름, 카테고리, 설명, 전화번호, 도로명/지번 주소
- 위도·경도, 주소, 영업시간 등
- 방문 기록 및 통계 정보
- 방문 일자, 방문 여부, 방문 횟수 등
- 사용 목적
- 점심 메뉴 추천, 중복 방문 방지, 방문 기록 조회, 통계 제공 등
- 처리 방식
- 위 정보는 모두 **사용자의 기기 내 로컬 데이터베이스**에만 저장됩니다.
- 개발자는 이 데이터를 서버를 통해 열람하거나 수집하지 않습니다.
3) **앱 사용 설정 정보**
- 내용: 알림 시간, 추천 거리/날씨 관련 설정, 다크모드 여부 등 앱 내 환경 설정
- 사용 목적: 사용자 맞춤 추천 및 알림 제공
- 처리 방식: 기기 내 로컬 저장소에만 저장되며, 서버로 전송되지 않습니다.
---
## 2. 데이터 저장 및 처리 방식
1) **로컬 저장소(기기 내 저장소)**
- 맛집 정보, 방문 기록, 앱 설정, 일부 캐시 데이터(날씨 등)는
**기기 내 로컬 데이터베이스에만 저장**됩니다.
- 앱을 삭제하면 일반적으로 해당 앱과 연계된 로컬 데이터도 함께 삭제됩니다.
다만, 운영체제나 백업 설정에 따라 일부 데이터가 OS 또는 클라우드 백업에 남을 수 있으며, 이는 각 플랫폼(예: Apple, Google)의 정책을 따릅니다.
2) **네트워크 통신 및 제3자 전송**
앱은 자체 서버를 운영하지 않지만, 다음과 같은 제3자 서비스와 통신합니다.
- **지도·식당 정보 제공을 위한 네이버 지도 웹 서비스**
- 전송되는 정보(예시): 사용자가 앱에 붙여넣은 네이버 지도 URL, 해당 URL에 포함된 식당 ID 등
- 사용 목적: 네이버 지도 페이지 및 관련 API(예: GraphQL)를 통해 식당 이름·주소·좌표·전화번호 등을 조회하고, 앱 내 식당 정보로 변환
- **지오코딩(Geocoding) 서비스: OpenStreetMap Nominatim**
- 전송되는 정보(예시): 사용자가 입력한 식당 주소(도로명·지번 등)
- 사용 목적: 주소를 위도·경도 좌표로 변환하여 지도/거리 계산 및 추천 알고리즘에 활용
- **기상청 Open API(공공데이터포털)**
- 전송되는 정보(예시): 위치를 기반으로 변환된 격자 좌표(nx, ny)
- 사용 목적: 현재 및 단기(1시간 후) 날씨 정보 조회
- **광고 네트워크(Google AdMob 등)**
- 자세한 내용은 아래 3절 “광고 및 제3자 서비스” 참조
앱 개발자는 이들 제3자 서비스의 서버에 저장되는 데이터에 직접 접근하지 않으며,
제3자 서비스에서 수집·처리하는 정보는 각 서비스의 개인정보 처리방침을 따릅니다.
---
## 3. 광고 및 제3자 서비스
앱은 무료 제공을 위해 광고를 노출할 수 있으며, 이 과정에서 **Google AdMob** 등 제3자 광고 네트워크가 참여합니다.
### 3-1. 광고 네트워크가 수집할 수 있는 정보
앱은 직접 사용자의 개인정보를 수집하지 않지만, 광고 네트워크는 다음과 같은 정보를 수집·처리할 수 있습니다(예시).
- 광고 식별자 (예: Android 광고 ID, IDFA 등)
- 기기 정보 (단말기 모델명, OS 버전, 언어/국가 설정 등)
- 대략적인 위치 정보 (국가/지역 수준, IP 기반 위치 등)
- 앱 사용 정보 (광고 조회/클릭 여부, 광고 노출 횟수 등)
이러한 정보는 **개발자가 아닌 광고 네트워크 사업자**가 수집·처리하며,
수집 범위와 이용 목적은 해당 사업자의 개인정보 처리방침을 따릅니다.
보다 자세한 내용은 각 서비스의 정책을 참고하세요.
- Google AdMob / Google Mobile Ads: https://policies.google.com/privacy
- 네이버(Naver): https://policy.naver.com/
- 공공데이터포털·기상청 Open API: 각 제공 기관의 개인정보 처리방침
### 3-2. 제3자 서비스 관련 안내
- 앱은 제3자 서비스에 사용자의 이름, 이메일, 전화번호 등 **직접 식별 정보**를 의도적으로 전송하지 않습니다.
- 위치 정보, 검색어, 기기 정보 등은 제3자 서비스의 기술적 처리 과정에서 사용될 수 있습니다.
- 제3자 서비스에서 제공하는 맞춤형 광고 또는 추천 기능 등은 해당 서비스의 정책에 따라 동작합니다.
---
## 4. 권한 사용
앱은 기능 제공을 위해 다음과 같은 권한을 사용할 수 있습니다.
각 권한은 **명시된 목적 외의 용도로 사용되지 않습니다.**
1) **위치 권한**
- 사용 목적
- 현재 위치를 기준으로 주변 맛집을 추천하기 위해 사용
- 현재/1시간 후 날씨 정보를 조회하기 위해 사용
- 추천 대상이 되는 식당 범위를 “현재 위치 반경”으로 제한하기 위해 사용
- 특징
- 위치 권한을 거부할 경우, 앱은 서울 시청(기본 좌표)을 기준으로 동작합니다.
- 사용자의 위치 이력을 장기적으로 추적하거나, 별도 계정과 연결하여 프로파일링하지 않습니다.
2) **알림 권한**
- 사용 목적
- 점심 식사 후 방문 기록을 남기도록 안내하는 **방문 확인 알림** 발송
- 알림 진동·소리, 정확한 시각의 알림 예약, 기기 재부팅 후 예약 알림 복원
- 특징
- 알림 내용에는 주로 추천된 식당 이름, 방문 여부 확인 요청 등의 간단한 메시지가 포함됩니다.
- 알림 권한을 거부해도 앱의 기본 사용은 가능하나, 방문 리마인더 기능은 제한될 수 있습니다.
3) **블루투스 권한**
- 사용 목적
- 주변 기기와 **맛집 리스트를 공유**하기 위해 사용
- 팀/동료와 함께 맛집 목록을 교환하는 기능에 활용
- 특징
- 공유 대상 데이터는 식당 이름, 주소, 카테고리 등으로, 사용자의 이름·이메일 등 직접 식별 정보는 포함하지 않습니다.
- 공유는 기기간 통신을 전제로 하며, 개발자가 운영하는 중앙 서버를 경유하지 않습니다(향후 실제 Bluetooth 스택 도입 시에도 동일 원칙을 적용합니다).
4) **네트워크 권한**
- 사용 목적
- 네이버 지도 페이지 및 관련 API 호출(붙여넣은 지도 링크 기반 식당 정보 조회)
- 지오코딩 서비스(OpenStreetMap Nominatim) 및 기상청 Open API 호출(좌표·날씨 정보)
- 광고(Google AdMob) 로딩 및 통계 전송
- 특징
- 앱은 별도의 자체 백엔드 서버를 운영하지 않으며, 네트워크 요청은 위와 같은 제3자 API·광고 서비스에 한정됩니다.
---
## 5. 아동의 개인정보
- 앱은 성인(직장인 등 일반 사용자)을 주요 대상으로 설계되었습니다.
- 만 14세 미만(또는 각 국가에서 정한 연령 기준 미만)의 아동을 대상으로 개인정보를 수집하려는 의도가 없습니다.
- 만약 아동의 개인정보가 부주의로 수집된 사실을 인지하게 될 경우, 가능한 한 신속히 해당 정보를 삭제하기 위한 조치를 취하겠습니다.
---
## 6. 개인정보 처리방침의 변경
- 본 개인정보 처리방침은 서비스 개선, 관련 법령 및 가이드라인 개정, 기능 추가/변경 등에 따라 수시로 수정될 수 있습니다.
- 중요한 내용(수집 항목, 이용 목적, 제3자 제공 등)이 변경되는 경우, 앱 내 공지 또는 스토어 설명 등을 통해 변경 내용을 안내하겠습니다.
- 변경된 개인정보 처리방침은 명시된 시행일로부터 효력이 발생합니다.
**시행일자: 2025.12.05**
---
## 7. 문의처
앱의 개인정보 처리방침과 관련하여 문의, 의견 제출, 권리 행사(열람, 정정, 삭제 요청 등)가 필요하신 경우 아래 연락처로 문의해 주세요.
- 담당자: 네이처브릿지AI 앱개발팀
- 이메일: naturebridgeai@gmail.com

View File

@@ -0,0 +1,52 @@
한 줄 설명
직장인 점심 고민 끝, 날씨·거리 맞춘 랜덤 맛집 추천
상세 설명
매일 점심마다 “오늘 뭐 먹지?” 하며 10분 넘게 고민하다가, 결국 늘 가던 곳만 돌고 있지 않으신가요?
‘오늘 뭐 먹Z?’는 한국 직장인을 위해 날씨와 거리, 최근 방문 기록까지 고려해서, 내가 등록해 둔 믿을 수 있는 맛집들 중 딱 한 곳을 랜덤으로 골라주는 스마트 점심 추천 앱입니다. 메뉴 고민 시간을 10~15분에서 1분으로 줄여 보세요.
■ 이런 분께 딱이에요
- 회사 주변 맛집은 많은데, 무엇을 갈지 매번 고르기 힘든 직장인
- 새로운 가게도 가고 싶지만, 연속 방문은 피하고 싶은 분
- 팀 점심·회식 메뉴를 빠르게 정해야 하는 리더/매니저
- 내가 어디를 얼마나 자주 갔는지, 데이터로 관리하고 싶은 사람
■ 주요 기능
1. 날씨·거리 기반 랜덤 추천
- 현재 날씨와 1시간 후 예보를 함께 보여주고, 비 오는 날에는 자동으로 가까운 맛집 위주로 추천
- 최대 이동 거리(100m~2km)를 슬라이더로 설정하면, 그 안에 있는 가게만 후보로 사용
- n일 이내 방문한 가게는 자동 제외하는 알고리즘으로, 연속 방문을 막고 메뉴 다양성 보장
- 한식·중식·일식·카페 등 카테고리를 선택해, 그날 기분에 맞는 맛집만 골라서 추천
2. 네이버 지도 연동 맛집 수집
- 네이버 지도앱의 ‘공유’ 기능으로 복사한 링크(naver.me 등)를 **수정하지 말고 그대로** 붙여넣으면, 가게 이름·주소·카테고리·좌표를 자동으로 불러와 등록
- 회사 구내식당이나 단골 분식집은 직접 입력으로 손쉽게 등록
- 메모, 전화번호까지 함께 저장해 두고, 동료에게 설명할 때도 한 번에 보여줄 수 있습니다.
3. 캘린더 & 통계로 보는 나의 점심 히스토리
- 월별 캘린더에서 ‘추천받은 날’과 ‘실제 방문한 날’을 다른 색 마커로 한눈에 확인
- 특정 날짜를 누르면, 그날 추천·방문 기록과 가게 상세 정보까지 한 번에 조회
- 월별 총 방문 횟수, 가장 많이 간 카테고리, 최근 7일 방문 패턴, 자주 방문한 맛집 TOP 3를 통계 카드로 제공
- “오늘 어디 갔더라?”를 기억하지 않아도, 캘린더와 통계가 내가 쌓아온 점심 히스토리를 정리해 줍니다.
4. Bluetooth 리스트 공유로 팀 점심까지 한번에
- Bluetooth 기반 리스트 공유로, 같은 공간에 있는 동료와 내 맛집 리스트를 간편하게 주고받기
- 공유 코드를 생성해 내 리스트를 보내고, 상대의 리스트를 받아 합쳐 팀 공용 맛집 풀(pool) 구성
- 이미 등록된 가게는 자동으로 걸러주고, 새로운 맛집만 골라 추가해 중복 없이 리스트를 확장
5. 스마트 알림 & 편의 기능
- 점심 후 일정 시간이 지나면 ‘오늘 추천받은 곳, 실제로 갔나요? 방문 확인 알림을 보내 기록 누락 방지
- 알림에서 한 번만 눌러도 방문 완료로 저장되어, 캘린더·통계에 자동 반영
- 다크 모드 지원, 한국어에 최적화된 깔끔한 UI로 누구나 직관적으로 사용 가능
- 광고 시청 후 1Tap으로 추천을 받는 구조로, 앱은 무료로 이용하면서 개발자는 지속적으로 서비스를 개선할 수 있습니다.
■ 왜 ‘오늘 뭐 먹Z?’여야 할까요?
- 내 주변 ‘아무 식당’이 아니라, 내가 직접 고른 맛집들만 기준으로 추천해 실패 확률을 줄여 줍니다.
- 날씨·거리·카테고리·최근 방문 이력을 모두 고려해, “오늘은 여기 가야겠다”라는 결정을 대신 내려줍니다.
- 캘린더와 통계 화면 덕분에, 점심 시간이 단순 소비가 아니라 나만의 작은 라이프로그가 됩니다.
- 팀 점심·회식까지 한 앱으로 해결해, 메뉴 선택 스트레스를 팀 전체에서 줄일 수 있습니다.
- 지금 ‘오늘 뭐 먹Z?’를 설치하고, 한국 직장인의 가장 큰 고민 중 하나인 점심 메뉴 선택을 1분 안에 끝내 보세요.
※ 본 서비스는 현재 한국(대한민국) 지역에서만 사용 가능합니다.

View File

@@ -0,0 +1,40 @@
# 캘린더·통계 UX 개선 작업 계획
## 목적
- 캘린더/통계 화면에서 월 동기화 불일치를 제거하고 과거·현재 데이터를 온전히 노출.
- 기록 가독성(마커 색/범례/헤더)과 액션 동선(상세/수정/CTA 안내)을 개선해 사용자 혼란을 최소화.
- 추천 화면의 오류/비활성 안내를 명확히 해 추천 흐름 이탈을 줄임.
## 범위
- 캘린더/통계 UI·상태 동기화: `lib/presentation/pages/calendar/calendar_screen.dart`, `lib/presentation/pages/calendar/widgets/visit_statistics.dart`
- 추천 화면 오류/CTA 상태 안내: `lib/presentation/pages/random_selection/random_selection_screen.dart`
- 마커/범례 시각 일관성: 동일 파일 내 스타일 및 범례 영역
- 기록 액션 라우팅: 방문/추천 카드 탭 액션 연결
## 작업 항목
- [x] 달력 범위 동적 설정
- `TableCalendar.firstDay/lastDay`를 데이터 최소일~현재+1년 등 동적 값으로 계산해 2024·과거 기록도 조회 가능하게 조정.
- [x] 월 이동 시 통계 동기화
- `TableCalendar.onPageChanged`(또는 `onHeaderTapped`)로 `_focusedDay` 업데이트 후 `VisitStatistics`에 전달하는 월을 갱신.
- 필요 시 월 상태를 Provider/State로 분리해 탭 간 단일 소스로 관리.
- [x] 월 선택 드롭다운/빠른 이동
- 통계 카드 헤더에 월 선택 UI(드롭다운/피커) 추가해 과거 월로 점프 가능하게 구성.
- [x] 마커·범례 색상 일치 및 대비 강화
- 방문/추천 색 팔레트 정의(라이트/다크 대응) 후 `markerBuilder`와 범례 색을 일치.
- 추천/방문 아이콘·툴팁 추가로 이벤트 구분성 강화.
- [x] 일별 기록 헤더·액션 정교화
- 헤더 문구/카운트에서 방문·추천을 분리 표기(예: `기록 3건 · 방문2/추천1`).
- 방문/추천 카드 `onTap`에 상세/수정/확인 라우팅 연결.
- [x] 추천 화면 오류/비활성 안내
- 날씨 로딩 실패 시 실제 상태(재시도/권한 확인) 메시지로 교체, 임의 날씨 표시 제거.
- 추천 버튼 비활성 사유를 UI로 노출(위치 준비 중/맛집 0개/거리 내 없음 등)하고 해결 가이드 제시.
## 검증
- 라이트/다크 모드에서 마커·범례·CTA 대비 확인.
- 과거 월/현재 월/미래 월 이동 시 캘린더·통계 동기화 확인.
- 데이터 없음·권한 거부·위치 실패·날씨 실패 상태별 UI 확인.
- iOS/Android에서 추천 CTA 비활성/활성 전환 및 스낵바/다이얼로그 메시지 확인.
## 후속 조치
- QA 시나리오 통과 후 `flutter analyze`, 필요 시 관련 `flutter test` 실행.
- 변경 사항에 맞춰 스크린샷/문서(README 혹은 디자인 가이드) 업데이트 여부 검토.

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

@@ -4,7 +4,7 @@ class AppConstants {
static const String appDescription = '점심 메뉴 추천 앱';
static const String appVersion = '1.0.0';
static const String appCopyright =
'© 2025. NatureBridgeAI. All rights reserved.';
'© 2025. NatureBridgeAI & cclabs. All rights reserved.';
// Animation Durations
static const Duration splashAnimationDuration = Duration(seconds: 3);
@@ -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

@@ -0,0 +1,55 @@
/// UI 관련 상수 정의
/// 하드코딩된 패딩, 마진, 크기 값들을 중앙 집중화
class AppDimensions {
AppDimensions._();
// Padding & Margin
static const double paddingXs = 4.0;
static const double paddingSm = 8.0;
static const double paddingMd = 12.0;
static const double paddingDefault = 16.0;
static const double paddingLg = 20.0;
static const double paddingXl = 24.0;
// Border Radius
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 16.0;
static const double radiusXl = 20.0;
static const double radiusRound = 999.0;
// Icon Sizes
static const double iconSm = 16.0;
static const double iconMd = 24.0;
static const double iconLg = 32.0;
static const double iconXl = 48.0;
static const double iconXxl = 64.0;
static const double iconHuge = 80.0;
// Card Sizes
static const double cardIconSize = 48.0;
static const double cardMinHeight = 80.0;
// Ad Settings
static const int adInterval = 6; // 5리스트 후 1광고
static const int adOffset = 5; // 광고 시작 위치
static const double adHeightSmall = 100.0;
static const double adHeightMedium = 320.0;
// Distance Settings
static const double maxSearchDistance = 2000.0; // meters
static const int distanceSliderDivisions = 19;
// List Settings
static const double listItemSpacing = 8.0;
static const double sectionSpacing = 16.0;
// Bottom Sheet
static const double bottomSheetHandleWidth = 40.0;
static const double bottomSheetHandleHeight = 4.0;
// Avatar/Profile
static const double avatarSm = 32.0;
static const double avatarMd = 48.0;
static const double avatarLg = 64.0;
}

View File

@@ -1,142 +0,0 @@
/// 애플리케이션 전체 예외 클래스들
///
/// 각 레이어별로 명확한 예외 계층 구조를 제공합니다.
/// 앱 예외 기본 클래스
abstract class AppException implements Exception {
final String message;
final String? code;
final dynamic originalError;
const AppException({required this.message, this.code, this.originalError});
@override
String toString() =>
'$runtimeType: $message${code != null ? ' (코드: $code)' : ''}';
}
/// 비즈니스 로직 예외
class BusinessException extends AppException {
const BusinessException({
required String message,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
}
/// 검증 예외
class ValidationException extends AppException {
final Map<String, String>? fieldErrors;
const ValidationException({
required String message,
this.fieldErrors,
String? code,
}) : super(message: message, code: code);
@override
String toString() {
final base = super.toString();
if (fieldErrors != null && fieldErrors!.isNotEmpty) {
final errors = fieldErrors!.entries
.map((e) => '${e.key}: ${e.value}')
.join(', ');
return '$base [필드 오류: $errors]';
}
return base;
}
}
/// 데이터 예외
class DataException extends AppException {
const DataException({
required String message,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
}
/// 저장소 예외
class StorageException extends DataException {
const StorageException({
required String message,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
}
/// 권한 예외
class PermissionException extends AppException {
final String permission;
const PermissionException({
required String message,
required this.permission,
String? code,
}) : super(message: message, code: code);
@override
String toString() => '$runtimeType: $message (권한: $permission)';
}
/// 위치 서비스 예외
class LocationException extends AppException {
const LocationException({
required String message,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
}
/// 설정 예외
class ConfigurationException extends AppException {
const ConfigurationException({required String message, String? code})
: super(message: message, code: code);
}
/// UI 예외
class UIException extends AppException {
const UIException({
required String message,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
}
/// 리소스를 찾을 수 없음 예외
class NotFoundException extends AppException {
final String resourceType;
final dynamic resourceId;
const NotFoundException({
required this.resourceType,
required this.resourceId,
String? message,
}) : super(
message: message ?? '$resourceType을(를) 찾을 수 없습니다 (ID: $resourceId)',
code: 'NOT_FOUND',
);
}
/// 중복 리소스 예외
class DuplicateException extends AppException {
final String resourceType;
const DuplicateException({required this.resourceType, String? message})
: super(message: message ?? '이미 존재하는 $resourceType입니다', code: 'DUPLICATE');
}
/// 추천 엔진 예외
class RecommendationException extends BusinessException {
const RecommendationException({required String message, String? code})
: super(message: message, code: code);
}
/// 알림 예외
class NotificationException extends AppException {
const NotificationException({
required String message,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
}

View File

@@ -1,142 +0,0 @@
/// 데이터 레이어 예외 클래스들
///
/// API, 데이터베이스, 파싱 관련 예외를 정의합니다.
import 'app_exceptions.dart';
/// API 예외 기본 클래스
abstract class ApiException extends DataException {
final int? statusCode;
const ApiException({
required String message,
this.statusCode,
String? code,
dynamic originalError,
}) : super(message: message, code: code, originalError: originalError);
@override
String toString() =>
'$runtimeType: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}';
}
/// 네이버 API 예외
class NaverApiException extends ApiException {
const NaverApiException({
required String message,
int? statusCode,
String? code,
dynamic originalError,
}) : super(
message: message,
statusCode: statusCode,
code: code,
originalError: originalError,
);
}
/// HTML 파싱 예외
class HtmlParsingException extends DataException {
final String? url;
const HtmlParsingException({
required String message,
this.url,
dynamic originalError,
}) : super(
message: message,
code: 'HTML_PARSE_ERROR',
originalError: originalError,
);
@override
String toString() {
final base = super.toString();
return url != null ? '$base (URL: $url)' : base;
}
}
/// 데이터 변환 예외
class DataConversionException extends DataException {
final String fromType;
final String toType;
const DataConversionException({
required String message,
required this.fromType,
required this.toType,
dynamic originalError,
}) : super(
message: message,
code: 'DATA_CONVERSION_ERROR',
originalError: originalError,
);
@override
String toString() => '$runtimeType: $message ($fromType$toType)';
}
/// 캐시 예외
class CacheException extends StorageException {
const CacheException({
required String message,
String? code,
dynamic originalError,
}) : super(
message: message,
code: code ?? 'CACHE_ERROR',
originalError: originalError,
);
}
/// Hive 예외
class HiveException extends StorageException {
const HiveException({
required String message,
String? code,
dynamic originalError,
}) : super(
message: message,
code: code ?? 'HIVE_ERROR',
originalError: originalError,
);
}
/// URL 처리 예외
class UrlProcessingException extends DataException {
final String url;
const UrlProcessingException({
required String message,
required this.url,
String? code,
dynamic originalError,
}) : super(
message: message,
code: code ?? 'URL_PROCESSING_ERROR',
originalError: originalError,
);
@override
String toString() => '$runtimeType: $message (URL: $url)';
}
/// 잘못된 URL 형식 예외
class InvalidUrlException extends UrlProcessingException {
const InvalidUrlException({required String url, String? message})
: super(
message: message ?? '올바르지 않은 URL 형식입니다',
url: url,
code: 'INVALID_URL',
);
}
/// 지원하지 않는 URL 예외
class UnsupportedUrlException extends UrlProcessingException {
const UnsupportedUrlException({required String url, String? message})
: super(
message: message ?? '지원하지 않는 URL입니다',
url: url,
code: 'UNSUPPORTED_URL',
);
}

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,149 +0,0 @@
import 'package:flutter/material.dart';
import '../constants/app_colors.dart';
import '../constants/app_typography.dart';
/// 빈 상태 위젯
///
/// 데이터가 없을 때 표시하는 공통 위젯
class EmptyStateWidget extends StatelessWidget {
/// 제목
final String title;
/// 설명 메시지 (선택사항)
final String? message;
/// 아이콘 (선택사항)
final IconData? icon;
/// 아이콘 크기
final double iconSize;
/// 액션 버튼 텍스트 (선택사항)
final String? actionText;
/// 액션 버튼 콜백 (선택사항)
final VoidCallback? onAction;
/// 커스텀 위젯 (아이콘 대신 사용할 수 있음)
final Widget? customWidget;
const EmptyStateWidget({
super.key,
required this.title,
this.message,
this.icon,
this.iconSize = 80.0,
this.actionText,
this.onAction,
this.customWidget,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// 아이콘 또는 커스텀 위젯
if (customWidget != null)
customWidget!
else if (icon != null)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color:
(isDark ? AppColors.darkPrimary : AppColors.lightPrimary)
.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: iconSize,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
),
const SizedBox(height: 24),
// 제목
Text(
title,
style: AppTypography.heading2(isDark),
textAlign: TextAlign.center,
),
// 설명 메시지 (있을 경우)
if (message != null) ...[
const SizedBox(height: 12),
Text(
message!,
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
// 액션 버튼 (있을 경우)
if (actionText != null && onAction != null) ...[
const SizedBox(height: 32),
ElevatedButton(
onPressed: onAction,
style: ElevatedButton.styleFrom(
backgroundColor: isDark
? AppColors.darkPrimary
: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
actionText!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
);
}
}
/// 리스트 빈 상태 위젯
///
/// 리스트나 그리드가 비어있을 때 사용하는 특화된 위젯
class ListEmptyStateWidget extends StatelessWidget {
/// 아이템 유형 (예: "식당", "기록" 등)
final String itemType;
/// 추가 액션 콜백 (선택사항)
final VoidCallback? onAdd;
const ListEmptyStateWidget({super.key, required this.itemType, this.onAdd});
@override
Widget build(BuildContext context) {
return EmptyStateWidget(
icon: Icons.inbox_outlined,
title: '$itemType이(가) 없습니다',
message: '새로운 $itemType을(를) 추가해보세요',
actionText: onAdd != null ? '$itemType 추가' : null,
onAction: onAdd,
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../constants/app_dimensions.dart';
import '../constants/app_typography.dart';
/// 상세 정보를 표시하는 공통 행 위젯
/// [label]과 [value]를 수직 또는 수평으로 배치
class InfoRow extends StatelessWidget {
final String label;
final String value;
final bool isDark;
/// true: 수평 배치 (레이블 | 값), false: 수직 배치 (레이블 위, 값 아래)
final bool horizontal;
/// 수평 배치 시 레이블 영역 너비
final double? labelWidth;
const InfoRow({
super.key,
required this.label,
required this.value,
required this.isDark,
this.horizontal = false,
this.labelWidth = 80,
});
@override
Widget build(BuildContext context) {
if (horizontal) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingXs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: labelWidth,
child: Text(label, style: AppTypography.caption(isDark)),
),
const SizedBox(width: AppDimensions.paddingSm),
Expanded(child: Text(value, style: AppTypography.body2(isDark))),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimensions.paddingXs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.caption(isDark)),
const SizedBox(height: 2),
Text(value, style: AppTypography.body2(isDark)),
],
),
);
}
}

View File

@@ -1,89 +0,0 @@
import 'package:flutter/material.dart';
import '../constants/app_colors.dart';
/// 로딩 인디케이터 위젯
///
/// 앱 전체에서 일관된 로딩 표시를 위한 공통 위젯
class LoadingIndicator extends StatelessWidget {
/// 로딩 메시지 (선택사항)
final String? message;
/// 인디케이터 크기
final double size;
/// 스트로크 너비
final double strokeWidth;
const LoadingIndicator({
super.key,
this.message,
this.size = 40.0,
this.strokeWidth = 4.0,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(
isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
),
),
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: TextStyle(
fontSize: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
textAlign: TextAlign.center,
),
],
],
),
);
}
}
/// 전체 화면 로딩 인디케이터
///
/// 화면 전체를 덮는 로딩 표시를 위한 위젯
class FullScreenLoadingIndicator extends StatelessWidget {
/// 로딩 메시지 (선택사항)
final String? message;
/// 배경 투명도
final double backgroundOpacity;
const FullScreenLoadingIndicator({
super.key,
this.message,
this.backgroundOpacity = 0.5,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
color: (isDark ? Colors.black : Colors.white).withValues(
alpha: backgroundOpacity,
),
child: LoadingIndicator(message: message),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import '../constants/app_colors.dart';
import '../constants/app_dimensions.dart';
/// Shimmer 효과를 가진 스켈톤 로더
class SkeletonLoader extends StatefulWidget {
final double width;
final double height;
final double borderRadius;
const SkeletonLoader({
super.key,
this.width = double.infinity,
required this.height,
this.borderRadius = AppDimensions.radiusSm,
});
@override
State<SkeletonLoader> createState() => _SkeletonLoaderState();
}
class _SkeletonLoaderState extends State<SkeletonLoader>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark
? AppColors.darkSurface.withValues(alpha: 0.6)
: Colors.grey.shade300;
final highlightColor = isDark
? AppColors.darkSurface.withValues(alpha: 0.9)
: Colors.grey.shade100;
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [baseColor, highlightColor, baseColor],
stops: [
(_animation.value - 1).clamp(0.0, 1.0),
_animation.value.clamp(0.0, 1.0),
(_animation.value + 1).clamp(0.0, 1.0),
],
),
),
);
},
);
}
}
/// 맛집 카드 스켈톤
class RestaurantCardSkeleton extends StatelessWidget {
const RestaurantCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Card(
margin: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingDefault,
vertical: AppDimensions.paddingSm,
),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
child: Padding(
padding: const EdgeInsets.all(AppDimensions.paddingDefault),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// 카테고리 아이콘 영역
const SkeletonLoader(
width: AppDimensions.cardIconSize,
height: AppDimensions.cardIconSize,
),
const SizedBox(width: AppDimensions.paddingMd),
// 가게 정보 영역
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SkeletonLoader(height: 20, width: 150),
const SizedBox(height: AppDimensions.paddingXs),
const SkeletonLoader(height: 14, width: 100),
],
),
),
// 거리 배지
const SkeletonLoader(width: 60, height: 28, borderRadius: 14),
],
),
const SizedBox(height: AppDimensions.paddingMd),
// 주소
const SkeletonLoader(height: 14),
],
),
),
);
}
}
/// 맛집 리스트 스켈톤
class RestaurantListSkeleton extends StatelessWidget {
final int itemCount;
const RestaurantListSkeleton({super.key, this.itemCount = 5});
@override
Widget build(BuildContext context) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemCount: itemCount,
itemBuilder: (context, index) => const RestaurantCardSkeleton(),
);
}
}

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';
@@ -33,9 +34,12 @@ class NaverApiClient {
_proxyClient = NaverProxyClient(networkClient: _networkClient);
}
/// 네이버 로컬 검색 API 호출
/// 네이버 로컬 검색 API 호출 (현재 비활성화됨)
///
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
/// 개인정보 처리방침 및 운영 정책에 따라
/// 네이버 로컬 검색 Open API(키 기반 검색)는 사용하지 않는다.
/// 이 메서드는 네트워크 요청을 보내지 않고 항상 빈 리스트를 반환한다.
/// (향후 정책 변경 시, 기존 구현을 복원하여 사용할 수 있다.)
Future<List<NaverLocalSearchResult>> searchLocal({
required String query,
double? latitude,
@@ -44,14 +48,10 @@ class NaverApiClient {
int start = 1,
String sort = 'random',
}) async {
return _localSearchApi.searchLocal(
query: query,
latitude: latitude,
longitude: longitude,
display: display,
start: start,
sort: sort,
AppLogger.debug(
'[NaverApiClient] searchLocal 호출됨 - 로컬 검색 Open API는 현재 비활성화 상태입니다.',
);
return <NaverLocalSearchResult>[];
}
/// 단축 URL을 실제 URL로 변환
@@ -88,7 +88,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 +127,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 +152,7 @@ class NaverApiClient {
);
if (response.statusCode != 200 || response.data == null) {
debugPrint(
AppLogger.error(
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
);
return {
@@ -172,11 +176,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 +191,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,553 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../core/constants/api_keys.dart';
import '../../core/network/network_client.dart';
import '../../core/network/network_config.dart';
import '../../core/errors/network_exceptions.dart';
import '../../domain/entities/restaurant.dart';
import '../datasources/remote/naver_html_extractor.dart';
/// 네이버 API 클라이언트
///
/// 네이버 오픈 API와 지도 서비스를 위한 통합 클라이언트입니다.
class NaverApiClient {
final NetworkClient _networkClient;
NaverApiClient({NetworkClient? networkClient})
: _networkClient = networkClient ?? NetworkClient();
/// 네이버 로컬 검색 API 호출
///
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
Future<List<NaverLocalSearchResult>> searchLocal({
required String query,
double? latitude,
double? longitude,
int display = 20,
int start = 1,
String sort = 'random', // random, comment
}) async {
// API 키 확인
if (!ApiKeys.areKeysConfigured()) {
throw ApiKeyException();
}
try {
final response = await _networkClient.get<Map<String, dynamic>>(
ApiKeys.naverLocalSearchEndpoint,
queryParameters: {
'query': query,
'display': display,
'start': start,
'sort': sort,
if (latitude != null && longitude != null) ...{
'coordinate': '$longitude,$latitude', // 경도,위도 순서
},
},
options: Options(
headers: {
'X-Naver-Client-Id': ApiKeys.naverClientId,
'X-Naver-Client-Secret': ApiKeys.naverClientSecret,
},
),
);
if (response.statusCode == 200 && response.data != null) {
final items = response.data!['items'] as List<dynamic>?;
if (items == null || items.isEmpty) {
return [];
}
return items
.map(
(item) =>
NaverLocalSearchResult.fromJson(item as Map<String, dynamic>),
)
.toList();
}
throw ParseException(message: '검색 결과를 파싱할 수 없습니다');
} on DioException catch (e) {
// 에러는 NetworkClient에서 이미 변환됨
throw e.error ??
ServerException(message: '네이버 API 호출 실패', statusCode: 500);
}
}
/// 네이버 단축 URL 리다이렉션 처리
///
/// naver.me 단축 URL을 실제 지도 URL로 변환합니다.
Future<String> resolveShortUrl(String shortUrl) async {
if (!shortUrl.contains('naver.me')) {
debugPrint('NaverApiClient: 단축 URL이 아님, 원본 반환 - $shortUrl');
return shortUrl;
}
try {
debugPrint('NaverApiClient: 단축 URL 리디렉션 처리 시작 - $shortUrl');
// 웹 환경에서는 CORS 프록시 사용
if (kIsWeb) {
return await _resolveShortUrlViaProxy(shortUrl);
}
// 모바일 환경에서는 여러 단계의 리다이렉션 처리
String currentUrl = shortUrl;
int redirectCount = 0;
const maxRedirects = 10;
while (redirectCount < maxRedirects) {
debugPrint(
'NaverApiClient: 리다이렉션 시도 #${redirectCount + 1} - $currentUrl',
);
final response = await _networkClient.get(
currentUrl,
options: Options(
followRedirects: false,
validateStatus: (status) => true, // 모든 상태 코드 허용
headers: {'User-Agent': NetworkConfig.userAgent},
),
useCache: false,
);
debugPrint('NaverApiClient: 응답 상태 코드 - ${response.statusCode}');
// 리다이렉션 체크 (301, 302, 307, 308)
if ([301, 302, 307, 308].contains(response.statusCode)) {
final location = response.headers['location']?.firstOrNull;
if (location != null) {
debugPrint('NaverApiClient: Location 헤더 발견 - $location');
// 상대 경로인 경우 절대 경로로 변환
if (!location.startsWith('http')) {
final Uri baseUri = Uri.parse(currentUrl);
currentUrl = baseUri.resolve(location).toString();
} else {
currentUrl = location;
}
// 목표 URL에 도달했는지 확인
if (currentUrl.contains('pcmap.place.naver.com') ||
currentUrl.contains('map.naver.com/p/')) {
debugPrint('NaverApiClient: 최종 URL 도착 - $currentUrl');
return currentUrl;
}
redirectCount++;
} else {
debugPrint('NaverApiClient: Location 헤더 없음');
break;
}
} else if (response.statusCode == 200) {
// 200 OK인 경우 meta refresh 태그 확인
debugPrint('NaverApiClient: 200 OK - meta refresh 태그 확인');
final String? html = response.data as String?;
if (html != null &&
html.contains('meta') &&
html.contains('refresh')) {
final metaRefreshRegex = RegExp(
'<meta[^>]+http-equiv=["\']refresh["\'][^>]+content=["\']\\d+;\\s*url=([^"\'>]+)',
caseSensitive: false,
);
final match = metaRefreshRegex.firstMatch(html);
if (match != null) {
final redirectUrl = match.group(1)!;
debugPrint('NaverApiClient: Meta refresh URL 발견 - $redirectUrl');
// 상대 경로 처리
if (!redirectUrl.startsWith('http')) {
final Uri baseUri = Uri.parse(currentUrl);
currentUrl = baseUri.resolve(redirectUrl).toString();
} else {
currentUrl = redirectUrl;
}
redirectCount++;
continue;
}
}
// meta refresh가 없으면 현재 URL이 최종 URL
debugPrint('NaverApiClient: 200 OK - 최종 URL - $currentUrl');
return currentUrl;
} else {
debugPrint('NaverApiClient: 리다이렉션 아님 - 상태 코드 ${response.statusCode}');
break;
}
}
// 모든 시도 후 현재 URL 반환
debugPrint('NaverApiClient: 최종 URL - $currentUrl');
return currentUrl;
} catch (e) {
debugPrint('NaverApiClient: 단축 URL 리다이렉션 실패 - $e');
return shortUrl;
}
}
/// 프록시를 통한 단축 URL 리다이렉션 (웹 환경)
Future<String> _resolveShortUrlViaProxy(String shortUrl) async {
try {
final proxyUrl =
'${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(shortUrl)}';
final response = await _networkClient.get<Map<String, dynamic>>(
proxyUrl,
options: Options(headers: {'Accept': 'application/json'}),
useCache: false,
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data!;
// status.url 확인
if (data['status'] != null &&
data['status'] is Map &&
data['status']['url'] != null) {
final finalUrl = data['status']['url'] as String;
debugPrint('NaverApiClient: 프록시 최종 URL - $finalUrl');
return finalUrl;
}
// contents에서 meta refresh 태그 찾기
final contents = data['contents'] as String?;
if (contents != null && contents.isNotEmpty) {
final metaRefreshRegex = RegExp(
'<meta\\s+http-equiv=["\']refresh["\']'
'\\s+content=["\']0;\\s*url=([^"\']+)["\']',
caseSensitive: false,
);
final match = metaRefreshRegex.firstMatch(contents);
if (match != null) {
final redirectUrl = match.group(1)!;
debugPrint('NaverApiClient: Meta refresh URL - $redirectUrl');
return redirectUrl;
}
}
}
return shortUrl;
} catch (e) {
debugPrint('NaverApiClient: 프록시 리다이렉션 실패 - $e');
return shortUrl;
}
}
/// 네이버 지도 HTML 가져오기
///
/// 웹 환경에서는 CORS 프록시를 사용합니다.
Future<String> fetchMapPageHtml(String url) async {
try {
if (kIsWeb) {
return await _fetchViaProxy(url);
}
// 모바일 환경에서는 직접 요청
final response = await _networkClient.get<String>(
url,
options: Options(
responseType: ResponseType.plain,
headers: {
'User-Agent': NetworkConfig.userAgent,
'Referer': 'https://map.naver.com',
},
),
useCache: false, // 네이버 지도는 동적 콘텐츠이므로 캐시 사용 안함
);
if (response.statusCode == 200 && response.data != null) {
return response.data!;
}
throw ServerException(
message: 'HTML을 가져올 수 없습니다',
statusCode: response.statusCode ?? 500,
);
} on DioException catch (e) {
throw e.error ??
ServerException(message: 'HTML 가져오기 실패', statusCode: 500);
}
}
/// 프록시를 통한 HTML 가져오기 (웹 환경)
Future<String> _fetchViaProxy(String url) async {
final proxyUrl =
'${NetworkConfig.corsProxyUrl}?url=${Uri.encodeComponent(url)}';
final response = await _networkClient.get<Map<String, dynamic>>(
proxyUrl,
options: Options(headers: {'Accept': 'application/json'}),
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data!;
// 상태 코드 확인
if (data['status'] != null && data['status'] is Map) {
final statusMap = data['status'] as Map<String, dynamic>;
final httpCode = statusMap['http_code'];
if (httpCode != null && httpCode != 200) {
throw ServerException(
message: '네이버 서버 응답 오류',
statusCode: httpCode as int,
);
}
}
// contents 반환
final contents = data['contents'];
if (contents == null || contents.toString().isEmpty) {
throw ParseException(message: '빈 응답을 받았습니다');
}
return contents.toString();
}
throw ServerException(
message: '프록시 요청 실패',
statusCode: response.statusCode ?? 500,
);
}
/// GraphQL 쿼리 실행
///
/// 네이버 지도 API의 GraphQL 엔드포인트에 요청을 보냅니다.
Future<Map<String, dynamic>> fetchGraphQL({
required String operationName,
required Map<String, dynamic> variables,
required String query,
}) async {
const String graphqlUrl = 'https://pcmap-api.place.naver.com/graphql';
try {
final response = await _networkClient.post<Map<String, dynamic>>(
graphqlUrl,
data: {
'operationName': operationName,
'variables': variables,
'query': query,
},
options: Options(
headers: {
'Content-Type': 'application/json',
'Referer': 'https://map.naver.com/',
'User-Agent': NetworkConfig.userAgent,
},
),
);
if (response.statusCode == 200 && response.data != null) {
return response.data!;
}
throw ParseException(message: 'GraphQL 응답을 파싱할 수 없습니다');
} on DioException catch (e) {
throw e.error ??
ServerException(message: 'GraphQL 요청 실패', statusCode: 500);
}
}
/// pcmap URL에서 한글 텍스트 리스트 가져오기
///
/// restaurant/{ID}/home 형식의 URL에서 모든 한글 텍스트를 추출합니다.
Future<Map<String, dynamic>> fetchKoreanTextsFromPcmap(String placeId) async {
// restaurant 타입 URL 사용
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
try {
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
debugPrint('요청 URL: $pcmapUrl');
debugPrint('Place ID: $placeId');
String html;
if (kIsWeb) {
// 웹 환경에서는 프록시 사용
html = await _fetchViaProxy(pcmapUrl);
} else {
// 모바일 환경에서는 직접 요청
final response = await _networkClient.get<String>(
pcmapUrl,
options: Options(
responseType: ResponseType.plain,
headers: {
'User-Agent': NetworkConfig.userAgent,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
'Referer': 'https://map.naver.com/',
},
),
useCache: false,
);
if (response.statusCode != 200 || response.data == null) {
debugPrint(
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
);
return {
'success': false,
'error': 'HTTP ${response.statusCode}',
'koreanTexts': <String>[],
};
}
html = response.data!;
}
// 모든 한글 텍스트 추출
final koreanTexts = NaverHtmlExtractor.extractAllValidKoreanTexts(html);
// JSON-LD 데이터 추출 시도
final jsonLdName = NaverHtmlExtractor.extractPlaceNameFromJsonLd(html);
// Apollo State 데이터 추출 시도
final apolloName = NaverHtmlExtractor.extractPlaceNameFromApolloState(html);
debugPrint('========== 추출 결과 ==========');
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
debugPrint('JSON-LD 상호명: $jsonLdName');
debugPrint('Apollo State 상호명: $apolloName');
debugPrint('=====================================');
return {
'success': true,
'placeId': placeId,
'url': pcmapUrl,
'koreanTexts': koreanTexts,
'jsonLdName': jsonLdName,
'apolloStateName': apolloName,
'extractedAt': DateTime.now().toIso8601String(),
};
} catch (e) {
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
return {
'success': false,
'error': e.toString(),
'koreanTexts': <String>[],
};
}
}
/// 최종 리디렉션 URL 획득
///
/// 주어진 URL이 리디렉션되는 최종 URL을 반환합니다.
Future<String> getFinalRedirectUrl(String url) async {
try {
debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 중 - $url');
// 429 에러 방지를 위한 지연
await Future.delayed(const Duration(milliseconds: 500));
final response = await _networkClient.get(
url,
options: Options(
followRedirects: true,
maxRedirects: 5,
responseType: ResponseType.plain,
),
useCache: false,
);
final finalUrl = response.realUri.toString();
debugPrint('NaverApiClient: 최종 리디렉션 URL - $finalUrl');
return finalUrl;
} catch (e) {
debugPrint('NaverApiClient: 최종 리디렉션 URL 획득 실패 - $e');
return url;
}
}
/// 리소스 정리
void dispose() {
_networkClient.dispose();
}
}
/// 네이버 로컬 검색 결과
class NaverLocalSearchResult {
final String title;
final String link;
final String category;
final String description;
final String telephone;
final String address;
final String roadAddress;
final int mapx; // 경도 (x좌표)
final int mapy; // 위도 (y좌표)
NaverLocalSearchResult({
required this.title,
required this.link,
required this.category,
required this.description,
required this.telephone,
required this.address,
required this.roadAddress,
required this.mapx,
required this.mapy,
});
factory NaverLocalSearchResult.fromJson(Map<String, dynamic> json) {
return NaverLocalSearchResult(
title: _removeHtmlTags(json['title'] ?? ''),
link: json['link'] ?? '',
category: json['category'] ?? '',
description: _removeHtmlTags(json['description'] ?? ''),
telephone: json['telephone'] ?? '',
address: json['address'] ?? '',
roadAddress: json['roadAddress'] ?? '',
mapx: int.tryParse(json['mapx']?.toString() ?? '0') ?? 0,
mapy: int.tryParse(json['mapy']?.toString() ?? '0') ?? 0,
);
}
/// HTML 태그 제거
static String _removeHtmlTags(String text) {
return text.replaceAll(RegExp(r'<[^>]+>'), '');
}
/// 위도 (십진도)
double get latitude => mapy / 10000000.0;
/// 경도 (십진도)
double get longitude => mapx / 10000000.0;
/// Restaurant 엔티티로 변환
Restaurant toRestaurant({required String id}) {
// 카테고리 파싱
final categories = category.split('>').map((c) => c.trim()).toList();
final mainCategory = categories.isNotEmpty ? categories.first : '기타';
final subCategory = categories.length > 1 ? categories.last : mainCategory;
return Restaurant(
id: id,
name: title,
category: mainCategory,
subCategory: subCategory,
description: description.isNotEmpty ? description : null,
phoneNumber: telephone.isNotEmpty ? telephone : null,
roadAddress: roadAddress.isNotEmpty ? roadAddress : address,
jibunAddress: address,
latitude: latitude,
longitude: longitude,
lastVisitDate: null,
source: DataSource.NAVER,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
naverPlaceId: null,
naverUrl: link.isNotEmpty ? link : null,
businessHours: null,
lastVisited: null,
visitCount: 0,
);
}
}

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,10 +22,28 @@ import 'data/sample/sample_data_initializer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize timezone
// Initialize timezone (동기, 빠름)
tz.initializeTimeZones();
// Initialize Hive
// 광고 SDK와 Hive 초기화를 병렬 처리
await Future.wait([
if (AdHelper.isMobilePlatform) MobileAds.instance.initialize(),
_initializeHive(),
]);
// Hive 초기화 후 병렬 처리 가능한 작업들
await Future.wait([
SampleDataInitializer.seedInitialData(),
_initializeNotifications(),
AdaptiveTheme.getThemeMode(),
]).then((results) {
final savedThemeMode = results[2] as AdaptiveThemeMode?;
runApp(ProviderScope(child: LunchPickApp(savedThemeMode: savedThemeMode)));
});
}
/// Hive 초기화 및 Box 오픈
Future<void> _initializeHive() async {
await Hive.initFlutter();
// Register Hive Adapters
@@ -33,25 +53,21 @@ void main() async {
Hive.registerAdapter(RecommendationRecordAdapter());
Hive.registerAdapter(UserSettingsAdapter());
// Open Hive Boxes
await Hive.openBox<Restaurant>(AppConstants.restaurantBox);
await Hive.openBox<VisitRecord>(AppConstants.visitRecordBox);
await Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox);
await Hive.openBox(AppConstants.settingsBox);
await Hive.openBox<UserSettings>('user_settings');
await SampleDataInitializer.seedManualRestaurantsIfNeeded();
// Open Hive Boxes (병렬 오픈)
await Future.wait([
Hive.openBox<Restaurant>(AppConstants.restaurantBox),
Hive.openBox<VisitRecord>(AppConstants.visitRecordBox),
Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox),
Hive.openBox(AppConstants.settingsBox),
Hive.openBox<UserSettings>('user_settings'),
]);
}
// Initialize Notification Service (only for non-web platforms)
if (!kIsWeb) {
final notificationService = NotificationService();
await notificationService.initialize();
await notificationService.requestPermission();
}
// Get saved theme mode
final savedThemeMode = await AdaptiveTheme.getThemeMode();
runApp(ProviderScope(child: LunchPickApp(savedThemeMode: savedThemeMode)));
/// 알림 서비스 초기화 (비-웹 플랫폼)
Future<void> _initializeNotifications() async {
if (kIsWeb) return;
final notificationService = NotificationService();
await notificationService.ensureInitialized(requestPermission: true);
}
class LunchPickApp extends StatelessWidget {

View File

@@ -3,9 +3,18 @@ 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 '../../../core/widgets/info_row.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 +28,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 +53,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 +103,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 +300,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 +354,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 +370,431 @@ 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),
InfoRow(
label: '주소',
value: restaurant.roadAddress,
isDark: isDark,
horizontal: true,
),
if (visitTime != null)
InfoRow(
label: isVisitConfirmed ? '방문 완료' : '방문 예정',
value: _formatFullDateTime(visitTime),
isDark: isDark,
horizontal: true,
),
if (recoTime != null)
InfoRow(
label: '추천 시각',
value: _formatFullDateTime(recoTime),
isDark: isDark,
horizontal: true,
),
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),
),
),
);
},
);
},
);
}
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: [
InfoRow(
label: '카테고리',
value: '${restaurant.category} > ${restaurant.subCategory}',
isDark: isDark,
horizontal: true,
),
if (restaurant.phoneNumber != null)
InfoRow(
label: '전화번호',
value: restaurant.phoneNumber!,
isDark: isDark,
horizontal: true,
),
InfoRow(
label: '도로명',
value: restaurant.roadAddress,
isDark: isDark,
horizontal: true,
),
InfoRow(
label: '지번',
value: restaurant.jibunAddress,
isDark: isDark,
horizontal: true,
),
if (restaurant.description != null &&
restaurant.description!.isNotEmpty)
InfoRow(
label: '메모',
value: restaurant.description!,
isDark: isDark,
horizontal: true,
),
],
),
),
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,195 @@
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;
/// 카드 전체 탭(tap) 시 실행할 콜백.
final VoidCallback? onTap;
const RecommendationRecordCard({
super.key,
required this.recommendation,
required this.onConfirmVisit,
required this.onDelete,
this.onTap,
});
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: InkWell(
onTap: onTap,
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

@@ -1,9 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_dimensions.dart';
import '../../../core/constants/app_typography.dart';
import '../../../core/widgets/skeleton_loader.dart';
import '../../../core/utils/category_mapper.dart';
import '../../../core/utils/app_logger.dart';
import '../../providers/restaurant_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/visit_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 +38,13 @@ 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);
final lastVisitDates =
ref.watch(allLastVisitDatesProvider).valueOrNull ?? {};
return Scaffold(
backgroundColor: isDark
@@ -66,6 +75,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
if (_isSearching) ...[
IconButton(
icon: const Icon(Icons.close),
tooltip: '검색 닫기',
onPressed: () {
setState(() {
_isSearching = false;
@@ -77,6 +87,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
] else ...[
IconButton(
icon: const Icon(Icons.search),
tooltip: '맛집 검색',
onPressed: () {
setState(() {
_isSearching = true;
@@ -103,51 +114,143 @@ 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 = AppDimensions.adInterval;
const adOffset = AppDimensions.adOffset;
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: 100, // 작은 템플릿으로 노출
);
}
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,
lastVisitDate: lastVisitDates[item.restaurant.id],
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.lightPrimary),
),
loading: () {
AppLogger.debug('[restaurant_list_ui] loading...');
return const RestaurantListSkeleton();
},
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: isDark
? AppColors.darkError
: AppColors.lightError,
),
const SizedBox(height: 16),
Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)),
const SizedBox(height: 8),
Text(
error.toString(),
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
],
child: Padding(
padding: const EdgeInsets.all(AppDimensions.paddingDefault),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: AppDimensions.iconXxl,
color: isDark
? AppColors.darkError
: AppColors.lightError,
),
const SizedBox(height: AppDimensions.paddingDefault),
Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)),
const SizedBox(height: AppDimensions.paddingSm),
Text(
error.toString(),
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppDimensions.paddingLg),
ElevatedButton.icon(
onPressed: () =>
ref.invalidate(sortedRestaurantsByDistanceProvider),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingXl,
vertical: AppDimensions.paddingMd,
),
),
icon: const Icon(Icons.refresh),
label: const Text('다시 시도'),
),
],
),
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddOptions,
backgroundColor: AppColors.lightPrimary,
child: const Icon(Icons.add, color: Colors.white),
floatingActionButton: Semantics(
label: '맛집 추가하기',
button: true,
child: FloatingActionButton(
onPressed: _showAddOptions,
tooltip: '맛집 추가',
backgroundColor: AppColors.lightPrimary,
child: const Icon(Icons.add, color: Colors.white),
),
),
);
}
@@ -241,25 +344,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 +376,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,925 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.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/core/utils/validators.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
class AddRestaurantDialog extends ConsumerStatefulWidget {
final int initialTabIndex;
const AddRestaurantDialog({
super.key,
this.initialTabIndex = 0,
});
@override
ConsumerState<AddRestaurantDialog> createState() => _AddRestaurantDialogState();
}
class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog> with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _categoryController = TextEditingController();
final _subCategoryController = TextEditingController();
final _descriptionController = TextEditingController();
final _phoneController = TextEditingController();
final _roadAddressController = TextEditingController();
final _jibunAddressController = TextEditingController();
final _latitudeController = TextEditingController();
final _longitudeController = TextEditingController();
final _naverUrlController = TextEditingController();
// 기본 좌표 (서울시청)
final double _defaultLatitude = 37.5665;
final double _defaultLongitude = 126.9780;
// UI 상태 관리
late TabController _tabController;
bool _isLoading = false;
String? _errorMessage;
Restaurant? _fetchedRestaurantData;
final _linkController = TextEditingController();
@override
void initState() {
super.initState();
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: widget.initialTabIndex,
);
}
@override
void dispose() {
_nameController.dispose();
_categoryController.dispose();
_subCategoryController.dispose();
_descriptionController.dispose();
_phoneController.dispose();
_roadAddressController.dispose();
_jibunAddressController.dispose();
_latitudeController.dispose();
_longitudeController.dispose();
_naverUrlController.dispose();
_linkController.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Dialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 제목과 탭바
Container(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
child: Column(
children: [
Text(
'맛집 추가',
style: AppTypography.heading1(isDark),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: AppColors.lightPrimary,
borderRadius: BorderRadius.circular(12),
),
indicatorSize: TabBarIndicatorSize.tab,
labelColor: Colors.white,
unselectedLabelColor: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
labelStyle: AppTypography.body1(false).copyWith(fontWeight: FontWeight.w600),
tabs: const [
Tab(text: '직접 입력'),
Tab(text: '네이버 지도에서 가져오기'),
],
),
),
],
),
),
// 탭뷰 컨텐츠
Flexible(
child: TabBarView(
controller: _tabController,
physics: const NeverScrollableScrollPhysics(),
children: [
// 직접 입력 탭
SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 가게 이름
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: '가게 이름 *',
hintText: '예: 서울갈비',
prefixIcon: const Icon(Icons.store),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '가게 이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 카테고리
Row(
children: [
Expanded(
child: TextFormField(
controller: _categoryController,
decoration: InputDecoration(
labelText: '카테고리 *',
hintText: '예: 한식',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '카테고리를 입력해주세요';
}
return null;
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _subCategoryController,
decoration: InputDecoration(
labelText: '세부 카테고리',
hintText: '예: 갈비',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
const SizedBox(height: 16),
// 설명
TextFormField(
controller: _descriptionController,
maxLines: 2,
decoration: InputDecoration(
labelText: '설명',
hintText: '맛집에 대한 간단한 설명',
prefixIcon: const Icon(Icons.description),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
// 전화번호
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: '전화번호',
hintText: '예: 02-1234-5678',
prefixIcon: const Icon(Icons.phone),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
// 도로명 주소
TextFormField(
controller: _roadAddressController,
decoration: InputDecoration(
labelText: '도로명 주소 *',
hintText: '예: 서울시 중구 세종대로 110',
prefixIcon: const Icon(Icons.location_on),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '도로명 주소를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 지번 주소
TextFormField(
controller: _jibunAddressController,
decoration: InputDecoration(
labelText: '지번 주소',
hintText: '예: 서울시 중구 태평로1가 31',
prefixIcon: const Icon(Icons.map),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
// 위도/경도 입력
Row(
children: [
Expanded(
child: TextFormField(
controller: _latitudeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: '위도',
hintText: '37.5665',
prefixIcon: const Icon(Icons.explore),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: Validators.validateLatitude,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _longitudeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: '경도',
hintText: '126.9780',
prefixIcon: const Icon(Icons.explore),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
validator: Validators.validateLongitude,
),
),
],
),
const SizedBox(height: 8),
Text(
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
style: TextStyle(
fontSize: 12,
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
),
),
const SizedBox(height: 24),
// 버튼
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'취소',
style: TextStyle(
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _saveRestaurant,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
),
child: const Text('저장'),
),
],
),
],
),
),
),
// 네이버 지도 탭
_buildNaverMapTab(isDark),
],
),
),
],
),
),
);
}
// 네이버 지도 탭 빌드
Widget _buildNaverMapTab(bool isDark) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 안내 메시지
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.lightPrimary.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: AppColors.lightPrimary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
kIsWeb
? '네이버 지도에서 맛집 페이지 URL을 복사하여\n붙여넣어 주세요.\n\n웹 환경에서는 프록시 서버를 통해 정보를 가져옵니다.\n네트워크 상황에 따라 시간이 걸릴 수 있습니다.'
: '네이버 지도에서 맛집 페이지 URL을 복사하여\n붙여넣어 주세요.',
style: TextStyle(
fontSize: 14,
color: isDark ? AppColors.darkText : AppColors.lightText,
height: 1.5,
),
),
),
],
),
),
const SizedBox(height: 24),
// URL 입력 필드
TextFormField(
controller: _naverUrlController,
decoration: InputDecoration(
labelText: '네이버 지도 URL',
hintText: 'https://map.naver.com/... 또는 https://naver.me/...',
prefixIcon: Icon(
Icons.link,
color: AppColors.lightPrimary,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: AppColors.lightPrimary,
width: 2,
),
),
errorText: _errorMessage,
errorMaxLines: 2,
),
enabled: !_isLoading,
),
const SizedBox(height: 24),
// 가져온 정보 표시 (JSON 스타일)
if (_fetchedRestaurantData != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? AppColors.darkBackground
: AppColors.lightBackground.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 타이틀
Row(
children: [
Icon(
Icons.code,
size: 20,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 8),
Text(
'가져온 정보',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
),
],
),
const SizedBox(height: 12),
// JSON 스타일 정보 표시
_buildJsonField(
'이름',
_nameController,
isDark,
icon: Icons.store,
),
_buildJsonField(
'카테고리',
_categoryController,
isDark,
icon: Icons.category,
),
_buildJsonField(
'세부 카테고리',
_subCategoryController,
isDark,
icon: Icons.label_outline,
),
_buildJsonField(
'주소',
_roadAddressController,
isDark,
icon: Icons.location_on,
),
_buildJsonField(
'전화',
_phoneController,
isDark,
icon: Icons.phone,
),
_buildJsonField(
'설명',
_descriptionController,
isDark,
icon: Icons.description,
maxLines: 2,
),
_buildJsonField(
'좌표',
TextEditingController(
text: '${_latitudeController.text}, ${_longitudeController.text}'
),
isDark,
icon: Icons.my_location,
isCoordinate: true,
),
if (_linkController.text.isNotEmpty)
_buildJsonField(
'링크',
_linkController,
isDark,
icon: Icons.link,
isLink: true,
),
],
),
),
const SizedBox(height: 24),
],
// 버튼
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: Text(
'취소',
style: TextStyle(
color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
),
),
),
const SizedBox(width: 8),
if (_fetchedRestaurantData == null)
ElevatedButton(
onPressed: _isLoading ? null : _fetchFromNaverUrl,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.download, size: 18),
SizedBox(width: 8),
Text('가져오기'),
],
),
)
else ...[
OutlinedButton(
onPressed: () {
setState(() {
_fetchedRestaurantData = null;
_clearControllers();
});
},
style: OutlinedButton.styleFrom(
foregroundColor: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
side: BorderSide(
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('초기화'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _saveRestaurant,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.save, size: 18),
SizedBox(width: 8),
Text('저장'),
],
),
),
],
],
),
],
),
);
}
// JSON 스타일 필드 빌드
Widget _buildJsonField(
String label,
TextEditingController controller,
bool isDark, {
IconData? icon,
int maxLines = 1,
bool isCoordinate = false,
bool isLink = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 16,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 8),
],
Text(
'$label:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
),
],
),
const SizedBox(height: 4),
if (isCoordinate)
Row(
children: [
Expanded(
child: TextFormField(
controller: _latitudeController,
style: TextStyle(
fontSize: 14,
fontFamily: 'monospace',
color: isDark ? AppColors.darkText : AppColors.lightText,
),
decoration: InputDecoration(
hintText: '위도',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
filled: true,
fillColor: isDark
? AppColors.darkSurface
: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
),
),
),
),
const SizedBox(width: 8),
Text(
',',
style: TextStyle(
fontSize: 14,
fontFamily: 'monospace',
color: isDark ? AppColors.darkText : AppColors.lightText,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _longitudeController,
style: TextStyle(
fontSize: 14,
fontFamily: 'monospace',
color: isDark ? AppColors.darkText : AppColors.lightText,
),
decoration: InputDecoration(
hintText: '경도',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
filled: true,
fillColor: isDark
? AppColors.darkSurface
: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
),
),
),
),
],
)
else
TextFormField(
controller: controller,
maxLines: maxLines,
style: TextStyle(
fontSize: 14,
fontFamily: isLink ? 'monospace' : null,
color: isLink
? AppColors.lightPrimary
: isDark ? AppColors.darkText : AppColors.lightText,
decoration: isLink ? TextDecoration.underline : null,
),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
filled: true,
fillColor: isDark
? AppColors.darkSurface
: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
),
),
),
],
),
);
}
// 컨트롤러 초기화
void _clearControllers() {
_nameController.clear();
_categoryController.clear();
_subCategoryController.clear();
_descriptionController.clear();
_phoneController.clear();
_roadAddressController.clear();
_jibunAddressController.clear();
_latitudeController.clear();
_longitudeController.clear();
_linkController.clear();
}
// 네이버 URL에서 정보 가져오기
Future<void> _fetchFromNaverUrl() async {
final url = _naverUrlController.text.trim();
if (url.isEmpty) {
setState(() {
_errorMessage = 'URL을 입력해주세요.';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final notifier = ref.read(restaurantNotifierProvider.notifier);
final restaurant = await notifier.addRestaurantFromUrl(url);
// 성공 시 폼에 정보 채우고 _fetchedRestaurantData 설정
setState(() {
_nameController.text = restaurant.name;
_categoryController.text = restaurant.category;
_subCategoryController.text = restaurant.subCategory;
_descriptionController.text = restaurant.description ?? '';
_phoneController.text = restaurant.phoneNumber ?? '';
_roadAddressController.text = restaurant.roadAddress;
_jibunAddressController.text = restaurant.jibunAddress;
_latitudeController.text = restaurant.latitude.toString();
_longitudeController.text = restaurant.longitude.toString();
// 링크 정보가 있다면 설정
_linkController.text = restaurant.naverUrl ?? '';
// Restaurant 객체 저장
_fetchedRestaurantData = restaurant;
_isLoading = false;
});
// 성공 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text('맛집 정보를 가져왔습니다. 확인 후 저장해주세요.'),
],
),
backgroundColor: AppColors.lightPrimary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString().replaceFirst('Exception: ', '');
});
}
}
Future<void> _saveRestaurant() async {
if (_formKey.currentState?.validate() != true) {
return;
}
final notifier = ref.read(restaurantNotifierProvider.notifier);
try {
// _fetchedRestaurantData가 있으면 해당 데이터 사용 (네이버에서 가져온 경우)
final fetchedData = _fetchedRestaurantData;
if (fetchedData != null) {
// 사용자가 수정한 필드만 업데이트
final updatedRestaurant = fetchedData.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: double.tryParse(_latitudeController.text.trim()) ?? fetchedData.latitude,
longitude: double.tryParse(_longitudeController.text.trim()) ?? fetchedData.longitude,
updatedAt: DateTime.now(),
);
// 이미 완성된 Restaurant 객체를 직접 추가
await notifier.addRestaurantDirect(updatedRestaurant);
} else {
// 직접 입력한 경우 (기존 로직)
await notifier.addRestaurant(
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: _latitudeController.text.trim().isEmpty
? _defaultLatitude
: double.tryParse(_latitudeController.text.trim()) ?? _defaultLatitude,
longitude: _longitudeController.text.trim().isEmpty
? _defaultLongitude
: double.tryParse(_longitudeController.text.trim()) ?? _defaultLongitude,
source: DataSource.USER_INPUT,
);
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('맛집이 추가되었습니다'),
backgroundColor: AppColors.lightPrimary,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('오류가 발생했습니다: ${e.toString()}'),
backgroundColor: AppColors.lightError,
),
);
}
}
}
}

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,23 +172,22 @@ 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 '위도를 입력해주세요';
}
final latitude = double.tryParse(value);
if (latitude == null || latitude < -90 || latitude > 90) {
return '올바른 위도값을 입력해주세요';
if (value != null && value.isNotEmpty) {
final latitude = double.tryParse(value);
if (latitude == null || latitude < -90 || latitude > 90) {
return '올바른 위도값을 입력해주세요';
}
}
return null;
},
@@ -178,25 +196,24 @@ 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 '경도를 입력해주세요';
}
final longitude = double.tryParse(value);
if (longitude == null ||
longitude < -180 ||
longitude > 180) {
return '올바른 경도값을 입력해주세요';
if (value != null && value.isNotEmpty) {
final longitude = double.tryParse(value);
if (longitude == null ||
longitude < -180 ||
longitude > 180) {
return '올바른 경도값을 입력해주세요';
}
}
return null;
},
@@ -209,6 +226,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 +417,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

@@ -2,19 +2,28 @@ 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/core/widgets/info_row.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';
/// 맛집 카드 위젯
/// [lastVisitDate]를 외부에서 주입받아 리스트 렌더링 최적화
class RestaurantCard extends ConsumerWidget {
final Restaurant restaurant;
final double? distanceKm;
final DateTime? lastVisitDate;
const RestaurantCard({super.key, required this.restaurant});
const RestaurantCard({
super.key,
required this.restaurant,
this.distanceKm,
this.lastVisitDate,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final lastVisitAsync = ref.watch(lastVisitDateProvider(restaurant.id));
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -49,41 +58,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('삭제'),
],
),
),
],
),
],
),
],
),
@@ -122,39 +184,26 @@ class RestaurantCard extends ConsumerWidget {
),
// 마지막 방문일
lastVisitAsync.when(
data: (lastVisit) {
if (lastVisit != null) {
final daysSinceVisit = DateTime.now()
.difference(lastVisit)
.inDays;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(
Icons.schedule,
size: 16,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
daysSinceVisit == 0
? '오늘 방문'
: '$daysSinceVisit일 전 방문',
style: AppTypography.caption(isDark),
),
],
if (lastVisitDate != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(
Icons.schedule,
size: 16,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
);
}
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
const SizedBox(width: 4),
Text(
_formatLastVisit(lastVisitDate!),
style: AppTypography.caption(isDark),
),
],
),
),
],
),
),
@@ -185,6 +234,11 @@ class RestaurantCard extends ConsumerWidget {
}
}
String _formatLastVisit(DateTime date) {
final daysSinceVisit = DateTime.now().difference(date).inDays;
return daysSinceVisit == 0 ? '오늘 방문' : '$daysSinceVisit일 전 방문';
}
void _showRestaurantDetail(BuildContext context, bool isDark) {
showDialog(
context: context,
@@ -197,22 +251,22 @@ class RestaurantCard extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow(
'카테고리',
'${restaurant.category} > ${restaurant.subCategory}',
isDark,
InfoRow(
label: '카테고리',
value: '${restaurant.category} > ${restaurant.subCategory}',
isDark: isDark,
),
if (restaurant.description != null)
_buildDetailRow('설명', restaurant.description!, isDark),
InfoRow(label: '설명', value: restaurant.description!, isDark: isDark),
if (restaurant.phoneNumber != null)
_buildDetailRow('전화번호', restaurant.phoneNumber!, isDark),
_buildDetailRow('도로명 주소', restaurant.roadAddress, isDark),
_buildDetailRow('지번 주소', restaurant.jibunAddress, isDark),
InfoRow(label: '전화번호', value: restaurant.phoneNumber!, isDark: isDark),
InfoRow(label: '도로명 주소', value: restaurant.roadAddress, isDark: isDark),
InfoRow(label: '지번 주소', value: restaurant.jibunAddress, isDark: isDark),
if (restaurant.lastVisitDate != null)
_buildDetailRow(
'마지막 방문',
'${restaurant.lastVisitDate!.year}${restaurant.lastVisitDate!.month}${restaurant.lastVisitDate!.day}',
isDark,
InfoRow(
label: '마지막 방문',
value: '${restaurant.lastVisitDate!.year}${restaurant.lastVisitDate!.month}${restaurant.lastVisitDate!.day}',
isDark: isDark,
),
],
),
@@ -226,89 +280,172 @@ class RestaurantCard extends ConsumerWidget {
);
}
Widget _buildDetailRow(String label, String value, bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
),
),
],
),
);
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: [
Text(label, style: AppTypography.caption(isDark)),
const SizedBox(height: 2),
Text(value, style: AppTypography.body2(isDark)),
Icon(Icons.place, size: 16, color: textColor),
const SizedBox(width: 4),
Text(
text,
style: AppTypography.caption(
isDark,
).copyWith(color: textColor, fontWeight: FontWeight.w600),
),
],
),
);
}
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),
),
),
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),
),
),
],
),
);
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';
}
}
if (confirmed == true) {
await ref
.read(restaurantNotifierProvider.notifier)
.deleteRestaurant(restaurant.id);
}
},
),
const SizedBox(height: 8),
],
),
);
},
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: 360,
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});
@@ -13,10 +15,10 @@ class SplashScreen extends StatefulWidget {
}
class _SplashScreenState extends State<SplashScreen>
with TickerProviderStateMixin {
late List<AnimationController> _foodControllers;
late AnimationController _questionMarkController;
late AnimationController _centerIconController;
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
List<Offset>? _iconPositions;
Size? _lastScreenSize;
final List<IconData> foodIcons = [
Icons.rice_bowl,
@@ -38,28 +40,71 @@ class _SplashScreenState extends State<SplashScreen>
}
void _initializeAnimations() {
// 음식 아이콘 애니메이션 (여러 개)
_foodControllers = List.generate(
foodIcons.length,
(index) => AnimationController(
duration: Duration(seconds: 2 + index % 3),
vsync: this,
)..repeat(reverse: true),
);
// 물음표 애니메이션
_questionMarkController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
)..repeat();
// 중앙 아이콘 애니메이션
_centerIconController = AnimationController(
duration: const Duration(seconds: 1),
// 단일 컨트롤러로 모든 애니메이션 제어 (메모리 최적화)
_animationController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..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;
@@ -80,9 +125,9 @@ class _SplashScreenState extends State<SplashScreen>
children: [
// 선택 아이콘
ScaleTransition(
scale: Tween(begin: 0.8, end: 1.2).animate(
scale: Tween(begin: 0.9, end: 1.1).animate(
CurvedAnimation(
parent: _centerIconController,
parent: _animationController,
curve: Curves.easeInOut,
),
),
@@ -102,11 +147,11 @@ class _SplashScreenState extends State<SplashScreen>
children: [
Text('오늘 뭐 먹Z', style: AppTypography.heading1(isDark)),
AnimatedBuilder(
animation: _questionMarkController,
animation: _animationController,
builder: (context, child) {
final questionMarks =
'?' *
(((_questionMarkController.value * 3).floor() % 3) +
(((_animationController.value * 3).floor() % 3) +
1);
return Text(
questionMarks,
@@ -143,34 +188,42 @@ 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];
// 각 아이콘마다 위상(phase)을 다르게 적용
final phase = index / foodIcons.length;
return Positioned(
left: MediaQuery.of(context).size.width * left,
top: MediaQuery.of(context).size.height * top,
child: FadeTransition(
opacity: Tween(begin: 0.2, end: 0.8).animate(
CurvedAnimation(
parent: _foodControllers[index],
curve: Curves.easeInOut,
),
),
child: ScaleTransition(
scale: Tween(begin: 0.5, end: 1.5).animate(
CurvedAnimation(
parent: _foodControllers[index],
curve: Curves.easeInOut,
left: position.dx,
top: position.dy,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
// 위상 차이로 각 아이콘이 다른 타이밍에 애니메이션
final value =
((_animationController.value + phase) % 1.0 - 0.5).abs() * 2;
return Opacity(
opacity: 0.2 + value * 0.4,
child: Transform.scale(
scale: 0.7 + value * 0.5,
child: child,
),
),
child: Icon(
foodIcons[index],
size: 40,
color: AppColors.lightPrimary.withOpacity(0.3),
),
);
},
child: Icon(
foodIcons[index],
size: 40,
color: AppColors.lightPrimary.withValues(alpha: 0.3),
),
),
);
@@ -178,20 +231,34 @@ class _SplashScreenState extends State<SplashScreen>
}
void _navigateToHome() {
Future.delayed(AppConstants.splashAnimationDuration, () {
if (mounted) {
context.go('/home');
}
// 권한 요청이 지연되어도 스플래시(Splash) 화면이 멈추지 않도록 최대 3초만 대기한다.
final permissionFuture = _ensurePermissions().timeout(
const Duration(seconds: 3),
onTimeout: () {},
);
Future.wait([
permissionFuture,
Future.delayed(AppConstants.splashAnimationDuration),
]).whenComplete(() {
if (!mounted) return;
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) {
controller.dispose();
}
_questionMarkController.dispose();
_centerIconController.dispose();
_animationController.dispose();
super.dispose();
}
}

View File

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

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