Compare commits

34 Commits

Author SHA1 Message Date
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
134 changed files with 25143 additions and 4420 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

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

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

View File

@@ -0,0 +1,388 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/domain/entities/recommendation_record.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/domain/entities/visit_record.dart';
import 'package:lunchpick/presentation/providers/di_providers.dart';
class DebugTestDataState {
final bool isEnabled;
final bool isProcessing;
final String? errorMessage;
const DebugTestDataState({
this.isEnabled = false,
this.isProcessing = false,
this.errorMessage,
});
DebugTestDataState copyWith({
bool? isEnabled,
bool? isProcessing,
String? errorMessage,
}) {
return DebugTestDataState(
isEnabled: isEnabled ?? this.isEnabled,
isProcessing: isProcessing ?? this.isProcessing,
errorMessage: errorMessage,
);
}
}
class DebugTestDataNotifier extends StateNotifier<DebugTestDataState> {
DebugTestDataNotifier(this._ref) : super(const DebugTestDataState());
final Ref _ref;
static const String _idPrefix = 'debug-preview-';
Future<void> initialize() async {
if (state.isProcessing) return;
state = state.copyWith(isProcessing: true, errorMessage: null);
try {
final hasDebugData = await _hasExistingDebugData();
state = state.copyWith(isEnabled: hasDebugData, isProcessing: false);
} catch (e) {
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
}
}
Future<void> enableTestData() async {
if (state.isProcessing) return;
state = state.copyWith(isProcessing: true, errorMessage: null);
try {
await _clearDebugData();
await _seedDebugData();
state = state.copyWith(isEnabled: true, isProcessing: false);
} catch (e) {
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
}
}
Future<void> disableTestData() async {
if (state.isProcessing) return;
state = state.copyWith(isProcessing: true, errorMessage: null);
try {
await _clearDebugData();
state = state.copyWith(isEnabled: false, isProcessing: false);
} catch (e) {
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
}
}
Future<void> _seedDebugData() async {
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
final visitRepo = _ref.read(visitRepositoryProvider);
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
final samples = _buildDebugSamples();
for (final sample in samples) {
await restaurantRepo.addRestaurant(sample.restaurant);
}
for (final sample in samples) {
for (final visit in sample.visits) {
await visitRepo.addVisitRecord(visit);
}
for (final reco in sample.recommendations) {
await recommendationRepo.addRecommendationRecord(reco);
}
}
}
Future<void> _clearDebugData() async {
final visitRepo = _ref.read(visitRepositoryProvider);
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
final visits = await visitRepo.getAllVisitRecords();
for (final visit in visits.where((v) => _isDebugId(v.id))) {
await visitRepo.deleteVisitRecord(visit.id);
}
final recos = await recommendationRepo.getAllRecommendationRecords();
for (final reco in recos.where((r) => _isDebugId(r.id))) {
await recommendationRepo.deleteRecommendationRecord(reco.id);
}
final restaurants = await restaurantRepo.getAllRestaurants();
for (final restaurant in restaurants.where((r) => _isDebugId(r.id))) {
await restaurantRepo.deleteRestaurant(restaurant.id);
}
}
Future<bool> _hasExistingDebugData() async {
final visitRepo = _ref.read(visitRepositoryProvider);
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
final visits = await visitRepo.getAllVisitRecords();
final recos = await recommendationRepo.getAllRecommendationRecords();
final restaurants = await restaurantRepo.getAllRestaurants();
return visits.any((v) => _isDebugId(v.id)) ||
recos.any((r) => _isDebugId(r.id)) ||
restaurants.any((r) => _isDebugId(r.id));
}
List<_DebugSample> _buildDebugSamples() {
final today = DateTime.now();
final baseDay = DateTime(today.year, today.month, today.day);
DateTime atDayOffset(int daysAgo, {int hour = 12, int minute = 0}) {
return baseDay
.subtract(Duration(days: daysAgo))
.add(Duration(hours: hour, minutes: minute));
}
VisitRecord buildVisit({
required String id,
required String restaurantId,
required DateTime visitDate,
required bool isConfirmed,
}) {
return VisitRecord(
id: id,
restaurantId: restaurantId,
visitDate: visitDate,
isConfirmed: isConfirmed,
createdAt: visitDate,
);
}
RecommendationRecord buildRecommendation({
required String id,
required String restaurantId,
required DateTime recommendationDate,
}) {
return RecommendationRecord(
id: id,
restaurantId: restaurantId,
recommendationDate: recommendationDate,
visited: false,
createdAt: recommendationDate,
);
}
Restaurant buildRestaurant({
required String id,
required String name,
required String category,
required String subCategory,
required String roadAddress,
required String jibunAddress,
required double latitude,
required double longitude,
required String description,
required String phoneNumber,
required List<VisitRecord> visits,
}) {
final latestVisit = visits
.map((v) => v.visitDate)
.reduce((a, b) => a.isAfter(b) ? a : b);
return Restaurant(
id: id,
name: name,
category: category,
subCategory: subCategory,
description: description,
phoneNumber: phoneNumber,
roadAddress: roadAddress,
jibunAddress: jibunAddress,
latitude: latitude,
longitude: longitude,
lastVisitDate: latestVisit,
source: DataSource.PRESET,
createdAt: baseDay,
updatedAt: baseDay,
naverPlaceId: null,
naverUrl: null,
businessHours: null,
lastVisited: latestVisit,
visitCount: visits.length,
needsAddressVerification: false,
);
}
final bistroId = _withPrefix('bistro');
final sushiId = _withPrefix('sushi');
final coffeeId = _withPrefix('coffee');
final bistroVisits = [
buildVisit(
id: _withPrefix('visit-bistro-0'),
restaurantId: bistroId,
visitDate: atDayOffset(0, hour: 12, minute: 10),
isConfirmed: false,
),
buildVisit(
id: _withPrefix('visit-bistro-1'),
restaurantId: bistroId,
visitDate: atDayOffset(2, hour: 19, minute: 0),
isConfirmed: true,
),
buildVisit(
id: _withPrefix('visit-bistro-2'),
restaurantId: bistroId,
visitDate: atDayOffset(5, hour: 13, minute: 15),
isConfirmed: true,
),
];
final sushiVisits = [
buildVisit(
id: _withPrefix('visit-sushi-0'),
restaurantId: sushiId,
visitDate: atDayOffset(1, hour: 12, minute: 40),
isConfirmed: true,
),
buildVisit(
id: _withPrefix('visit-sushi-1'),
restaurantId: sushiId,
visitDate: atDayOffset(3, hour: 18, minute: 30),
isConfirmed: false,
),
buildVisit(
id: _withPrefix('visit-sushi-2'),
restaurantId: sushiId,
visitDate: atDayOffset(6, hour: 20, minute: 10),
isConfirmed: true,
),
];
final coffeeVisits = [
buildVisit(
id: _withPrefix('visit-coffee-0'),
restaurantId: coffeeId,
visitDate: atDayOffset(2, hour: 9, minute: 30),
isConfirmed: true,
),
buildVisit(
id: _withPrefix('visit-coffee-1'),
restaurantId: coffeeId,
visitDate: atDayOffset(4, hour: 15, minute: 15),
isConfirmed: true,
),
buildVisit(
id: _withPrefix('visit-coffee-2'),
restaurantId: coffeeId,
visitDate: atDayOffset(7, hour: 11, minute: 50),
isConfirmed: true,
),
];
final samples = <_DebugSample>[
_DebugSample(
restaurant: buildRestaurant(
id: bistroId,
name: 'Debug Bistro',
category: 'Fusion',
subCategory: 'Brunch',
description:
'Sample data to preview the record and statistics experience.',
phoneNumber: '02-100-0001',
roadAddress: '서울 테스트로 12',
jibunAddress: '서울 테스트동 12-1',
latitude: 37.5665,
longitude: 126.9780,
visits: bistroVisits,
),
visits: bistroVisits,
recommendations: [
buildRecommendation(
id: _withPrefix('reco-bistro-0'),
restaurantId: bistroId,
recommendationDate: atDayOffset(1, hour: 11, minute: 20),
),
buildRecommendation(
id: _withPrefix('reco-bistro-1'),
restaurantId: bistroId,
recommendationDate: atDayOffset(4, hour: 18, minute: 40),
),
],
),
_DebugSample(
restaurant: buildRestaurant(
id: sushiId,
name: 'Sample Sushi Bar',
category: 'Japanese',
subCategory: 'Sushi',
description: 'Rotating omakase picks to mimic real visit timelines.',
phoneNumber: '02-200-0002',
roadAddress: '서울 샘플로 21',
jibunAddress: '서울 샘플동 21-3',
latitude: 37.5559,
longitude: 126.9363,
visits: sushiVisits,
),
visits: sushiVisits,
recommendations: [
buildRecommendation(
id: _withPrefix('reco-sushi-0'),
restaurantId: sushiId,
recommendationDate: atDayOffset(3, hour: 12, minute: 0),
),
buildRecommendation(
id: _withPrefix('reco-sushi-1'),
restaurantId: sushiId,
recommendationDate: atDayOffset(7, hour: 19, minute: 10),
),
],
),
_DebugSample(
restaurant: buildRestaurant(
id: coffeeId,
name: 'Test Coffee Lab',
category: 'Cafe',
subCategory: 'Dessert',
description: 'Morning cafe stops added so charts render immediately.',
phoneNumber: '02-300-0003',
roadAddress: '서울 예제길 5',
jibunAddress: '서울 예제동 5-2',
latitude: 37.5412,
longitude: 126.986,
visits: coffeeVisits,
),
visits: coffeeVisits,
recommendations: [
buildRecommendation(
id: _withPrefix('reco-coffee-0'),
restaurantId: coffeeId,
recommendationDate: atDayOffset(0, hour: 8, minute: 50),
),
buildRecommendation(
id: _withPrefix('reco-coffee-1'),
restaurantId: coffeeId,
recommendationDate: atDayOffset(5, hour: 16, minute: 30),
),
],
),
];
return samples;
}
bool _isDebugId(String id) => id.startsWith(_idPrefix);
String _withPrefix(String rawId) => '$_idPrefix$rawId';
}
final debugTestDataNotifierProvider =
StateNotifierProvider<DebugTestDataNotifier, DebugTestDataState>((ref) {
return DebugTestDataNotifier(ref);
});
class _DebugSample {
final Restaurant restaurant;
final List<VisitRecord> visits;
final List<RecommendationRecord> recommendations;
_DebugSample({
required this.restaurant,
required this.visits,
required this.recommendations,
});
}

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