Compare commits
40 Commits
master
...
6426d14336
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6426d14336 | ||
|
|
b989981464 | ||
|
|
48c22d76d0 | ||
|
|
c607a52962 | ||
|
|
f5a02f581e | ||
|
|
1cbf9ca82c | ||
|
|
a9fb5695fb | ||
|
|
6f45c7b456 | ||
|
|
21941443ee | ||
|
|
32e25aeb07 | ||
|
|
42c609c57a | ||
|
|
cf7e187985 | ||
|
|
0c6b10d4f6 | ||
|
|
753f578504 | ||
|
|
887f1ad6fe | ||
|
|
0e45616dfd | ||
|
|
99ad8a3bd5 | ||
|
|
2857fe1cb6 | ||
|
|
6a3e8f30d8 | ||
|
|
bcc26f5e79 | ||
|
|
04b1c3e987 | ||
|
|
095222ef61 | ||
|
|
637507f02a | ||
|
|
3f659432e9 | ||
|
|
a4c7f55fc0 | ||
|
|
d733bf664b | ||
|
|
5cae033977 | ||
|
|
4b0e2b4e28 | ||
|
|
4cfff7252e | ||
|
|
e4c5fa7356 | ||
|
|
3ff9e5f837 | ||
|
|
d101f7d0dc | ||
|
|
9f82a0cfda | ||
|
|
df4c34194c | ||
|
|
69902bbc30 | ||
|
|
0e75a23ade | ||
|
|
c1aa16c521 | ||
|
|
d05e378569 | ||
|
|
0e8c06bade | ||
|
|
2a01fa50c6 |
5
.gitignore
vendored
@@ -52,6 +52,11 @@ local.properties
|
|||||||
/android/local.properties
|
/android/local.properties
|
||||||
/ios/Flutter/ephemeral/
|
/ios/Flutter/ephemeral/
|
||||||
|
|
||||||
|
# Keystore & secrets
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
doc/key/
|
||||||
|
|
||||||
# Test Hive files
|
# Test Hive files
|
||||||
test_hive/
|
test_hive/
|
||||||
|
|
||||||
|
|||||||
63
AGENTS.md
@@ -1,52 +1,61 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## 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
|
## Build, Test, and Development Commands
|
||||||
- `flutter pub get` – fetch packages after cloning or switching branches.
|
- `flutter pub get` – run after cloning or switching branches.
|
||||||
- `flutter pub run build_runner build --delete-conflicting-outputs` – regenerate adapters and JSON code when models change.
|
- `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 run -d ios|android|chrome` – start the app on the specified device; prefer simulators that can access location APIs.
|
- `flutter pub run build_runner watch --delete-conflicting-outputs` – keep this on during development to auto-regenerate seeds when `store.db` changes.
|
||||||
- `flutter build apk|appbundle|ios --release` – produce production bundles once QA is green.
|
- `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
|
## 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
|
## 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
|
## 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
|
## 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
|
## 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.
|
- Applies repo-wide unless more specific instructions exist; system/developer directives still win.
|
||||||
- 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.
|
- Keep changes minimal and localized; avoid destructive rewrites, config changes, or dependency bumps unless requested.
|
||||||
- When a task requires multiple steps, maintain an `update_plan` with exactly one step marked `in_progress`.
|
- For multi-step tasks, maintain an `update_plan` with exactly one step `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.
|
- 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
|
## Collaboration & Language
|
||||||
- 기본 응답은 한국어로 작성하고, 코드/로그/명령어는 원문을 유지합니다.
|
- Default responses must be in Korean; keep code/logs/commands in their original form.
|
||||||
- Business logic, identifiers, and UI strings remain in English, but 주석과 문서 설명은 가능한 한 한국어로 작성하고 처음에는 해당 영어 용어를 괄호로 병기합니다.
|
- Business logic, identifiers, and UI strings remain in English; comments/docs in Korean with the first English term in parentheses.
|
||||||
- Git push 보고나 작업 완료 보고 역시 한국어로 작성합니다.
|
- 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
|
## Validation & Quality Checks
|
||||||
- Run `dart format --set-exit-if-changed .` before finishing a task to ensure formatting stays consistent.
|
- Run `dart format --set-exit-if-changed .` before finishing.
|
||||||
- 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.
|
- 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 which commands were executed as part of the validation notes in your PR or hand-off summary.
|
- Document executed commands in your PR or hand-off notes.
|
||||||
|
|
||||||
## Sensitive Areas & Approvals
|
## Sensitive Areas & Approvals
|
||||||
- Editing Android/iOS/macOS build settings, signing artifacts, and Gradle/Xcode configs requires explicit approval.
|
- Editing Android/iOS/macOS build settings, signing artifacts, Gradle/Xcode configs needs explicit approval.
|
||||||
- Do not touch `pubspec.yaml` dependency graphs, secrets, or introduce network activity/tools without checking with the requester first.
|
- Do not touch `pubspec.yaml` dependency graphs, secrets, or add network activity/tools without requester confirmation.
|
||||||
|
|
||||||
## Branch & Reporting Conventions
|
## Branch & Reporting Conventions
|
||||||
- Create task branches as `codex/<type>-<slug>` (e.g., `codex/fix-search-null`).
|
- Branch naming: `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.
|
- Commit messages use `type(scope): summary`; keep explanations short and focused on observable behavior.
|
||||||
- When presenting alternatives, only show the top two concise options to speed up decision-making.
|
- 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
|
## 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.
|
- Each file/class should have a single reason to change; split widgets ~400 lines and methods ~60 lines into helpers.
|
||||||
- Preserve the presentation → domain → data dependency flow. Domain stays framework-agnostic, and data never references presentation.
|
- Preserve presentation → domain → data dependency flow. Domain stays framework-agnostic; data must not reference presentation.
|
||||||
- Extract validation, transformations, sorting, and filtering logic into dedicated services/utilities instead of burying them inside widgets.
|
- Extract validation/transform/sort/filter logic into services/utilities instead of burying it inside widgets.
|
||||||
|
|||||||
387
CLAUDE.md
@@ -1,331 +1,100 @@
|
|||||||
# Claude Code Global Development Rules
|
# LunchPick - 점심 메뉴 추천 앱
|
||||||
|
|
||||||
## 🌐 Language Settings
|
> 글로벌 규칙(~/.claude/CLAUDE.md) 상속. 상세 가이드는 [AGENTS.md](AGENTS.md) 참조.
|
||||||
- **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**
|
|
||||||
|
|
||||||
## 🤖 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:**
|
- `Restaurant`: 음식점 정보 (이름, 카테고리, 위치, 영업시간 등)
|
||||||
- `Claude Opus 4 - Direct Implementation. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!`
|
- `VisitRecord`: 방문 기록
|
||||||
- `Claude Opus 4 - flutter-network-engineer. I have reviewed all the following rules: API integration, error handling, network optimization. Proceeding with the task. Master!`
|
- `RecommendationRecord`: 추천 기록
|
||||||
- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary)
|
- `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:**
|
dart run build_runner watch --delete-conflicting-outputs
|
||||||
- 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
|
|
||||||
|
|
||||||
### Phase 2: Implementation Planning
|
# 분석 & 테스트
|
||||||
**Required Actions:**
|
flutter analyze
|
||||||
- Create detailed implementation roadmap based on Phase 1 findings
|
flutter test
|
||||||
- Define specific task lists and acceptance criteria per module
|
flutter test test_hive # Hive 변경 시
|
||||||
- Specify performance/quality requirements
|
|
||||||
- Plan test strategy and coverage
|
|
||||||
- Identify potential risks and edge cases
|
|
||||||
|
|
||||||
### Phase 3: Implementation Execution
|
# 릴리즈 빌드
|
||||||
**Required Actions:**
|
flutter build appbundle --release
|
||||||
- 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit Types
|
## Agent 응답 형식
|
||||||
- `feat`: New feature
|
|
||||||
- `fix`: Bug fix
|
|
||||||
- `refactor`: Code refactoring
|
|
||||||
- `perf`: Performance improvement
|
|
||||||
- `test`: Test changes
|
|
||||||
- `docs`: Documentation
|
|
||||||
- `style`: Code formatting
|
|
||||||
- `chore`: Build/tooling changes
|
|
||||||
|
|
||||||
## 🏗️ Architecture Guidelines
|
```text
|
||||||
|
[Model Name] - [Agent Name]. I have reviewed all the following rules: [categories]. Proceeding with the task. Master!
|
||||||
### 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 Safe Refactoring Practices
|
### Available Agents
|
||||||
|
|
||||||
### Preventing Side Effects During Refactoring
|
| Agent | 용도 |
|
||||||
- **Run all tests before and after** every refactoring step
|
|-------|------|
|
||||||
- **Make incremental changes**: One small refactoring at a time
|
| Direct Implementation | 직접 구현 |
|
||||||
- **Use automated refactoring tools** when available (IDE support)
|
| flutter-ui-designer | UI/UX 디자인 |
|
||||||
- **Preserve existing behavior**: Refactoring should not change functionality
|
| flutter-architecture-designer | 아키텍처 설계 |
|
||||||
- **Create characterization tests** for legacy code before refactoring
|
| flutter-network-engineer | 네트워크/API |
|
||||||
- **Use feature flags** for large-scale refactorings
|
| flutter-qa-engineer | QA/테스트 |
|
||||||
- **Monitor production metrics** after deployment
|
| 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**:
|
- `api_keys.dart`는 커밋 금지 (로컬 생성)
|
||||||
- [ ] Keep commits atomic and reversible
|
- Android/iOS 빌드 설정 변경 시 승인 필요
|
||||||
- [ ] Run tests after each change
|
- Hive 스키마 변경 시 마이그레이션 고려
|
||||||
- [ ] Verify no behavior changes
|
- 네이버 API 호출 시 캐시 정책 준수
|
||||||
- [ ] 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.
|
|
||||||
|
|||||||
@@ -20,22 +20,30 @@ android {
|
|||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("../../doc/key/lunchpick-release.keystore")
|
||||||
|
storePassword = "lunchpick"
|
||||||
|
keyAlias = "lunchpick"
|
||||||
|
keyPassword = "lunchpick"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.naturebridgeai.lunchpick"
|
applicationId = "com.naturebridgeai.lunchpick"
|
||||||
// You can update the following values to match your application needs.
|
minSdk = flutter.minSdkVersion
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = 23
|
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
|
manifestPlaceholders["admobAppId"] =
|
||||||
|
project.findProperty("ADMOB_APP_ID")
|
||||||
|
?: "ca-app-pub-3940256099942544~3347511713"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,23 @@
|
|||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<!-- 부팅 시 실행 권한 (예약된 알림 유지) -->
|
<!-- 부팅 시 실행 권한 (예약된 알림 유지) -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<!-- 위치 권한 -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<!-- 블루투스 권한 (Android 12+) -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<!-- 블루투스 권한 (Android 11 이하 호환) -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
<application
|
<application
|
||||||
android:label="lunchpick"
|
android:label="오늘뭐먹Z"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="${admobAppId}" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 33 KiB |
BIN
assets/appicon/appicon.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/appicon/appicon512.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
16535
assets/data/store_seed.json
Normal file
10
assets/data/store_seed.meta.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": "47e28144",
|
||||||
|
"generatedAt": "2025-11-26T07:30:53.780901Z",
|
||||||
|
"sourceDb": "doc/restaurant_data/store.db",
|
||||||
|
"itemCount": 1503,
|
||||||
|
"sourceSignature": {
|
||||||
|
"hash": "47e28144",
|
||||||
|
"size": 458752
|
||||||
|
}
|
||||||
|
}
|
||||||
31
build.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
targets:
|
||||||
|
$default:
|
||||||
|
sources:
|
||||||
|
- $package$
|
||||||
|
- lib/**
|
||||||
|
- bin/**
|
||||||
|
- test/**
|
||||||
|
- web/**
|
||||||
|
- example/**
|
||||||
|
- doc/**
|
||||||
|
- tool/**
|
||||||
|
- assets/**
|
||||||
|
- pubspec.yaml
|
||||||
|
builders:
|
||||||
|
lunchpick|store_seed_builder:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
builders:
|
||||||
|
store_seed_builder:
|
||||||
|
import: "package:lunchpick/builders/store_seed_builder.dart"
|
||||||
|
builder_factories: ["storeSeedBuilder"]
|
||||||
|
build_extensions:
|
||||||
|
"doc/restaurant_data/store.db":
|
||||||
|
- "assets/data/store_seed.json"
|
||||||
|
- "assets/data/store_seed.meta.json"
|
||||||
|
auto_apply: root_package
|
||||||
|
build_to: source
|
||||||
|
runs_before: ["source_gen|combining_builder"]
|
||||||
|
defaults:
|
||||||
|
generate_for:
|
||||||
|
- doc/restaurant_data/store.db
|
||||||
@@ -520,7 +520,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Text(
|
child: Text(
|
||||||
'© 2025. NatureBridgeAI. All rights reserved.',
|
'© 2025. NatureBridgeAI & cclabs. All rights reserved.',
|
||||||
style: AppTypography.caption(isDark).copyWith(
|
style: AppTypography.caption(isDark).copyWith(
|
||||||
color: (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary)
|
color: (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary)
|
||||||
.withOpacity(0.5),
|
.withOpacity(0.5),
|
||||||
|
|||||||
@@ -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] 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`에서 직접 입력을 처리합니다.
|
- [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일/거리 조건을 만족하는 식당을 찾으면 팝업으로 노출하고, 다시 추천받기/닫기 버튼을 제공하며, 닫거나 추가 행동 없이 유지되면 방문 처리 및 옵션으로 지정한 시간 이후 푸시 알림을 예약해야 한다. 조건에 맞는 식당이 없으면 광고 없이 “조건에 맞는 식당이 존재하지 않습니다” 토스트만 출력한다.
|
- [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). 임시 광고(닫기 포함) 재생 후 조건 충족 시 추천 팝업을 띄우고, 닫기/자동확정 시 방문 기록·알림 예약을 처리하며 다시 추천은 광고 없이 제외 목록을 적용한다. 조건 불충족 시 광고 없이 토스트를 노출한다.
|
||||||
- [ ] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 현재 화면은 단순 토글과 더미 코드만 제공하며 요구된 광고 게이팅·기기 리스트·데이터 병합 로직이 없습니다.
|
- [x] P2P 리스트 공유 기능(광고 시청(임시화면만 제공) → 코드 생성 → Bluetooth 스캔/수신 → JSON 병합)을 서비스 계층(bluetoothServiceProvider, adServiceProvider, PermissionService 등)과 함께 완성하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:1874-1978, lib/presentation/pages/share/share_screen.dart:13-218). 공유 코드 생성 시 블루투스 권한 확인과 광고 게이팅을 적용하고, 수신 데이터는 광고 시청 후 중복 제거 병합하며, 스캔/전송/취소 흐름을 UI에 연결했습니다.
|
||||||
- [ ] 실시간 날씨 API(기상청) 연동 및 캐시 무효화 로직 구현하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:306-329, lib/data/repositories/weather_repository_impl.dart:16-92). 지금은 더미 WeatherInfo를 반환하고 있어 추천 화면의 날씨 카드가 실제 데이터를 사용하지 못합니다.
|
- [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`으로 주입해야 동작합니다.
|
||||||
- [ ] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 구현본은 `visitRecordsProvider`만 사용하며 추천 기록, 방문 확인 버튼, 추천 이벤트 마커가 모두 빠져 있습니다.
|
- [x] 방문 캘린더에서 추천 이력(`recommendationHistoryProvider`)과 방문 기록을 함께 로딩하고 카드 액션(방문 확인)까지 연결하기 (doc/01_requirements/오늘 뭐 먹Z? 완전한 개발 가이드.md:2055-2127, lib/presentation/pages/calendar/calendar_screen.dart:18-210). 추천 기록을 함께 로딩해 마커/목록에 표시하고, 추천 카드에서 방문 확인 시 `RecommendationNotifier.confirmVisit`를 호출하도록 연계했습니다.
|
||||||
- [ ] 문서에서 요구한 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에 산재해 있어 요구된 책임 분리가 이뤄지지 않습니다.
|
- [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을 처리하여 중복 호출을 줄입니다.
|
||||||
- [ ] `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] `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] RestaurantRepositoryImpl 단위 테스트를 복구하고 `path_provider` 초기화 문제를 해결하기 (doc/07_test_report_lunchpick.md:52-57, test/unit/data/repositories/restaurant_repository_impl_test.dart). Hive 임시 디렉터리 초기화와 어댑터 등록 후 CRUD/URL 추가/미리보기 흐름을 검증하는 단위 테스트를 복구했습니다.
|
||||||
|
|||||||
@@ -31,3 +31,11 @@
|
|||||||
- [아키텍처 개요](03_architecture/architecture_overview.md)
|
- [아키텍처 개요](03_architecture/architecture_overview.md)
|
||||||
- [코드 컨벤션](03_architecture/code_convention.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
|
||||||
|
빌드시 키값을 포함해야 함.
|
||||||
BIN
doc/public_data_api/기상청41_단기예보 조회서비스_오픈API활용가이드_241128.docx
Normal file
BIN
doc/restaurant_data/store.db
Normal file
190
doc/store_desc/privacy_policy.md
Normal 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
|
||||||
52
doc/store_desc/store_description.md
Normal 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분 안에 끝내 보세요.
|
||||||
|
※ 본 서비스는 현재 한국(대한민국) 지역에서만 사용 가능합니다.
|
||||||
40
doc/ux_calendar_sync_plan.md
Normal 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 혹은 디자인 가이드) 업데이트 여부 검토.
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
|
GAD_APPLICATION_ID=ca-app-pub-3940256099942544~1458002511
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
|
GAD_APPLICATION_ID=ca-app-pub-3940256099942544~1458002511
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -7,7 +8,16 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Lunchpick</string>
|
<string>오늘뭐먹Z</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>lunchpick</string>
|
<string>오늘뭐먹Z</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -54,5 +54,7 @@
|
|||||||
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
||||||
|
<key>GADApplicationIdentifier</key>
|
||||||
|
<string>$(GAD_APPLICATION_ID)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
192
lib/builders/store_seed_builder.dart
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:build/build.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
class StoreSeedBuilder implements Builder {
|
||||||
|
StoreSeedBuilder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Map<String, List<String>> buildExtensions = const {
|
||||||
|
'doc/restaurant_data/store.db': [
|
||||||
|
'assets/data/store_seed.json',
|
||||||
|
'assets/data/store_seed.meta.json',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> build(BuildStep buildStep) async {
|
||||||
|
final inputId = buildStep.inputId;
|
||||||
|
final bytes = await buildStep.readAsBytes(inputId);
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
log.warning('store.db가 비어 있습니다. 시드를 건너뜁니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('store_seed_');
|
||||||
|
final tempDbPath = p.join(tempDir.path, 'store.db');
|
||||||
|
await File(tempDbPath).writeAsBytes(bytes, flush: true);
|
||||||
|
|
||||||
|
final sqlitePath = await _findSqliteBinary();
|
||||||
|
if (sqlitePath == null) {
|
||||||
|
log.severe('sqlite3 바이너리를 찾을 수 없습니다. 설치 후 다시 시도하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rows = await _fetchRows(sqlitePath, tempDbPath);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
log.warning('restaurants 테이블에서 가져온 행이 없습니다.');
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newSeeds = rows.map(_seedFromMap).toList();
|
||||||
|
final merged = await _mergeWithExisting(buildStep, newSeeds);
|
||||||
|
|
||||||
|
final signature = _buildSignature(bytes);
|
||||||
|
final generatedAt = DateTime.now().toUtc().toIso8601String();
|
||||||
|
final meta = {
|
||||||
|
'version': signature,
|
||||||
|
'generatedAt': generatedAt,
|
||||||
|
'sourceDb': inputId.path,
|
||||||
|
'itemCount': merged.length,
|
||||||
|
'sourceSignature': {'hash': signature, 'size': bytes.length},
|
||||||
|
};
|
||||||
|
|
||||||
|
final encoder = const JsonEncoder.withIndent(' ');
|
||||||
|
await buildStep.writeAsString(
|
||||||
|
AssetId(inputId.package, 'assets/data/store_seed.json'),
|
||||||
|
'${encoder.convert(merged)}\n',
|
||||||
|
);
|
||||||
|
await buildStep.writeAsString(
|
||||||
|
AssetId(inputId.package, 'assets/data/store_seed.meta.json'),
|
||||||
|
'${encoder.convert(meta)}\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
log.info(
|
||||||
|
'store_seed 생성 완료: ${merged.length}개 (sig: $signature, src: ${inputId.path})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> _fetchRows(
|
||||||
|
String sqlitePath,
|
||||||
|
String dbPath,
|
||||||
|
) async {
|
||||||
|
const query =
|
||||||
|
'SELECT id, province, district, name, title, address, road_address, '
|
||||||
|
'latitude, longitude FROM restaurants';
|
||||||
|
|
||||||
|
final result = await Process.run(
|
||||||
|
sqlitePath,
|
||||||
|
['-json', dbPath, query],
|
||||||
|
stdoutEncoding: utf8,
|
||||||
|
stderrEncoding: utf8,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw StateError('sqlite3 실행 실패: ${result.stderr}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final output = result.stdout as String;
|
||||||
|
final decoded = jsonDecode(output);
|
||||||
|
if (decoded is! List) {
|
||||||
|
throw const FormatException('예상치 못한 JSON 포맷입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _seedFromMap(Map<String, dynamic> map) {
|
||||||
|
return {
|
||||||
|
'storeId': map['id'] as int,
|
||||||
|
'province': (map['province'] as String).trim(),
|
||||||
|
'district': (map['district'] as String).trim(),
|
||||||
|
'name': (map['name'] as String).trim(),
|
||||||
|
'title': (map['title'] as String).trim(),
|
||||||
|
'address': (map['address'] as String).trim(),
|
||||||
|
'roadAddress': (map['road_address'] as String).trim(),
|
||||||
|
'latitude': (map['latitude'] as num).toDouble(),
|
||||||
|
'longitude': (map['longitude'] as num).toDouble(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> _mergeWithExisting(
|
||||||
|
BuildStep buildStep,
|
||||||
|
List<Map<String, dynamic>> newSeeds,
|
||||||
|
) async {
|
||||||
|
final existingId = AssetId(
|
||||||
|
buildStep.inputId.package,
|
||||||
|
'assets/data/store_seed.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> existing = [];
|
||||||
|
if (await buildStep.canRead(existingId)) {
|
||||||
|
final raw = await buildStep.readAsString(existingId);
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is List) {
|
||||||
|
existing = decoded.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
log.warning('기존 store_seed.json 파싱 실패, 신규 데이터로 대체합니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final byId = <String, Map<String, dynamic>>{};
|
||||||
|
for (final seed in existing) {
|
||||||
|
final id = _seedId(seed);
|
||||||
|
byId[id] = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final seed in newSeeds) {
|
||||||
|
final id = _seedId(seed);
|
||||||
|
if (!byId.containsKey(id)) {
|
||||||
|
byId[id] = seed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isDuplicateByNameAndAddress = byId.values.any((existingSeed) {
|
||||||
|
return existingSeed['name'] == seed['name'] &&
|
||||||
|
existingSeed['roadAddress'] == seed['roadAddress'];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDuplicateByNameAndAddress) {
|
||||||
|
byId[id] = seed; // 같은 ID는 최신 값으로 교체
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final merged = byId.values.toList()
|
||||||
|
..sort((a, b) => (_seedId(a)).compareTo(_seedId(b)));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _seedId(Map<String, dynamic> seed) => 'store-${seed['storeId']}';
|
||||||
|
|
||||||
|
Future<String?> _findSqliteBinary() async {
|
||||||
|
try {
|
||||||
|
final result = await Process.run('which', ['sqlite3']);
|
||||||
|
if (result.exitCode == 0) {
|
||||||
|
final path = (result.stdout as String).trim();
|
||||||
|
if (path.isNotEmpty) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildSignature(List<int> bytes) {
|
||||||
|
int hash = 0;
|
||||||
|
for (final byte in bytes) {
|
||||||
|
hash = (hash * 31 + byte) & 0x7fffffff;
|
||||||
|
}
|
||||||
|
return hash.toRadixString(16).padLeft(8, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder storeSeedBuilder(BuilderOptions options) => StoreSeedBuilder();
|
||||||
|
// ignore_for_file: depend_on_referenced_packages
|
||||||
@@ -18,9 +18,25 @@ class ApiKeys {
|
|||||||
static String get naverClientId => _decodeIfNeeded(_encodedClientId);
|
static String get naverClientId => _decodeIfNeeded(_encodedClientId);
|
||||||
static String get naverClientSecret => _decodeIfNeeded(_encodedClientSecret);
|
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 =
|
static const String naverLocalSearchEndpoint =
|
||||||
'https://openapi.naver.com/v1/search/local.json';
|
'https://openapi.naver.com/v1/search/local.json';
|
||||||
|
|
||||||
|
// VWorld 지오코딩 키 (dart-define: VWORLD_API_KEY, base64 권장)
|
||||||
|
static const String _encodedVworldApiKey = String.fromEnvironment(
|
||||||
|
'VWORLD_API_KEY',
|
||||||
|
defaultValue: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
static String get vworldApiKey => _decodeIfNeeded(_encodedVworldApiKey);
|
||||||
|
|
||||||
static bool areKeysConfigured() {
|
static bool areKeysConfigured() {
|
||||||
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
return naverClientId.isNotEmpty && naverClientSecret.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class AppColors {
|
|||||||
static const lightError = Color(0xFFFF5252);
|
static const lightError = Color(0xFFFF5252);
|
||||||
static const lightText = Color(0xFF222222); // 추가
|
static const lightText = Color(0xFF222222); // 추가
|
||||||
static const lightCard = Colors.white; // 추가
|
static const lightCard = Colors.white; // 추가
|
||||||
|
static const lightWarning = Color(0xFFFFA000);
|
||||||
|
|
||||||
// Dark Theme Colors
|
// Dark Theme Colors
|
||||||
static const darkPrimary = Color(0xFF03C75A);
|
static const darkPrimary = Color(0xFF03C75A);
|
||||||
@@ -24,4 +25,5 @@ class AppColors {
|
|||||||
static const darkError = Color(0xFFFF5252);
|
static const darkError = Color(0xFFFF5252);
|
||||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||||
|
static const darkWarning = Color(0xFFFFB74D);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class AppConstants {
|
|||||||
static const String appDescription = '점심 메뉴 추천 앱';
|
static const String appDescription = '점심 메뉴 추천 앱';
|
||||||
static const String appVersion = '1.0.0';
|
static const String appVersion = '1.0.0';
|
||||||
static const String appCopyright =
|
static const String appCopyright =
|
||||||
'© 2025. NatureBridgeAI. All rights reserved.';
|
'© 2025. NatureBridgeAI & cclabs. All rights reserved.';
|
||||||
|
|
||||||
// Animation Durations
|
// Animation Durations
|
||||||
static const Duration splashAnimationDuration = Duration(seconds: 3);
|
static const Duration splashAnimationDuration = Duration(seconds: 3);
|
||||||
@@ -14,20 +14,31 @@ class AppConstants {
|
|||||||
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
static const String naverMapApiKey = 'YOUR_NAVER_MAP_API_KEY';
|
||||||
static const String weatherApiKey = 'YOUR_WEATHER_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 androidAdAppId = 'ca-app-pub-3940256099942544~3347511713';
|
||||||
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511';
|
||||||
static const String interstitialAdUnitId =
|
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
|
// Hive Box Names
|
||||||
static const String restaurantBox = 'restaurants';
|
static const String restaurantBox = 'restaurants';
|
||||||
static const String visitRecordBox = 'visit_records';
|
static const String visitRecordBox = 'visit_records';
|
||||||
static const String recommendationBox = 'recommendations';
|
static const String recommendationBox = 'recommendations';
|
||||||
static const String settingsBox = 'settings';
|
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
|
// Default Settings
|
||||||
static const int defaultDaysToExclude = 7;
|
static const int defaultDaysToExclude = 14;
|
||||||
static const int defaultNotificationMinutes = 90;
|
static const int defaultNotificationMinutes = 90;
|
||||||
static const int defaultMaxDistanceNormal = 1000; // meters
|
static const int defaultMaxDistanceNormal = 1000; // meters
|
||||||
static const int defaultMaxDistanceRainy = 500; // meters
|
static const int defaultMaxDistanceRainy = 500; // meters
|
||||||
|
|||||||
55
lib/core/constants/app_dimensions.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
|
||||||
/// 로깅 인터셉터
|
/// 로깅 인터셉터
|
||||||
///
|
///
|
||||||
@@ -13,16 +14,18 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
final method = options.method;
|
final method = options.method;
|
||||||
final headers = options.headers;
|
final headers = options.headers;
|
||||||
|
|
||||||
print('═══════════════════════════════════════════════════════════════');
|
AppLogger.debug(
|
||||||
print('>>> REQUEST [$method] $uri');
|
'═══════════════════════════════════════════════════════════════',
|
||||||
print('>>> Headers: $headers');
|
);
|
||||||
|
AppLogger.debug('>>> REQUEST [$method] $uri');
|
||||||
|
AppLogger.debug('>>> Headers: $headers');
|
||||||
|
|
||||||
if (options.data != null) {
|
if (options.data != null) {
|
||||||
print('>>> Body: ${options.data}');
|
AppLogger.debug('>>> Body: ${options.data}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.queryParameters.isNotEmpty) {
|
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 statusCode = response.statusCode;
|
||||||
final uri = response.requestOptions.uri;
|
final uri = response.requestOptions.uri;
|
||||||
|
|
||||||
print('<<< RESPONSE [$statusCode] $uri');
|
AppLogger.debug('<<< RESPONSE [$statusCode] $uri');
|
||||||
|
|
||||||
if (response.headers.map.isNotEmpty) {
|
if (response.headers.map.isNotEmpty) {
|
||||||
print('<<< Headers: ${response.headers.map}');
|
AppLogger.debug('<<< Headers: ${response.headers.map}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
|
// 응답 본문은 너무 길 수 있으므로 처음 500자만 출력
|
||||||
final responseData = response.data.toString();
|
final responseData = response.data.toString();
|
||||||
if (responseData.length > 500) {
|
if (responseData.length > 500) {
|
||||||
print('<<< Body: ${responseData.substring(0, 500)}...(truncated)');
|
AppLogger.debug(
|
||||||
|
'<<< Body: ${responseData.substring(0, 500)}...(truncated)',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
print('<<< Body: $responseData');
|
AppLogger.debug('<<< Body: $responseData');
|
||||||
}
|
}
|
||||||
|
|
||||||
print('═══════════════════════════════════════════════════════════════');
|
AppLogger.debug(
|
||||||
|
'═══════════════════════════════════════════════════════════════',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.next(response);
|
return handler.next(response);
|
||||||
@@ -61,17 +68,21 @@ class LoggingInterceptor extends Interceptor {
|
|||||||
final uri = err.requestOptions.uri;
|
final uri = err.requestOptions.uri;
|
||||||
final message = err.message;
|
final message = err.message;
|
||||||
|
|
||||||
print('═══════════════════════════════════════════════════════════════');
|
AppLogger.debug(
|
||||||
print('!!! ERROR $uri');
|
'═══════════════════════════════════════════════════════════════',
|
||||||
print('!!! Message: $message');
|
);
|
||||||
|
AppLogger.debug('!!! ERROR $uri');
|
||||||
|
AppLogger.debug('!!! Message: $message');
|
||||||
|
|
||||||
if (err.response != null) {
|
if (err.response != null) {
|
||||||
print('!!! Status Code: ${err.response!.statusCode}');
|
AppLogger.debug('!!! Status Code: ${err.response!.statusCode}');
|
||||||
print('!!! Response: ${err.response!.data}');
|
AppLogger.debug('!!! Response: ${err.response!.data}');
|
||||||
}
|
}
|
||||||
|
|
||||||
print('!!! Error Type: ${err.type}');
|
AppLogger.debug('!!! Error Type: ${err.type}');
|
||||||
print('═══════════════════════════════════════════════════════════════');
|
AppLogger.debug(
|
||||||
|
'═══════════════════════════════════════════════════════════════',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.next(err);
|
return handler.next(err);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import '../network_config.dart';
|
import '../network_config.dart';
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
import '../../errors/network_exceptions.dart';
|
import '../../errors/network_exceptions.dart';
|
||||||
|
|
||||||
/// 재시도 인터셉터
|
/// 재시도 인터셉터
|
||||||
@@ -24,7 +25,7 @@ class RetryInterceptor extends Interceptor {
|
|||||||
// 지수 백오프 계산
|
// 지수 백오프 계산
|
||||||
final delay = _calculateBackoffDelay(retryCount);
|
final delay = _calculateBackoffDelay(retryCount);
|
||||||
|
|
||||||
print(
|
AppLogger.debug(
|
||||||
'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기',
|
'RetryInterceptor: 재시도 ${retryCount + 1}/${NetworkConfig.maxRetries} - ${delay}ms 대기',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ class RetryInterceptor extends Interceptor {
|
|||||||
// 네이버 관련 요청은 재시도하지 않음
|
// 네이버 관련 요청은 재시도하지 않음
|
||||||
final url = err.requestOptions.uri.toString();
|
final url = err.requestOptions.uri.toString();
|
||||||
if (url.contains('naver.com') || url.contains('naver.me')) {
|
if (url.contains('naver.com') || url.contains('naver.me')) {
|
||||||
print('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
AppLogger.debug('RetryInterceptor: 네이버 API 요청은 재시도하지 않음 - $url');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.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:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'network_config.dart';
|
import 'network_config.dart';
|
||||||
import '../errors/network_exceptions.dart';
|
import '../errors/network_exceptions.dart';
|
||||||
@@ -88,8 +90,12 @@ class NetworkClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
|
_dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NetworkClient: 캐시 설정 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NetworkClient: 캐시 설정 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
// 캐시 실패해도 계속 진행
|
// 캐시 실패해도 계속 진행
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +1,124 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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 {
|
class AdService {
|
||||||
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
|
InterstitialAd? _interstitialAd;
|
||||||
|
Completer<bool>? _loadingCompleter;
|
||||||
|
|
||||||
|
/// 광고를 로드하고 재생한 뒤 완료 여부를 반환한다.
|
||||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
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,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (_) => const _MockInterstitialAdDialog(),
|
barrierColor: Colors.black.withOpacity(0.35),
|
||||||
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
return result ?? false;
|
return () {
|
||||||
}
|
if (navigator.mounted && navigator.canPop()) {
|
||||||
}
|
navigator.pop();
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<bool> _ensureAdLoaded() async {
|
||||||
void dispose() {
|
if (_interstitialAd != null) return true;
|
||||||
_timer.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
InterstitialAd.load(
|
||||||
Widget build(BuildContext context) {
|
adUnitId: AdHelper.interstitialAdUnitId,
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
request: const AdRequest(),
|
||||||
|
adLoadCallback: InterstitialAdLoadCallback(
|
||||||
return Dialog(
|
onAdLoaded: (ad) {
|
||||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 80),
|
_interstitialAd = ad;
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
completer.complete(true);
|
||||||
child: Stack(
|
_loadingCompleter = null;
|
||||||
children: [
|
},
|
||||||
Padding(
|
onAdFailedToLoad: (error) {
|
||||||
padding: const EdgeInsets.all(24),
|
completer.complete(false);
|
||||||
child: Column(
|
_loadingCompleter = null;
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _preload() {
|
||||||
|
if (_interstitialAd != null || _loadingCompleter != null) return;
|
||||||
|
_ensureAdLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
lib/core/services/geocoding_service.dart
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// 주소 전처리: 상세 주소(층수, 상호명 등) 제거
|
||||||
|
final cleanedAddress = _cleanAddress(address);
|
||||||
|
|
||||||
|
// 1차: VWorld 지오코딩 시도 (키가 존재할 때만)
|
||||||
|
final vworldResult = await _geocodeWithVworld(cleanedAddress);
|
||||||
|
if (vworldResult != null) {
|
||||||
|
return vworldResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2차: Nominatim (fallback)
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(cleanedAddress)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 주소에서 상세 주소(층수, 상호명 등)를 제거하여 순수 도로명 주소만 추출한다.
|
||||||
|
///
|
||||||
|
/// 예시:
|
||||||
|
/// - "서울 관악구 관악로14길 6-4 1층 이자카야 혼네" → "서울 관악구 관악로14길 6-4"
|
||||||
|
/// - "서울특별시 강남구 테헤란로 123 B1 스타벅스" → "서울특별시 강남구 테헤란로 123"
|
||||||
|
String _cleanAddress(String address) {
|
||||||
|
final trimmed = address.trim();
|
||||||
|
|
||||||
|
// 패턴 1: 건물번호 뒤에 층수 정보가 있는 경우 (1층, B1, 지하1층 등)
|
||||||
|
// 도로명 주소의 건물번호는 숫자 또는 숫자-숫자 형태
|
||||||
|
final floorPattern = RegExp(
|
||||||
|
r'(\d+(?:-\d+)?)\s+(?:\d+층|[Bb]\d+|지하\d*층?).*$',
|
||||||
|
);
|
||||||
|
final floorMatch = floorPattern.firstMatch(trimmed);
|
||||||
|
if (floorMatch != null) {
|
||||||
|
final buildingNumber = floorMatch.group(1);
|
||||||
|
final beforeMatch = trimmed.substring(0, floorMatch.start);
|
||||||
|
return '$beforeMatch$buildingNumber'.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패턴 2: 건물번호 뒤에 상호명이 바로 오는 경우 (공백 + 한글/영문)
|
||||||
|
// 단, 구/동/로/길 같은 주소 구성요소는 제외
|
||||||
|
final namePattern = RegExp(
|
||||||
|
r'(\d+(?:-\d+)?)\s+(?![가-힣]+[구동로길읍면리]\s)([가-힣a-zA-Z&]+.*)$',
|
||||||
|
);
|
||||||
|
final nameMatch = namePattern.firstMatch(trimmed);
|
||||||
|
if (nameMatch != null) {
|
||||||
|
final buildingNumber = nameMatch.group(1);
|
||||||
|
final beforeMatch = trimmed.substring(0, nameMatch.start);
|
||||||
|
return '$beforeMatch$buildingNumber'.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:timezone/timezone.dart' as tz;
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
import 'package:timezone/data/latest_all.dart' as tz;
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
|
||||||
/// 알림 서비스 싱글톤 클래스
|
/// 알림 서비스 싱글톤 클래스
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
@@ -14,6 +15,7 @@ class NotificationService {
|
|||||||
// Flutter Local Notifications 플러그인
|
// Flutter Local Notifications 플러그인
|
||||||
final FlutterLocalNotificationsPlugin _notifications =
|
final FlutterLocalNotificationsPlugin _notifications =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
// 알림 채널 정보
|
// 알림 채널 정보
|
||||||
static const String _channelId = 'lunchpick_visit_reminder';
|
static const String _channelId = 'lunchpick_visit_reminder';
|
||||||
@@ -22,11 +24,36 @@ class NotificationService {
|
|||||||
|
|
||||||
// 알림 ID (방문 확인용)
|
// 알림 ID (방문 확인용)
|
||||||
static const int _visitReminderNotificationId = 1;
|
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 {
|
Future<bool> initialize() async {
|
||||||
|
if (_initialized) return true;
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
|
||||||
// 시간대 초기화
|
// 시간대 초기화
|
||||||
tz.initializeTimeZones();
|
await _ensureLocalTimezone();
|
||||||
|
|
||||||
// Android 초기화 설정
|
// Android 초기화 설정
|
||||||
const androidInitSettings = AndroidInitializationSettings(
|
const androidInitSettings = AndroidInitializationSettings(
|
||||||
@@ -58,18 +85,25 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 알림 플러그인 초기화
|
// 알림 플러그인 초기화
|
||||||
final initialized = await _notifications.initialize(
|
try {
|
||||||
initSettings,
|
final initialized = await _notifications.initialize(
|
||||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
initSettings,
|
||||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
|
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||||
);
|
onDidReceiveBackgroundNotificationResponse:
|
||||||
|
_onBackgroundNotificationTap,
|
||||||
|
);
|
||||||
|
_initialized = initialized ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
_initialized = false;
|
||||||
|
AppLogger.debug('알림 초기화 실패: $e');
|
||||||
|
}
|
||||||
|
|
||||||
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
if (_initialized && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
await _createNotificationChannel();
|
await _createNotificationChannel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialized ?? false;
|
return _initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android 알림 채널 생성
|
/// Android 알림 채널 생성
|
||||||
@@ -83,34 +117,34 @@ class NotificationService {
|
|||||||
enableVibration: true,
|
enableVibration: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications
|
try {
|
||||||
.resolvePlatformSpecificImplementation<
|
await _notifications
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
.resolvePlatformSpecificImplementation<
|
||||||
>()
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
?.createNotificationChannel(androidChannel);
|
>()
|
||||||
|
?.createNotificationChannel(androidChannel);
|
||||||
|
} catch (e) {
|
||||||
|
AppLogger.debug('안드로이드 채널 생성 실패: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 알림 권한 요청
|
/// 알림 권한 요청
|
||||||
Future<bool> requestPermission() async {
|
Future<bool> requestPermission() async {
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
if (kIsWeb) return false;
|
||||||
|
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
final androidImplementation = _notifications
|
final androidImplementation = _notifications
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
>();
|
>();
|
||||||
|
|
||||||
if (androidImplementation != null) {
|
if (androidImplementation != null) {
|
||||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
final granted = await androidImplementation
|
||||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
.requestNotificationsPermission();
|
||||||
final granted = await androidImplementation
|
return granted ?? false;
|
||||||
.requestNotificationsPermission();
|
|
||||||
return granted ?? false;
|
|
||||||
}
|
|
||||||
// Android 12 이하는 자동 허용
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
} else if (!kIsWeb &&
|
} else if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||||
defaultTargetPlatform == TargetPlatform.macOS)) {
|
|
||||||
final iosImplementation = _notifications
|
final iosImplementation = _notifications
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
IOSFlutterLocalNotificationsPlugin
|
IOSFlutterLocalNotificationsPlugin
|
||||||
@@ -144,27 +178,86 @@ class NotificationService {
|
|||||||
|
|
||||||
/// 권한 상태 확인
|
/// 권한 상태 확인
|
||||||
Future<bool> checkPermission() async {
|
Future<bool> checkPermission() async {
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
if (kIsWeb) return false;
|
||||||
|
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
final androidImplementation = _notifications
|
final androidImplementation = _notifications
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
AndroidFlutterLocalNotificationsPlugin
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
>();
|
>();
|
||||||
|
|
||||||
if (androidImplementation != null) {
|
if (androidImplementation != null) {
|
||||||
// Android 13 이상에서만 권한 확인
|
final granted = await androidImplementation.areNotificationsEnabled();
|
||||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
return granted ?? false;
|
||||||
final granted = await androidImplementation.areNotificationsEnabled();
|
|
||||||
return granted ?? false;
|
|
||||||
}
|
|
||||||
// Android 12 이하는 자동 허용
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
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;
|
static void Function(NotificationResponse)? onNotificationTap;
|
||||||
|
|
||||||
@@ -176,9 +269,22 @@ class NotificationService {
|
|||||||
int? delayMinutes,
|
int? delayMinutes,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
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(
|
final scheduledTime = tz.TZDateTime.now(
|
||||||
tz.local,
|
location,
|
||||||
).add(Duration(minutes: minutesToWait));
|
).add(Duration(minutes: minutesToWait));
|
||||||
|
|
||||||
// 알림 상세 설정
|
// 알림 상세 설정
|
||||||
@@ -215,45 +321,87 @@ class NotificationService {
|
|||||||
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
||||||
scheduledTime,
|
scheduledTime,
|
||||||
notificationDetails,
|
notificationDetails,
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
androidScheduleMode: await _resolveAndroidScheduleMode(),
|
||||||
uiLocalNotificationDateInterpretation:
|
uiLocalNotificationDateInterpretation:
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
payload:
|
payload:
|
||||||
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||||
);
|
);
|
||||||
if (kDebugMode) {
|
AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('알림 예약 실패: $e');
|
||||||
print('알림 예약 실패: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 예약된 방문 확인 알림 취소
|
/// 예약된 방문 확인 알림 취소
|
||||||
Future<void> cancelVisitReminder() async {
|
Future<void> cancelVisitReminder() async {
|
||||||
|
if (!await ensureInitialized()) return;
|
||||||
await _notifications.cancel(_visitReminderNotificationId);
|
await _notifications.cancel(_visitReminderNotificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 모든 알림 취소
|
/// 모든 알림 취소
|
||||||
Future<void> cancelAllNotifications() async {
|
Future<void> cancelAllNotifications() async {
|
||||||
|
if (!await ensureInitialized()) return;
|
||||||
await _notifications.cancelAll();
|
await _notifications.cancelAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 예약된 알림 목록 조회
|
/// 예약된 알림 목록 조회
|
||||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||||
|
if (!await ensureInitialized()) return [];
|
||||||
return await _notifications.pendingNotificationRequests();
|
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) {
|
void _onNotificationTap(NotificationResponse response) {
|
||||||
if (onNotificationTap != null) {
|
if (onNotificationTap != null) {
|
||||||
onNotificationTap!(response);
|
onNotificationTap!(response);
|
||||||
} else if (response.payload != null) {
|
} else if (response.payload != null) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('알림 탭: ${response.payload}');
|
||||||
print('알림 탭: ${response.payload}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,9 +411,7 @@ class NotificationService {
|
|||||||
if (onNotificationTap != null) {
|
if (onNotificationTap != null) {
|
||||||
onNotificationTap!(response);
|
onNotificationTap!(response);
|
||||||
} else if (response.payload != null) {
|
} else if (response.payload != null) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('백그라운드 알림 탭: ${response.payload}');
|
||||||
print('백그라운드 알림 탭: ${response.payload}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
lib/core/utils/ad_helper.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../constants/app_constants.dart';
|
||||||
|
|
||||||
|
class AdHelper {
|
||||||
|
static bool get isMobilePlatform {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String get interstitialAdUnitId {
|
||||||
|
if (!isMobilePlatform) {
|
||||||
|
throw UnsupportedError('Interstitial ads are only supported on mobile.');
|
||||||
|
}
|
||||||
|
return AppConstants.interstitialAdUnitId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String get nativeAdUnitId {
|
||||||
|
if (!isMobilePlatform) {
|
||||||
|
throw UnsupportedError('Native ads are only supported on mobile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
if (isIOS) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
return AppConstants.testIosNativeAdUnitId;
|
||||||
|
}
|
||||||
|
return AppConstants.iosNativeAdUnitId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android는 디버그/릴리즈 모두 실제 광고 단위 ID 사용
|
||||||
|
return AppConstants.androidNativeAdUnitId;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
lib/core/utils/app_logger.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 앱 전역에서 사용하는 로거.
|
||||||
|
/// debugPrint를 감싸 경고 없이 로그를 남기며, debug 레벨은 디버그 모드에서만 출력합니다.
|
||||||
|
class AppLogger {
|
||||||
|
AppLogger._();
|
||||||
|
|
||||||
|
static void debug(String message) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void info(String message) {
|
||||||
|
debugPrint(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void error(String message, {Object? error, StackTrace? stackTrace}) {
|
||||||
|
final buffer = StringBuffer(message);
|
||||||
|
if (error != null) {
|
||||||
|
buffer.write(' | error: $error');
|
||||||
|
}
|
||||||
|
if (stackTrace != null) {
|
||||||
|
buffer.write('\n$stackTrace');
|
||||||
|
}
|
||||||
|
debugPrint(buffer.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
lib/core/widgets/info_row.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
147
lib/core/widgets/skeleton_loader.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
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_client.dart';
|
||||||
import '../../../core/errors/network_exceptions.dart';
|
import '../../../core/errors/network_exceptions.dart';
|
||||||
@@ -46,7 +46,11 @@ class NaverGraphQLApi {
|
|||||||
|
|
||||||
return response.data!;
|
return response.data!;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint('fetchGraphQL error: $e');
|
AppLogger.error(
|
||||||
|
'fetchGraphQL error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: e.stackTrace,
|
||||||
|
);
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
message: 'GraphQL 요청 중 오류가 발생했습니다',
|
message: 'GraphQL 요청 중 오류가 발생했습니다',
|
||||||
statusCode: e.response?.statusCode ?? 500,
|
statusCode: e.response?.statusCode ?? 500,
|
||||||
@@ -104,13 +108,17 @@ class NaverGraphQLApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response['errors'] != null) {
|
if (response['errors'] != null) {
|
||||||
debugPrint('GraphQL errors: ${response['errors']}');
|
AppLogger.error('GraphQL errors: ${response['errors']}');
|
||||||
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
throw ParseException(message: 'GraphQL 오류: ${response['errors']}');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response['data']?['place'] ?? {};
|
return response['data']?['place'] ?? {};
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('fetchKoreanTextsFromPcmap error: $e');
|
AppLogger.error(
|
||||||
|
'fetchKoreanTextsFromPcmap error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +158,12 @@ class NaverGraphQLApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response['data']?['place'] ?? {};
|
return response['data']?['place'] ?? {};
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('fetchPlaceBasicInfo error: $e');
|
AppLogger.error(
|
||||||
|
'fetchPlaceBasicInfo error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
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/constants/api_keys.dart';
|
||||||
import '../../../core/network/network_client.dart';
|
import '../../../core/network/network_client.dart';
|
||||||
@@ -143,9 +143,13 @@ class NaverLocalSearchApi {
|
|||||||
.map((item) => NaverLocalSearchResult.fromJson(item))
|
.map((item) => NaverLocalSearchResult.fromJson(item))
|
||||||
.toList();
|
.toList();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint('NaverLocalSearchApi Error: ${e.message}');
|
AppLogger.error(
|
||||||
debugPrint('Error type: ${e.type}');
|
'NaverLocalSearchApi error: ${e.message}',
|
||||||
debugPrint('Error response: ${e.response?.data}');
|
error: e,
|
||||||
|
stackTrace: e.stackTrace,
|
||||||
|
);
|
||||||
|
AppLogger.debug('Error type: ${e.type}');
|
||||||
|
AppLogger.debug('Error response: ${e.response?.data}');
|
||||||
|
|
||||||
if (e.error is NetworkException) {
|
if (e.error is NetworkException) {
|
||||||
throw e.error!;
|
throw e.error!;
|
||||||
@@ -189,8 +193,12 @@ class NaverLocalSearchApi {
|
|||||||
|
|
||||||
// 정확한 매칭이 없으면 첫 번째 결과 반환
|
// 정확한 매칭이 없으면 첫 번째 결과 반환
|
||||||
return results.first;
|
return results.first;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('searchRestaurantDetails error: $e');
|
AppLogger.error(
|
||||||
|
'searchRestaurantDetails error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../../../core/network/network_client.dart';
|
import '../../../core/network/network_client.dart';
|
||||||
import '../../../core/network/network_config.dart';
|
import '../../../core/network/network_config.dart';
|
||||||
@@ -22,7 +23,7 @@ class NaverProxyClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
final proxyUrl = NetworkConfig.getCorsProxyUrl(url);
|
||||||
debugPrint('Using proxy URL: $proxyUrl');
|
AppLogger.debug('Using proxy URL: $proxyUrl');
|
||||||
|
|
||||||
final response = await _networkClient.get<String>(
|
final response = await _networkClient.get<String>(
|
||||||
proxyUrl,
|
proxyUrl,
|
||||||
@@ -42,9 +43,13 @@ class NaverProxyClient {
|
|||||||
|
|
||||||
return response.data!;
|
return response.data!;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint('Proxy fetch error: ${e.message}');
|
AppLogger.error(
|
||||||
debugPrint('Status code: ${e.response?.statusCode}');
|
'Proxy fetch error: ${e.message}',
|
||||||
debugPrint('Response: ${e.response?.data}');
|
error: e,
|
||||||
|
stackTrace: e.stackTrace,
|
||||||
|
);
|
||||||
|
AppLogger.debug('Status code: ${e.response?.statusCode}');
|
||||||
|
AppLogger.debug('Response: ${e.response?.data}');
|
||||||
|
|
||||||
if (e.response?.statusCode == 403) {
|
if (e.response?.statusCode == 403) {
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
@@ -78,8 +83,12 @@ class NaverProxyClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return response.statusCode == 200;
|
return response.statusCode == 200;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('Proxy status check failed: $e');
|
AppLogger.error(
|
||||||
|
'Proxy status check failed: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../../../core/network/network_client.dart';
|
import '../../../core/network/network_client.dart';
|
||||||
import '../../../core/network/network_config.dart';
|
import '../../../core/network/network_config.dart';
|
||||||
@@ -36,10 +38,20 @@ class NaverUrlResolver {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location이 없는 경우, http.Client로 리다이렉트를 끝까지 따라가며 최종 URL 추출 (fallback)
|
||||||
|
final expanded = await _followRedirectsWithHttp(shortUrl);
|
||||||
|
if (expanded != null) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
// 리다이렉트가 없으면 원본 URL 반환
|
// 리다이렉트가 없으면 원본 URL 반환
|
||||||
return shortUrl;
|
return shortUrl;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint('resolveShortUrl error: $e');
|
AppLogger.error(
|
||||||
|
'resolveShortUrl error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: e.stackTrace,
|
||||||
|
);
|
||||||
|
|
||||||
// 리다이렉트 응답인 경우 Location 헤더 확인
|
// 리다이렉트 응답인 경우 Location 헤더 확인
|
||||||
if (e.response?.statusCode == 301 || e.response?.statusCode == 302) {
|
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 반환
|
// 오류 발생 시 원본 URL 반환
|
||||||
return shortUrl;
|
return shortUrl;
|
||||||
}
|
}
|
||||||
@@ -98,8 +116,12 @@ class NaverUrlResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return shortUrl;
|
return shortUrl;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('_resolveShortUrlViaProxy error: $e');
|
AppLogger.error(
|
||||||
|
'_resolveShortUrlViaProxy error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return shortUrl;
|
return shortUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,8 +161,12 @@ class NaverUrlResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return currentUrl;
|
return currentUrl;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('getFinalRedirectUrl error: $e');
|
AppLogger.error(
|
||||||
|
'getFinalRedirectUrl error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,4 +174,26 @@ class NaverUrlResolver {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
// 필요시 리소스 정리
|
// 필요시 리소스 정리
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// http.Client를 사용해 리다이렉트를 끝까지 따라가며 최종 URL을 반환한다.
|
||||||
|
/// 실패 시 null 반환.
|
||||||
|
Future<String?> _followRedirectsWithHttp(String shortUrl) async {
|
||||||
|
final client = http.Client();
|
||||||
|
try {
|
||||||
|
final request = http.Request('HEAD', Uri.parse(shortUrl))
|
||||||
|
..followRedirects = true
|
||||||
|
..maxRedirects = 5;
|
||||||
|
final response = await client.send(request);
|
||||||
|
return response.request?.url.toString();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
AppLogger.error(
|
||||||
|
'_followRedirectsWithHttp error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
import '../../core/network/network_client.dart';
|
import '../../core/network/network_client.dart';
|
||||||
import '../../core/errors/network_exceptions.dart';
|
import '../../core/errors/network_exceptions.dart';
|
||||||
@@ -33,9 +34,12 @@ class NaverApiClient {
|
|||||||
_proxyClient = NaverProxyClient(networkClient: _networkClient);
|
_proxyClient = NaverProxyClient(networkClient: _networkClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 네이버 로컬 검색 API 호출
|
/// 네이버 로컬 검색 API 호출 (현재 비활성화됨)
|
||||||
///
|
///
|
||||||
/// 검색어와 좌표를 기반으로 주변 식당을 검색합니다.
|
/// 개인정보 처리방침 및 운영 정책에 따라
|
||||||
|
/// 네이버 로컬 검색 Open API(키 기반 검색)는 사용하지 않는다.
|
||||||
|
/// 이 메서드는 네트워크 요청을 보내지 않고 항상 빈 리스트를 반환한다.
|
||||||
|
/// (향후 정책 변경 시, 기존 구현을 복원하여 사용할 수 있다.)
|
||||||
Future<List<NaverLocalSearchResult>> searchLocal({
|
Future<List<NaverLocalSearchResult>> searchLocal({
|
||||||
required String query,
|
required String query,
|
||||||
double? latitude,
|
double? latitude,
|
||||||
@@ -44,14 +48,10 @@ class NaverApiClient {
|
|||||||
int start = 1,
|
int start = 1,
|
||||||
String sort = 'random',
|
String sort = 'random',
|
||||||
}) async {
|
}) async {
|
||||||
return _localSearchApi.searchLocal(
|
AppLogger.debug(
|
||||||
query: query,
|
'[NaverApiClient] searchLocal 호출됨 - 로컬 검색 Open API는 현재 비활성화 상태입니다.',
|
||||||
latitude: latitude,
|
|
||||||
longitude: longitude,
|
|
||||||
display: display,
|
|
||||||
start: start,
|
|
||||||
sort: sort,
|
|
||||||
);
|
);
|
||||||
|
return <NaverLocalSearchResult>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 단축 URL을 실제 URL로 변환
|
/// 단축 URL을 실제 URL로 변환
|
||||||
@@ -88,7 +88,11 @@ class NaverApiClient {
|
|||||||
|
|
||||||
return response.data!;
|
return response.data!;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint('fetchMapPageHtml error: $e');
|
AppLogger.error(
|
||||||
|
'fetchMapPageHtml error: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: e.stackTrace,
|
||||||
|
);
|
||||||
|
|
||||||
if (e.error is NetworkException) {
|
if (e.error is NetworkException) {
|
||||||
throw e.error!;
|
throw e.error!;
|
||||||
@@ -123,9 +127,9 @@ class NaverApiClient {
|
|||||||
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
final pcmapUrl = 'https://pcmap.place.naver.com/restaurant/$placeId/home';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('========== 네이버 pcmap 한글 추출 시작 ==========');
|
AppLogger.debug('========== 네이버 pcmap 한글 추출 시작 ==========');
|
||||||
debugPrint('요청 URL: $pcmapUrl');
|
AppLogger.debug('요청 URL: $pcmapUrl');
|
||||||
debugPrint('Place ID: $placeId');
|
AppLogger.debug('Place ID: $placeId');
|
||||||
|
|
||||||
String html;
|
String html;
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
@@ -148,7 +152,7 @@ class NaverApiClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200 || response.data == null) {
|
if (response.statusCode != 200 || response.data == null) {
|
||||||
debugPrint(
|
AppLogger.error(
|
||||||
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
'NaverApiClient: pcmap 페이지 로드 실패 - status: ${response.statusCode}',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -172,11 +176,11 @@ class NaverApiClient {
|
|||||||
html,
|
html,
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('========== 추출 결과 ==========');
|
AppLogger.debug('========== 추출 결과 ==========');
|
||||||
debugPrint('총 한글 텍스트 수: ${koreanTexts.length}');
|
AppLogger.debug('총 한글 텍스트 수: ${koreanTexts.length}');
|
||||||
debugPrint('JSON-LD 상호명: $jsonLdName');
|
AppLogger.debug('JSON-LD 상호명: $jsonLdName');
|
||||||
debugPrint('Apollo State 상호명: $apolloName');
|
AppLogger.debug('Apollo State 상호명: $apolloName');
|
||||||
debugPrint('=====================================');
|
AppLogger.debug('=====================================');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
@@ -187,8 +191,12 @@ class NaverApiClient {
|
|||||||
'apolloStateName': apolloName,
|
'apolloStateName': apolloName,
|
||||||
'extractedAt': DateTime.now().toIso8601String(),
|
'extractedAt': DateTime.now().toIso8601String(),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverApiClient: pcmap 페이지 파싱 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverApiClient: pcmap 페이지 파싱 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'error': e.toString(),
|
'error': e.toString(),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
|
/// 네이버 HTML에서 데이터를 추출하는 유틸리티 클래스
|
||||||
class NaverHtmlExtractor {
|
class NaverHtmlExtractor {
|
||||||
@@ -323,11 +323,11 @@ class NaverHtmlExtractor {
|
|||||||
// 리스트로 변환하여 반환
|
// 리스트로 변환하여 반환
|
||||||
final resultList = uniqueTexts.toList();
|
final resultList = uniqueTexts.toList();
|
||||||
|
|
||||||
debugPrint('========== 유효한 한글 텍스트 추출 결과 ==========');
|
AppLogger.debug('========== 유효한 한글 텍스트 추출 결과 ==========');
|
||||||
for (int i = 0; i < resultList.length; i++) {
|
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;
|
return resultList;
|
||||||
}
|
}
|
||||||
@@ -377,8 +377,12 @@ class NaverHtmlExtractor {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlExtractor: JSON-LD 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlExtractor: JSON-LD 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -418,14 +422,21 @@ class NaverHtmlExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
// JSON 파싱 실패
|
AppLogger.error(
|
||||||
debugPrint('NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e');
|
'NaverHtmlExtractor: Apollo State JSON 파싱 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlExtractor: Apollo State 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlExtractor: Apollo State 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -442,7 +453,7 @@ class NaverHtmlExtractor {
|
|||||||
final match = ogUrlRegex.firstMatch(html);
|
final match = ogUrlRegex.firstMatch(html);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
final url = match.group(1);
|
final url = match.group(1);
|
||||||
debugPrint('NaverHtmlExtractor: og:url 추출 - $url');
|
AppLogger.debug('NaverHtmlExtractor: og:url 추출 - $url');
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,11 +465,15 @@ class NaverHtmlExtractor {
|
|||||||
final canonicalMatch = canonicalRegex.firstMatch(html);
|
final canonicalMatch = canonicalRegex.firstMatch(html);
|
||||||
if (canonicalMatch != null) {
|
if (canonicalMatch != null) {
|
||||||
final url = canonicalMatch.group(1);
|
final url = canonicalMatch.group(1);
|
||||||
debugPrint('NaverHtmlExtractor: canonical URL 추출 - $url');
|
AppLogger.debug('NaverHtmlExtractor: canonical URL 추출 - $url');
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlExtractor: Place Link 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlExtractor: Place Link 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
/// 네이버 지도 HTML 파서
|
/// 네이버 지도 HTML 파서
|
||||||
///
|
///
|
||||||
@@ -77,8 +77,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 이름 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 이름 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,8 +101,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 카테고리 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 카테고리 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,8 +123,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 서브 카테고리 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 서브 카테고리 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +149,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 설명 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 설명 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,8 +175,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 전화번호 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 전화번호 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,8 +199,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 도로명 주소 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 도로명 주소 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,8 +225,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 지번 주소 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 지번 주소 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,8 +266,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 위도 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 위도 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,8 +307,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 경도 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 경도 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,8 +333,12 @@ class NaverHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('NaverHtmlParser: 영업시간 추출 실패 - $e');
|
AppLogger.error(
|
||||||
|
'NaverHtmlParser: 영업시간 추출 실패 - $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
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 'package:flutter/foundation.dart';
|
||||||
import '../../api/naver_api_client.dart';
|
import 'package:html/parser.dart' as html_parser;
|
||||||
import '../../api/naver/naver_local_search_api.dart';
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
import '../../../core/errors/network_exceptions.dart';
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||||
import 'naver_html_parser.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../api/naver/naver_graphql_queries.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 '../../../core/utils/category_mapper.dart';
|
||||||
|
import 'naver_html_parser.dart';
|
||||||
|
|
||||||
/// 네이버 지도 URL 파서
|
/// 네이버 지도 URL 파서
|
||||||
/// 네이버 지도 URL에서 식당 정보를 추출합니다.
|
/// 네이버 지도 URL에서 식당 정보를 추출합니다.
|
||||||
@@ -21,8 +23,9 @@ class NaverMapParser {
|
|||||||
|
|
||||||
// 정규식 패턴
|
// 정규식 패턴
|
||||||
static final RegExp _placeIdRegex = RegExp(
|
static final RegExp _placeIdRegex = RegExp(
|
||||||
r'/p/(?:restaurant|entry/place)/(\d+)',
|
r'(?:/p/(?:restaurant|entry/place)/|/place/)(\d+)',
|
||||||
);
|
);
|
||||||
|
static final RegExp _pinIdRegex = RegExp(r'pinId["=](\d+)');
|
||||||
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
static final RegExp _shortUrlRegex = RegExp(r'naver\.me/([a-zA-Z0-9]+)$');
|
||||||
|
|
||||||
// 기본 좌표 (서울 시청)
|
// 기본 좌표 (서울 시청)
|
||||||
@@ -60,9 +63,7 @@ class NaverMapParser {
|
|||||||
throw NaverMapParseException('이미 dispose된 파서입니다');
|
throw NaverMapParseException('이미 dispose된 파서입니다');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('[naver_url] 원본 URL 수신: $url');
|
||||||
debugPrint('NaverMapParser: Starting to parse URL: $url');
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL 유효성 검증
|
// URL 유효성 검증
|
||||||
if (!_isValidNaverUrl(url)) {
|
if (!_isValidNaverUrl(url)) {
|
||||||
@@ -72,9 +73,7 @@ class NaverMapParser {
|
|||||||
// 짧은 URL인 경우 리다이렉트 처리
|
// 짧은 URL인 경우 리다이렉트 처리
|
||||||
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
final String finalUrl = await _apiClient.resolveShortUrl(url);
|
||||||
|
|
||||||
if (kDebugMode) {
|
AppLogger.debug('[naver_url] resolveShortUrl 결과: $finalUrl');
|
||||||
debugPrint('NaverMapParser: Final URL after redirect: $finalUrl');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Place ID 추출 (10자리 숫자)
|
// Place ID 추출 (10자리 숫자)
|
||||||
final String? placeId = _extractPlaceId(finalUrl);
|
final String? placeId = _extractPlaceId(finalUrl);
|
||||||
@@ -82,23 +81,18 @@ class NaverMapParser {
|
|||||||
// 짧은 URL에서 직접 ID 추출 시도
|
// 짧은 URL에서 직접 ID 추출 시도
|
||||||
final shortUrlId = _extractShortUrlId(url);
|
final shortUrlId = _extractShortUrlId(url);
|
||||||
if (shortUrlId != null) {
|
if (shortUrlId != null) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('[naver_url] 단축 URL ID를 Place ID로 사용: $shortUrlId');
|
||||||
debugPrint(
|
|
||||||
'NaverMapParser: Using short URL ID as place ID: $shortUrlId',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _createFallbackRestaurant(shortUrlId, url);
|
return _createFallbackRestaurant(shortUrlId, url);
|
||||||
}
|
}
|
||||||
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
throw NaverMapParseException('URL에서 Place ID를 추출할 수 없습니다: $url');
|
||||||
}
|
}
|
||||||
|
AppLogger.debug('[naver_url] Place ID 추출 성공: $placeId');
|
||||||
|
|
||||||
// 단축 URL인 경우 특별 처리
|
// 단축 URL인 경우 특별 처리
|
||||||
final isShortUrl = url.contains('naver.me');
|
final isShortUrl = url.contains('naver.me');
|
||||||
|
|
||||||
if (isShortUrl) {
|
if (isShortUrl) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
||||||
debugPrint('NaverMapParser: 단축 URL 감지, 향상된 파싱 시작');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
// 한글 텍스트 추출 및 로컬 검색 API를 통한 정확한 정보 획득
|
||||||
@@ -108,14 +102,17 @@ class NaverMapParser {
|
|||||||
userLatitude,
|
userLatitude,
|
||||||
userLongitude,
|
userLongitude,
|
||||||
);
|
);
|
||||||
if (kDebugMode) {
|
AppLogger.debug(
|
||||||
debugPrint('NaverMapParser: 단축 URL 파싱 성공 - ${restaurant.name}');
|
'[naver_url] LocalSearch 파싱 성공: '
|
||||||
}
|
'name=${restaurant.name}, road=${restaurant.roadAddress}',
|
||||||
|
);
|
||||||
return restaurant;
|
return restaurant;
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (kDebugMode) {
|
AppLogger.error(
|
||||||
debugPrint('NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e');
|
'NaverMapParser: 단축 URL 특별 처리 실패, 기본 파싱으로 전환 - $e',
|
||||||
}
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
// 실패 시 기본 파싱으로 계속 진행
|
// 실패 시 기본 파싱으로 계속 진행
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +123,12 @@ class NaverMapParser {
|
|||||||
userLatitude: userLatitude,
|
userLatitude: userLatitude,
|
||||||
userLongitude: userLongitude,
|
userLongitude: userLongitude,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] GraphQL/검색 파싱 결과 요약: '
|
||||||
|
'name=${restaurantData['name']}, '
|
||||||
|
'road=${restaurantData['roadAddress']}, '
|
||||||
|
'phone=${restaurantData['phone']}',
|
||||||
|
);
|
||||||
return _createRestaurant(restaurantData, placeId, finalUrl);
|
return _createRestaurant(restaurantData, placeId, finalUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is NaverMapParseException) {
|
if (e is NaverMapParseException) {
|
||||||
@@ -156,7 +159,11 @@ class NaverMapParser {
|
|||||||
/// URL에서 Place ID 추출
|
/// URL에서 Place ID 추출
|
||||||
String? _extractPlaceId(String url) {
|
String? _extractPlaceId(String url) {
|
||||||
final match = _placeIdRegex.firstMatch(url);
|
final match = _placeIdRegex.firstMatch(url);
|
||||||
return match?.group(1);
|
if (match != null) return match.group(1);
|
||||||
|
|
||||||
|
// 핀 공유 형식: pinId="1234567890" 또는 pinId=1234567890
|
||||||
|
final pinMatch = _pinIdRegex.firstMatch(url);
|
||||||
|
return pinMatch?.group(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 짧은 URL에서 ID 추출
|
/// 짧은 URL에서 ID 추출
|
||||||
@@ -177,9 +184,7 @@ class NaverMapParser {
|
|||||||
}) async {
|
}) async {
|
||||||
// 심플한 접근: URL로 직접 검색
|
// 심플한 접근: URL로 직접 검색
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('NaverMapParser: URL 기반 검색 시작');
|
||||||
debugPrint('NaverMapParser: URL 기반 검색 시작');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 네이버 지도 URL 구성
|
// 네이버 지도 URL 구성
|
||||||
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
final placeUrl = '$_naverMapBaseUrl/p/entry/place/$placeId';
|
||||||
@@ -196,32 +201,34 @@ class NaverMapParser {
|
|||||||
longitude: userLongitude,
|
longitude: userLongitude,
|
||||||
display: _searchDisplayCount,
|
display: _searchDisplayCount,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] URL 기반 검색 응답 개수: ${searchResults.length}, '
|
||||||
|
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
||||||
|
);
|
||||||
|
|
||||||
if (searchResults.isNotEmpty) {
|
if (searchResults.isNotEmpty) {
|
||||||
// place ID가 포함된 결과 찾기
|
// place ID가 포함된 결과 찾기
|
||||||
for (final result in searchResults) {
|
for (final result in searchResults) {
|
||||||
if (result.link.contains(placeId)) {
|
if (result.link.contains(placeId)) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug(
|
||||||
debugPrint(
|
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
||||||
'NaverMapParser: URL 검색으로 정확한 매칭 찾음 - ${result.title}',
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return _convertSearchResultToData(result);
|
return _convertSearchResultToData(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
// 정확한 매칭이 없으면 첫 번째 결과 사용
|
||||||
if (kDebugMode) {
|
AppLogger.debug(
|
||||||
debugPrint(
|
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
||||||
'NaverMapParser: URL 검색 첫 번째 결과 사용 - ${searchResults.first.title}',
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return _convertSearchResultToData(searchResults.first);
|
return _convertSearchResultToData(searchResults.first);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (kDebugMode) {
|
AppLogger.error(
|
||||||
debugPrint('NaverMapParser: URL 검색 실패 - $e');
|
'NaverMapParser: URL 검색 실패 - $e',
|
||||||
}
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Place ID로 검색
|
// Step 2: Place ID로 검색
|
||||||
@@ -236,19 +243,23 @@ class NaverMapParser {
|
|||||||
longitude: userLongitude,
|
longitude: userLongitude,
|
||||||
display: _searchDisplayCount,
|
display: _searchDisplayCount,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] Place ID 검색 응답 개수: ${searchResults.length}, '
|
||||||
|
'첫 번째: ${searchResults.isNotEmpty ? searchResults.first.title : '없음'}',
|
||||||
|
);
|
||||||
|
|
||||||
if (searchResults.isNotEmpty) {
|
if (searchResults.isNotEmpty) {
|
||||||
if (kDebugMode) {
|
AppLogger.debug(
|
||||||
debugPrint(
|
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
||||||
'NaverMapParser: Place ID 검색 결과 사용 - ${searchResults.first.title}',
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return _convertSearchResultToData(searchResults.first);
|
return _convertSearchResultToData(searchResults.first);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (kDebugMode) {
|
AppLogger.error(
|
||||||
debugPrint('NaverMapParser: Place ID 검색 실패 - $e');
|
'NaverMapParser: Place ID 검색 실패 - $e',
|
||||||
}
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
|
||||||
// 429 에러인 경우 즉시 예외 발생
|
// 429 에러인 경우 즉시 예외 발생
|
||||||
if (e is DioException && e.response?.statusCode == 429) {
|
if (e is DioException && e.response?.statusCode == 429) {
|
||||||
@@ -258,10 +269,12 @@ class NaverMapParser {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (kDebugMode) {
|
AppLogger.error(
|
||||||
debugPrint('NaverMapParser: URL 기반 검색 실패 - $e');
|
'NaverMapParser: URL 기반 검색 실패 - $e',
|
||||||
}
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
|
||||||
// 429 에러인 경우 즉시 예외 발생
|
// 429 에러인 경우 즉시 예외 발생
|
||||||
if (e is DioException && e.response?.statusCode == 429) {
|
if (e is DioException && e.response?.statusCode == 429) {
|
||||||
@@ -275,14 +288,15 @@ class NaverMapParser {
|
|||||||
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
// 기존 GraphQL 방식으로 fallback (실패할 가능성 높지만 시도)
|
||||||
// 첫 번째 시도: places 쿼리
|
// 첫 번째 시도: places 쿼리
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('NaverMapParser: Trying places query...');
|
||||||
debugPrint('NaverMapParser: Trying places query...');
|
|
||||||
}
|
|
||||||
final response = await _apiClient.fetchGraphQL(
|
final response = await _apiClient.fetchGraphQL(
|
||||||
operationName: 'getPlaceDetail',
|
operationName: 'getPlaceDetail',
|
||||||
variables: {'id': placeId},
|
variables: {'id': placeId},
|
||||||
query: NaverGraphQLQueries.placeDetailQuery,
|
query: NaverGraphQLQueries.placeDetailQuery,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] places query 응답 keys: ${response.keys.toList()}',
|
||||||
|
);
|
||||||
|
|
||||||
// places 응답 처리 (배열일 수도 있음)
|
// places 응답 처리 (배열일 수도 있음)
|
||||||
final placesData = response['data']?['places'];
|
final placesData = response['data']?['places'];
|
||||||
@@ -293,22 +307,25 @@ class NaverMapParser {
|
|||||||
return _extractPlaceData(placesData as Map<String, dynamic>);
|
return _extractPlaceData(placesData as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (kDebugMode) {
|
AppLogger.error(
|
||||||
debugPrint('NaverMapParser: places query failed - $e');
|
'NaverMapParser: places query failed - $e',
|
||||||
}
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 두 번째 시도: nxPlaces 쿼리
|
// 두 번째 시도: nxPlaces 쿼리
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
AppLogger.debug('NaverMapParser: Trying nxPlaces query...');
|
||||||
debugPrint('NaverMapParser: Trying nxPlaces query...');
|
|
||||||
}
|
|
||||||
final response = await _apiClient.fetchGraphQL(
|
final response = await _apiClient.fetchGraphQL(
|
||||||
operationName: 'getPlaceDetail',
|
operationName: 'getPlaceDetail',
|
||||||
variables: {'id': placeId},
|
variables: {'id': placeId},
|
||||||
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
query: NaverGraphQLQueries.nxPlaceDetailQuery,
|
||||||
);
|
);
|
||||||
|
AppLogger.debug(
|
||||||
|
'[naver_url] nxPlaces query 응답 keys: ${response.keys.toList()}',
|
||||||
|
);
|
||||||
|
|
||||||
// nxPlaces 응답 처리 (배열일 수도 있음)
|
// nxPlaces 응답 처리 (배열일 수도 있음)
|
||||||
final nxPlacesData = response['data']?['nxPlaces'];
|
final nxPlacesData = response['data']?['nxPlaces'];
|
||||||
@@ -319,18 +336,18 @@ class NaverMapParser {
|
|||||||
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
|
return _extractPlaceData(nxPlacesData as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (kDebugMode) {
|
AppLogger.error(
|
||||||
debugPrint('NaverMapParser: nxPlaces query failed - $e');
|
'NaverMapParser: nxPlaces query failed - $e',
|
||||||
}
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
// 모든 GraphQL 시도 실패 시 HTML 파싱으로 fallback
|
||||||
if (kDebugMode) {
|
AppLogger.debug(
|
||||||
debugPrint(
|
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
||||||
'NaverMapParser: All GraphQL queries failed, falling back to HTML parsing',
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return await _fallbackToHtmlParsing(placeId);
|
return await _fallbackToHtmlParsing(placeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +525,7 @@ class NaverMapParser {
|
|||||||
double? userLongitude,
|
double? userLongitude,
|
||||||
) async {
|
) async {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
AppLogger.debug('NaverMapParser: 단축 URL 향상된 파싱 시작');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 한글 텍스트 추출
|
// 1. 한글 텍스트 추출
|
||||||
@@ -525,17 +542,17 @@ class NaverMapParser {
|
|||||||
if (koreanData['jsonLdName'] != null) {
|
if (koreanData['jsonLdName'] != null) {
|
||||||
searchQuery = koreanData['jsonLdName'] as String;
|
searchQuery = koreanData['jsonLdName'] as String;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
AppLogger.debug('NaverMapParser: JSON-LD 상호명 사용 - $searchQuery');
|
||||||
}
|
}
|
||||||
} else if (koreanData['apolloStateName'] != null) {
|
} else if (koreanData['apolloStateName'] != null) {
|
||||||
searchQuery = koreanData['apolloStateName'] as String;
|
searchQuery = koreanData['apolloStateName'] as String;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
AppLogger.debug('NaverMapParser: Apollo State 상호명 사용 - $searchQuery');
|
||||||
}
|
}
|
||||||
} else if (koreanTexts.isNotEmpty) {
|
} else if (koreanTexts.isNotEmpty) {
|
||||||
searchQuery = koreanTexts.first as String;
|
searchQuery = koreanTexts.first as String;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
AppLogger.debug('NaverMapParser: 첫 번째 한글 텍스트 사용 - $searchQuery');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
throw NaverMapParseException('유효한 한글 텍스트를 찾을 수 없습니다');
|
||||||
@@ -543,7 +560,7 @@ class NaverMapParser {
|
|||||||
|
|
||||||
// 2. 로컬 검색 API 호출
|
// 2. 로컬 검색 API 호출
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
AppLogger.debug('NaverMapParser: 로컬 검색 API 호출 - "$searchQuery"');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Future.delayed(
|
await Future.delayed(
|
||||||
@@ -563,15 +580,15 @@ class NaverMapParser {
|
|||||||
|
|
||||||
// 디버깅: 검색 결과 Place ID 분석
|
// 디버깅: 검색 결과 Place ID 분석
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('=== 로컬 검색 결과 Place ID 분석 ===');
|
AppLogger.debug('=== 로컬 검색 결과 Place ID 분석 ===');
|
||||||
for (int i = 0; i < searchResults.length; i++) {
|
for (int i = 0; i < searchResults.length; i++) {
|
||||||
final result = searchResults[i];
|
final result = searchResults[i];
|
||||||
final extractedId = result.extractPlaceId();
|
final extractedId = result.extractPlaceId();
|
||||||
debugPrint('[$i] ${result.title}');
|
AppLogger.debug('[$i] ${result.title}');
|
||||||
debugPrint(' 링크: ${result.link}');
|
AppLogger.debug(' 링크: ${result.link}');
|
||||||
debugPrint(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
AppLogger.debug(' 추출된 Place ID: $extractedId (타겟: $placeId)');
|
||||||
}
|
}
|
||||||
debugPrint('=====================================');
|
AppLogger.debug('=====================================');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
// 3. 최적의 결과 선택 - 3단계 매칭 알고리즘
|
||||||
@@ -583,7 +600,7 @@ class NaverMapParser {
|
|||||||
if (extractedId == placeId) {
|
if (extractedId == placeId) {
|
||||||
bestMatch = result;
|
bestMatch = result;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
AppLogger.debug('✅ 1차 매칭 성공: Place ID 일치 - ${result.title}');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -604,7 +621,7 @@ class NaverMapParser {
|
|||||||
exactName.contains(result.title)) {
|
exactName.contains(result.title)) {
|
||||||
bestMatch = result;
|
bestMatch = result;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
AppLogger.debug('✅ 2차 매칭 성공: 상호명 유사 - ${result.title}');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -620,7 +637,7 @@ class NaverMapParser {
|
|||||||
userLongitude,
|
userLongitude,
|
||||||
);
|
);
|
||||||
if (bestMatch != null && kDebugMode) {
|
if (bestMatch != null && kDebugMode) {
|
||||||
debugPrint('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
AppLogger.debug('✅ 3차 매칭: 거리 기반 - ${bestMatch.title}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +645,7 @@ class NaverMapParser {
|
|||||||
if (bestMatch == null) {
|
if (bestMatch == null) {
|
||||||
bestMatch = searchResults.first;
|
bestMatch = searchResults.first;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
AppLogger.debug('✅ 최종 매칭: 첫 번째 결과 사용 - ${bestMatch.title}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +687,7 @@ class NaverMapParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kDebugMode && nearest != null) {
|
if (kDebugMode && nearest != null) {
|
||||||
debugPrint(
|
AppLogger.debug(
|
||||||
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
'가장 가까운 결과: ${nearest.title} (거리: ${minDistance.toStringAsFixed(2)}km)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../api/naver_api_client.dart';
|
import '../../api/naver_api_client.dart';
|
||||||
import '../../api/naver/naver_local_search_api.dart';
|
import '../../api/naver/naver_local_search_api.dart';
|
||||||
import '../../../domain/entities/restaurant.dart';
|
|
||||||
import '../../../core/errors/network_exceptions.dart';
|
import '../../../core/errors/network_exceptions.dart';
|
||||||
|
import '../../../domain/entities/restaurant.dart';
|
||||||
import 'naver_map_parser.dart';
|
import 'naver_map_parser.dart';
|
||||||
|
import 'naver_url_processor.dart';
|
||||||
|
|
||||||
/// 네이버 검색 서비스
|
/// 네이버 검색 서비스
|
||||||
///
|
///
|
||||||
@@ -12,14 +15,21 @@ import 'naver_map_parser.dart';
|
|||||||
class NaverSearchService {
|
class NaverSearchService {
|
||||||
final NaverApiClient _apiClient;
|
final NaverApiClient _apiClient;
|
||||||
final NaverMapParser _mapParser;
|
final NaverMapParser _mapParser;
|
||||||
|
final NaverUrlProcessor _urlProcessor;
|
||||||
final Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
// 성능 최적화를 위한 정규식 캐싱
|
// 성능 최적화를 위한 정규식 캐싱
|
||||||
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
|
static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]');
|
||||||
|
|
||||||
NaverSearchService({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
NaverSearchService({
|
||||||
: _apiClient = apiClient ?? NaverApiClient(),
|
NaverApiClient? apiClient,
|
||||||
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
NaverMapParser? mapParser,
|
||||||
|
NaverUrlProcessor? urlProcessor,
|
||||||
|
}) : _apiClient = apiClient ?? NaverApiClient(),
|
||||||
|
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient),
|
||||||
|
_urlProcessor =
|
||||||
|
urlProcessor ??
|
||||||
|
NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser);
|
||||||
|
|
||||||
/// URL에서 식당 정보 가져오기
|
/// URL에서 식당 정보 가져오기
|
||||||
///
|
///
|
||||||
@@ -32,7 +42,7 @@ class NaverSearchService {
|
|||||||
/// - [NetworkException] 네트워크 오류 발생 시
|
/// - [NetworkException] 네트워크 오류 발생 시
|
||||||
Future<Restaurant> getRestaurantFromUrl(String url) async {
|
Future<Restaurant> getRestaurantFromUrl(String url) async {
|
||||||
try {
|
try {
|
||||||
return await _mapParser.parseRestaurantFromUrl(url);
|
return await _urlProcessor.processUrl(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is NaverMapParseException || e is NetworkException) {
|
if (e is NaverMapParseException || e is NetworkException) {
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -149,9 +159,9 @@ class NaverSearchService {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 상세 파싱 실패해도 기본 정보 반환
|
// 상세 파싱 실패해도 기본 정보 반환
|
||||||
if (kDebugMode) {
|
AppLogger.debug(
|
||||||
debugPrint('[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}');
|
'[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}',
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
lib/data/datasources/remote/naver_url_processor.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||||
|
|
||||||
|
import '../../api/naver_api_client.dart';
|
||||||
|
import 'naver_map_parser.dart';
|
||||||
|
|
||||||
|
/// 네이버 지도 URL을 처리하고 결과를 캐시하는 경량 프로세서.
|
||||||
|
/// - 단축 URL 해석 → 지도 파서 실행
|
||||||
|
/// - 동일 URL 재요청 시 메모리 캐시 반환
|
||||||
|
class NaverUrlProcessor {
|
||||||
|
final NaverApiClient _apiClient;
|
||||||
|
final NaverMapParser _mapParser;
|
||||||
|
final _cache = HashMap<String, Restaurant>();
|
||||||
|
|
||||||
|
NaverUrlProcessor({NaverApiClient? apiClient, NaverMapParser? mapParser})
|
||||||
|
: _apiClient = apiClient ?? NaverApiClient(),
|
||||||
|
_mapParser = mapParser ?? NaverMapParser(apiClient: apiClient);
|
||||||
|
|
||||||
|
Future<Restaurant> processUrl(
|
||||||
|
String url, {
|
||||||
|
double? userLatitude,
|
||||||
|
double? userLongitude,
|
||||||
|
}) async {
|
||||||
|
final normalizedUrl = url.trim();
|
||||||
|
if (_cache.containsKey(normalizedUrl)) {
|
||||||
|
return _cache[normalizedUrl]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolved = await _apiClient.resolveShortUrl(normalizedUrl);
|
||||||
|
final restaurant = await _mapParser.parseRestaurantFromUrl(
|
||||||
|
resolved,
|
||||||
|
userLatitude: userLatitude,
|
||||||
|
userLongitude: userLongitude,
|
||||||
|
);
|
||||||
|
_cache[normalizedUrl] = restaurant;
|
||||||
|
_cache[resolved] = restaurant;
|
||||||
|
return restaurant;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearCache() => _cache.clear();
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:lunchpick/core/utils/distance_calculator.dart';
|
|||||||
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
import 'package:lunchpick/data/datasources/remote/naver_search_service.dart';
|
||||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
|
||||||
class RestaurantRepositoryImpl implements RestaurantRepository {
|
class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||||
static const String _boxName = 'restaurants';
|
static const String _boxName = 'restaurants';
|
||||||
@@ -63,8 +64,17 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<List<Restaurant>> watchRestaurants() async* {
|
Stream<List<Restaurant>> watchRestaurants() async* {
|
||||||
final box = await _box;
|
final box = await _box;
|
||||||
yield box.values.toList();
|
final initial = box.values.toList();
|
||||||
yield* box.watch().map((_) => 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
|
@override
|
||||||
@@ -94,6 +104,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
|||||||
businessHours: restaurant.businessHours,
|
businessHours: restaurant.businessHours,
|
||||||
lastVisited: visitDate,
|
lastVisited: visitDate,
|
||||||
visitCount: restaurant.visitCount + 1,
|
visitCount: restaurant.visitCount + 1,
|
||||||
|
needsAddressVerification: restaurant.needsAddressVerification,
|
||||||
);
|
);
|
||||||
await updateRestaurant(updatedRestaurant);
|
await updateRestaurant(updatedRestaurant);
|
||||||
}
|
}
|
||||||
@@ -224,7 +235,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
AppLogger.debug('API 검색 실패, 스크래핑된 정보만 사용: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
static const String _keyNotificationDelayMinutes =
|
static const String _keyNotificationDelayMinutes =
|
||||||
'notification_delay_minutes';
|
'notification_delay_minutes';
|
||||||
static const String _keyNotificationEnabled = 'notification_enabled';
|
static const String _keyNotificationEnabled = 'notification_enabled';
|
||||||
|
static const String _keyScreenshotModeEnabled = 'screenshot_mode_enabled';
|
||||||
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
||||||
static const String _keyFirstRun = 'first_run';
|
static const String _keyFirstRun = 'first_run';
|
||||||
static const String _keyCategoryWeights = 'category_weights';
|
static const String _keyCategoryWeights = 'category_weights';
|
||||||
|
|
||||||
// Default values
|
// Default values
|
||||||
static const int _defaultDaysToExclude = 7;
|
static const int _defaultDaysToExclude = 14;
|
||||||
static const int _defaultMaxDistanceRainy = 500;
|
static const int _defaultMaxDistanceRainy = 500;
|
||||||
static const int _defaultMaxDistanceNormal = 1000;
|
static const int _defaultMaxDistanceNormal = 1000;
|
||||||
static const int _defaultNotificationDelayMinutes = 90;
|
static const int _defaultNotificationDelayMinutes = 90;
|
||||||
static const bool _defaultNotificationEnabled = true;
|
static const bool _defaultNotificationEnabled = true;
|
||||||
|
static const bool _defaultScreenshotModeEnabled = false;
|
||||||
static const bool _defaultDarkModeEnabled = false;
|
static const bool _defaultDarkModeEnabled = false;
|
||||||
static const bool _defaultFirstRun = true;
|
static const bool _defaultFirstRun = true;
|
||||||
|
|
||||||
@@ -155,6 +157,21 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
await box.put(_keyNotificationEnabled, enabled);
|
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
|
@override
|
||||||
Future<bool> isDarkModeEnabled() async {
|
Future<bool> isDarkModeEnabled() async {
|
||||||
final box = await _box;
|
final box = await _box;
|
||||||
@@ -193,6 +210,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
_defaultNotificationDelayMinutes,
|
_defaultNotificationDelayMinutes,
|
||||||
);
|
);
|
||||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||||
|
await box.put(_keyScreenshotModeEnabled, _defaultScreenshotModeEnabled);
|
||||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||||
}
|
}
|
||||||
@@ -215,6 +233,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
||||||
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
||||||
_keyNotificationEnabled: await isNotificationEnabled(),
|
_keyNotificationEnabled: await isNotificationEnabled(),
|
||||||
|
_keyScreenshotModeEnabled: await isScreenshotModeEnabled(),
|
||||||
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
||||||
_keyFirstRun: await isFirstRun(),
|
_keyFirstRun: await isFirstRun(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
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/entities/weather_info.dart';
|
||||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||||
|
|
||||||
@@ -15,18 +21,32 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
required double latitude,
|
required double latitude,
|
||||||
required double longitude,
|
required double longitude,
|
||||||
}) async {
|
}) async {
|
||||||
// TODO: 실제 날씨 API 호출 구현
|
final cached = await getCachedWeather();
|
||||||
// 여기서는 임시로 더미 데이터 반환
|
|
||||||
|
|
||||||
final dummyWeather = WeatherInfo(
|
try {
|
||||||
current: WeatherData(temperature: 20, isRainy: false, description: '맑음'),
|
final weather = await _fetchWeatherFromKma(
|
||||||
nextHour: WeatherData(temperature: 22, isRainy: false, description: '맑음'),
|
latitude: latitude,
|
||||||
);
|
longitude: longitude,
|
||||||
|
);
|
||||||
// 캐시에 저장
|
await cacheWeatherInfo(weather);
|
||||||
await cacheWeatherInfo(dummyWeather);
|
return weather;
|
||||||
|
} catch (_) {
|
||||||
return dummyWeather;
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return WeatherInfo(
|
||||||
|
current: WeatherData(
|
||||||
|
temperature: 20,
|
||||||
|
isRainy: false,
|
||||||
|
description: '날씨 정보를 불러오지 못했어요',
|
||||||
|
),
|
||||||
|
nextHour: WeatherData(
|
||||||
|
temperature: 20,
|
||||||
|
isRainy: false,
|
||||||
|
description: '날씨 정보를 불러오지 못했어요',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,7 +68,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
try {
|
try {
|
||||||
// 안전한 타입 변환
|
// 안전한 타입 변환
|
||||||
if (cachedData is! Map) {
|
if (cachedData is! Map) {
|
||||||
print(
|
AppLogger.debug(
|
||||||
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
|
'WeatherCache: Invalid data type - expected Map but got ${cachedData.runtimeType}',
|
||||||
);
|
);
|
||||||
await clearWeatherCache();
|
await clearWeatherCache();
|
||||||
@@ -62,7 +82,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
// Map 구조 검증
|
// Map 구조 검증
|
||||||
if (!weatherMap.containsKey('current') ||
|
if (!weatherMap.containsKey('current') ||
|
||||||
!weatherMap.containsKey('nextHour')) {
|
!weatherMap.containsKey('nextHour')) {
|
||||||
print('WeatherCache: Missing required fields in weather data');
|
AppLogger.debug(
|
||||||
|
'WeatherCache: Missing required fields in weather data',
|
||||||
|
);
|
||||||
await clearWeatherCache();
|
await clearWeatherCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -70,7 +92,10 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
return _weatherInfoFromMap(weatherMap);
|
return _weatherInfoFromMap(weatherMap);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 캐시 데이터가 손상된 경우
|
// 캐시 데이터가 손상된 경우
|
||||||
print('WeatherCache: Error parsing cached weather data: $e');
|
AppLogger.error(
|
||||||
|
'WeatherCache: Error parsing cached weather data',
|
||||||
|
error: e,
|
||||||
|
);
|
||||||
await clearWeatherCache();
|
await clearWeatherCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -118,7 +143,9 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
// 날짜 파싱 시도
|
// 날짜 파싱 시도
|
||||||
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
final lastUpdateTime = DateTime.tryParse(lastUpdateTimeStr);
|
||||||
if (lastUpdateTime == null) {
|
if (lastUpdateTime == null) {
|
||||||
print('WeatherCache: Invalid date format in cache: $lastUpdateTimeStr');
|
AppLogger.debug(
|
||||||
|
'WeatherCache: Invalid date format in cache: $lastUpdateTimeStr',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +154,7 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
|
|
||||||
return difference < _cacheValidDuration;
|
return difference < _cacheValidDuration;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('WeatherCache: Error checking cache validity: $e');
|
AppLogger.error('WeatherCache: Error checking cache validity', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,13 +177,19 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
|
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
|
||||||
try {
|
try {
|
||||||
// current 필드 검증
|
// 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) {
|
if (currentMap == null) {
|
||||||
throw FormatException('Missing current weather data');
|
throw FormatException('Missing current weather data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// nextHour 필드 검증
|
// 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) {
|
if (nextHourMap == null) {
|
||||||
throw FormatException('Missing nextHour weather data');
|
throw FormatException('Missing nextHour weather data');
|
||||||
}
|
}
|
||||||
@@ -183,9 +216,284 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('WeatherCache: Error converting map to WeatherInfo: $e');
|
AppLogger.error(
|
||||||
print('WeatherCache: Map data: $map');
|
'WeatherCache: Error converting map to WeatherInfo',
|
||||||
|
error: e,
|
||||||
|
stackTrace: StackTrace.current,
|
||||||
|
);
|
||||||
|
AppLogger.debug('WeatherCache: Map data: $map');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<WeatherInfo> _fetchWeatherFromKma({
|
||||||
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
}) async {
|
||||||
|
final serviceKey = _encodeServiceKey(ApiKeys.weatherServiceKey);
|
||||||
|
if (serviceKey.isEmpty) {
|
||||||
|
throw Exception('기상청 서비스 키가 설정되지 않았습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final gridPoint = _latLonToGrid(latitude, longitude);
|
||||||
|
final baseDateTime = _resolveBaseDateTime();
|
||||||
|
|
||||||
|
final ncstUri = Uri.https(
|
||||||
|
'apis.data.go.kr',
|
||||||
|
'/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst',
|
||||||
|
{
|
||||||
|
'serviceKey': serviceKey,
|
||||||
|
'numOfRows': '100',
|
||||||
|
'pageNo': '1',
|
||||||
|
'dataType': 'JSON',
|
||||||
|
'base_date': baseDateTime.date,
|
||||||
|
'base_time': baseDateTime.ncstTime,
|
||||||
|
'nx': gridPoint.x.toString(),
|
||||||
|
'ny': gridPoint.y.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final fcstUri = Uri.https(
|
||||||
|
'apis.data.go.kr',
|
||||||
|
'/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst',
|
||||||
|
{
|
||||||
|
'serviceKey': serviceKey,
|
||||||
|
'numOfRows': '200',
|
||||||
|
'pageNo': '1',
|
||||||
|
'dataType': 'JSON',
|
||||||
|
'base_date': baseDateTime.date,
|
||||||
|
'base_time': baseDateTime.fcstTime,
|
||||||
|
'nx': gridPoint.x.toString(),
|
||||||
|
'ny': gridPoint.y.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final ncstItems = await _requestKmaItems(ncstUri);
|
||||||
|
final fcstItems = await _requestKmaItems(fcstUri);
|
||||||
|
|
||||||
|
final currentTemp = _extractLatestValue(ncstItems, 'T1H')?.round();
|
||||||
|
final currentPty = _extractLatestValue(ncstItems, 'PTY')?.round() ?? 0;
|
||||||
|
final currentSky = _extractLatestValue(ncstItems, 'SKY')?.round() ?? 1;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final nextHourData = _extractForecast(fcstItems, after: now);
|
||||||
|
final nextTemp = nextHourData.temperature?.round();
|
||||||
|
final nextPty = nextHourData.pty ?? 0;
|
||||||
|
final nextSky = nextHourData.sky ?? 1;
|
||||||
|
|
||||||
|
final currentWeather = WeatherData(
|
||||||
|
temperature: currentTemp ?? 20,
|
||||||
|
isRainy: _isRainy(currentPty),
|
||||||
|
description: _describeWeather(currentSky, currentPty),
|
||||||
|
);
|
||||||
|
|
||||||
|
final nextWeather = WeatherData(
|
||||||
|
temperature: nextTemp ?? currentTemp ?? 20,
|
||||||
|
isRainy: _isRainy(nextPty),
|
||||||
|
description: _describeWeather(nextSky, nextPty),
|
||||||
|
);
|
||||||
|
|
||||||
|
return WeatherInfo(current: currentWeather, nextHour: nextWeather);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<dynamic>> _requestKmaItems(Uri uri) async {
|
||||||
|
final response = await http.get(uri);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Weather API 호출 실패: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final items = body['response']?['body']?['items']?['item'];
|
||||||
|
if (items is List<dynamic>) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
throw Exception('Weather API 응답 파싱 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _extractLatestValue(List<dynamic> items, String category) {
|
||||||
|
final filtered = items.where((item) => item['category'] == category);
|
||||||
|
if (filtered.isEmpty) return null;
|
||||||
|
final sorted = filtered.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final dateA = a['baseDate'] as String? ?? '';
|
||||||
|
final timeA = a['baseTime'] as String? ?? '';
|
||||||
|
final dateB = b['baseDate'] as String? ?? '';
|
||||||
|
final timeB = b['baseTime'] as String? ?? '';
|
||||||
|
final dtA = _parseKmaDateTime(dateA, timeA);
|
||||||
|
final dtB = _parseKmaDateTime(dateB, timeB);
|
||||||
|
return dtB.compareTo(dtA);
|
||||||
|
});
|
||||||
|
final value = sorted.first['obsrValue'] ?? sorted.first['fcstValue'];
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
({double? temperature, int? pty, int? sky}) _extractForecast(
|
||||||
|
List<dynamic> items, {
|
||||||
|
required DateTime after,
|
||||||
|
}) {
|
||||||
|
DateTime? targetTime;
|
||||||
|
double? temperature;
|
||||||
|
int? pty;
|
||||||
|
int? sky;
|
||||||
|
|
||||||
|
DateTime fcstDateTime(Map<String, dynamic> item) {
|
||||||
|
final date = item['fcstDate'] as String? ?? '';
|
||||||
|
final time = item['fcstTime'] as String? ?? '';
|
||||||
|
return _parseKmaDateTime(date, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
final dt = fcstDateTime(item as Map<String, dynamic>);
|
||||||
|
if (!dt.isAfter(after)) continue;
|
||||||
|
if (targetTime == null || dt.isBefore(targetTime)) {
|
||||||
|
targetTime = dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTime == null) {
|
||||||
|
return (temperature: null, pty: null, sky: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
final map = item as Map<String, dynamic>;
|
||||||
|
final dt = fcstDateTime(map);
|
||||||
|
if (dt != targetTime) continue;
|
||||||
|
|
||||||
|
final category = map['category'];
|
||||||
|
final value = map['fcstValue'];
|
||||||
|
if (value == null) continue;
|
||||||
|
|
||||||
|
if (category == 'T1H' && temperature == null) {
|
||||||
|
temperature = value is num
|
||||||
|
? value.toDouble()
|
||||||
|
: double.tryParse('$value');
|
||||||
|
} else if (category == 'PTY' && pty == null) {
|
||||||
|
pty = value is num ? value.toInt() : int.tryParse('$value');
|
||||||
|
} else if (category == 'SKY' && sky == null) {
|
||||||
|
sky = value is num ? value.toInt() : int.tryParse('$value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (temperature: temperature, pty: pty, sky: sky);
|
||||||
|
}
|
||||||
|
|
||||||
|
_GridPoint _latLonToGrid(double lat, double lon) {
|
||||||
|
const re = 6371.00877;
|
||||||
|
const grid = 5.0;
|
||||||
|
const slat1 = 30.0 * pi / 180.0;
|
||||||
|
const slat2 = 60.0 * pi / 180.0;
|
||||||
|
const olon = 126.0 * pi / 180.0;
|
||||||
|
const olat = 38.0 * pi / 180.0;
|
||||||
|
const xo = 43.0;
|
||||||
|
const yo = 136.0;
|
||||||
|
|
||||||
|
final sn =
|
||||||
|
log(cos(slat1) / cos(slat2)) /
|
||||||
|
log(tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5));
|
||||||
|
final sf = pow(tan(pi * 0.25 + slat1 * 0.5), sn) * cos(slat1) / sn;
|
||||||
|
final ro = re / grid * sf / pow(tan(pi * 0.25 + olat * 0.5), sn);
|
||||||
|
final ra =
|
||||||
|
re / grid * sf / pow(tan(pi * 0.25 + (lat * pi / 180.0) * 0.5), sn);
|
||||||
|
var theta = lon * pi / 180.0 - olon;
|
||||||
|
if (theta > pi) theta -= 2.0 * pi;
|
||||||
|
if (theta < -pi) theta += 2.0 * pi;
|
||||||
|
theta *= sn;
|
||||||
|
|
||||||
|
final x = (ra * sin(theta) + xo + 0.5).floor();
|
||||||
|
final y = (ro - ra * cos(theta) + yo + 0.5).floor();
|
||||||
|
return _GridPoint(x: x, y: y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isRainy(int pty) => pty > 0;
|
||||||
|
|
||||||
|
String _describeWeather(int sky, int pty) {
|
||||||
|
if (pty == 1) return '비';
|
||||||
|
if (pty == 2) return '비/눈';
|
||||||
|
if (pty == 3) return '눈';
|
||||||
|
if (pty == 4) return '소나기';
|
||||||
|
if (pty == 5) return '빗방울';
|
||||||
|
if (pty == 6) return '빗방울/눈날림';
|
||||||
|
if (pty == 7) return '눈날림';
|
||||||
|
|
||||||
|
switch (sky) {
|
||||||
|
case 1:
|
||||||
|
return '맑음';
|
||||||
|
case 3:
|
||||||
|
return '구름 많음';
|
||||||
|
case 4:
|
||||||
|
return '흐림';
|
||||||
|
default:
|
||||||
|
return '맑음';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 서비스 키를 안전하게 URL 인코딩한다.
|
||||||
|
/// 이미 인코딩된 값(%)이 포함되어 있으면 그대로 사용한다.
|
||||||
|
String _encodeServiceKey(String key) {
|
||||||
|
if (key.isEmpty) return '';
|
||||||
|
if (key.contains('%')) return key;
|
||||||
|
return Uri.encodeComponent(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
_BaseDateTime _resolveBaseDateTime() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// 초단기실황은 매시 정시 발표(정시+10분 이후 호출 권장)
|
||||||
|
// 초단기예보는 매시 30분 발표(30분+10분 이후 호출 권장)
|
||||||
|
final ncstAnchor = now.minute >= 10
|
||||||
|
? DateTime(now.year, now.month, now.day, now.hour, 0)
|
||||||
|
: DateTime(now.year, now.month, now.day, now.hour - 1, 0);
|
||||||
|
final fcstAnchor = now.minute >= 40
|
||||||
|
? DateTime(now.year, now.month, now.day, now.hour, 30)
|
||||||
|
: DateTime(now.year, now.month, now.day, now.hour - 1, 30);
|
||||||
|
|
||||||
|
final date = _formatDate(fcstAnchor); // 둘 다 같은 날짜/시점 기준
|
||||||
|
final ncstTime = _formatTime(ncstAnchor);
|
||||||
|
final fcstTime = _formatTime(fcstAnchor);
|
||||||
|
|
||||||
|
return _BaseDateTime(date: date, ncstTime: ncstTime, fcstTime: fcstTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) {
|
||||||
|
final y = dt.year.toString().padLeft(4, '0');
|
||||||
|
final m = dt.month.toString().padLeft(2, '0');
|
||||||
|
final d = dt.day.toString().padLeft(2, '0');
|
||||||
|
return '$y$m$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime dt) {
|
||||||
|
final h = dt.hour.toString().padLeft(2, '0');
|
||||||
|
final m = dt.minute.toString().padLeft(2, '0');
|
||||||
|
return '$h$m';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _parseKmaDateTime(String date, String time) {
|
||||||
|
final year = int.parse(date.substring(0, 4));
|
||||||
|
final month = int.parse(date.substring(4, 6));
|
||||||
|
final day = int.parse(date.substring(6, 8));
|
||||||
|
final hour = int.parse(time.substring(0, 2));
|
||||||
|
final minute = int.parse(time.substring(2, 4));
|
||||||
|
return DateTime(year, month, day, hour, minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridPoint {
|
||||||
|
final int x;
|
||||||
|
final int y;
|
||||||
|
|
||||||
|
_GridPoint({required this.x, required this.y});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BaseDateTime {
|
||||||
|
final String date;
|
||||||
|
final String ncstTime;
|
||||||
|
final String fcstTime;
|
||||||
|
|
||||||
|
_BaseDateTime({
|
||||||
|
required this.date,
|
||||||
|
required this.ncstTime,
|
||||||
|
required this.fcstTime,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import 'package:lunchpick/core/constants/app_constants.dart';
|
|||||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||||
|
|
||||||
|
import 'store_dataset_seeder.dart';
|
||||||
import 'manual_restaurant_samples.dart';
|
import 'manual_restaurant_samples.dart';
|
||||||
|
|
||||||
/// 초기 구동 시 샘플 데이터를 채워 넣는 도우미
|
/// 초기 구동 시 샘플 데이터를 채워 넣는 도우미
|
||||||
class SampleDataInitializer {
|
class SampleDataInitializer {
|
||||||
|
static Future<void> seedInitialData() async {
|
||||||
|
await StoreDatasetSeeder().seedIfNeeded();
|
||||||
|
await seedManualRestaurantsIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> seedManualRestaurantsIfNeeded() async {
|
static Future<void> seedManualRestaurantsIfNeeded() async {
|
||||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||||
final visitBox = Hive.box<VisitRecord>(AppConstants.visitRecordBox);
|
final visitBox = Hive.box<VisitRecord>(AppConstants.visitRecordBox);
|
||||||
|
|||||||
243
lib/data/sample/store_dataset_seeder.dart
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:lunchpick/core/constants/app_constants.dart';
|
||||||
|
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||||
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||||
|
|
||||||
|
class StoreSeedMeta {
|
||||||
|
final String version;
|
||||||
|
final DateTime generatedAt;
|
||||||
|
final int itemCount;
|
||||||
|
final StoreSeedSourceSignature? sourceSignature;
|
||||||
|
|
||||||
|
StoreSeedMeta({
|
||||||
|
required this.version,
|
||||||
|
required this.generatedAt,
|
||||||
|
required this.itemCount,
|
||||||
|
this.sourceSignature,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StoreSeedMeta.fromJson(Map<String, dynamic> json) {
|
||||||
|
StoreSeedSourceSignature? signature;
|
||||||
|
if (json['sourceSignature'] != null) {
|
||||||
|
signature = StoreSeedSourceSignature.fromJson(
|
||||||
|
json['sourceSignature'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StoreSeedMeta(
|
||||||
|
version: json['version'] as String,
|
||||||
|
generatedAt: DateTime.parse(json['generatedAt'] as String),
|
||||||
|
itemCount: json['itemCount'] as int,
|
||||||
|
sourceSignature: signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreSeedSourceSignature {
|
||||||
|
final String hash;
|
||||||
|
final int? size;
|
||||||
|
|
||||||
|
StoreSeedSourceSignature({required this.hash, this.size});
|
||||||
|
|
||||||
|
factory StoreSeedSourceSignature.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreSeedSourceSignature(
|
||||||
|
hash: json['hash'] as String,
|
||||||
|
size: (json['size'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreSeedItem {
|
||||||
|
final int storeId;
|
||||||
|
final String name;
|
||||||
|
final String title;
|
||||||
|
final String address;
|
||||||
|
final String roadAddress;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
|
||||||
|
StoreSeedItem({
|
||||||
|
required this.storeId,
|
||||||
|
required this.name,
|
||||||
|
required this.title,
|
||||||
|
required this.address,
|
||||||
|
required this.roadAddress,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StoreSeedItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreSeedItem(
|
||||||
|
storeId: json['storeId'] as int,
|
||||||
|
name: (json['name'] as String).trim(),
|
||||||
|
title: (json['title'] as String).trim(),
|
||||||
|
address: (json['address'] as String).trim(),
|
||||||
|
roadAddress: (json['roadAddress'] as String).trim(),
|
||||||
|
latitude: (json['latitude'] as num).toDouble(),
|
||||||
|
longitude: (json['longitude'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreDatasetSeeder {
|
||||||
|
Future<void> seedIfNeeded() async {
|
||||||
|
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||||
|
final settingsBox = Hive.box(AppConstants.settingsBox);
|
||||||
|
|
||||||
|
final meta = await _loadMeta();
|
||||||
|
if (meta == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentVersion =
|
||||||
|
settingsBox.get(AppConstants.storeSeedVersionKey) as String?;
|
||||||
|
final shouldSeed = restaurantBox.isEmpty || currentVersion != meta.version;
|
||||||
|
if (!shouldSeed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seeds = await _loadSeedItems();
|
||||||
|
if (seeds.isEmpty) {
|
||||||
|
AppLogger.info('store_seed.json 데이터가 비어 있어 시드를 건너뜁니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _applySeeds(
|
||||||
|
restaurantBox: restaurantBox,
|
||||||
|
seeds: seeds,
|
||||||
|
generatedAt: meta.generatedAt,
|
||||||
|
);
|
||||||
|
await settingsBox.put(AppConstants.storeSeedVersionKey, meta.version);
|
||||||
|
|
||||||
|
AppLogger.info(
|
||||||
|
'스토어 시드 적용 완료: version=${meta.version}, count=${meta.itemCount}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StoreSeedMeta?> _loadMeta() async {
|
||||||
|
try {
|
||||||
|
final metaJson = await rootBundle.loadString(
|
||||||
|
AppConstants.storeSeedMetaAsset,
|
||||||
|
);
|
||||||
|
final decoded = jsonDecode(metaJson) as Map<String, dynamic>;
|
||||||
|
return StoreSeedMeta.fromJson(decoded);
|
||||||
|
} catch (e, stack) {
|
||||||
|
AppLogger.error(
|
||||||
|
'store_seed.meta.json 로딩 실패',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StoreSeedItem>> _loadSeedItems() async {
|
||||||
|
try {
|
||||||
|
final dataJson = await rootBundle.loadString(
|
||||||
|
AppConstants.storeSeedDataAsset,
|
||||||
|
);
|
||||||
|
final decoded = jsonDecode(dataJson);
|
||||||
|
if (decoded is! List) {
|
||||||
|
throw const FormatException('store_seed.json 포맷이 배열이 아닙니다.');
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(StoreSeedItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
} catch (e, stack) {
|
||||||
|
AppLogger.error('store_seed.json 로딩 실패', error: e, stackTrace: stack);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySeeds({
|
||||||
|
required Box<Restaurant> restaurantBox,
|
||||||
|
required List<StoreSeedItem> seeds,
|
||||||
|
required DateTime generatedAt,
|
||||||
|
}) async {
|
||||||
|
final seedMap = {for (final seed in seeds) _buildId(seed.storeId): seed};
|
||||||
|
int added = 0;
|
||||||
|
int updated = 0;
|
||||||
|
|
||||||
|
for (final entry in seedMap.entries) {
|
||||||
|
final id = entry.key;
|
||||||
|
final seed = entry.value;
|
||||||
|
final existing = restaurantBox.get(id);
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
final restaurant = _buildRestaurant(seed, generatedAt);
|
||||||
|
await restaurantBox.put(id, restaurant);
|
||||||
|
added++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.source == DataSource.PRESET) {
|
||||||
|
final description = _buildDescription(seed, existing.description);
|
||||||
|
final restaurant = existing.copyWith(
|
||||||
|
name: seed.name,
|
||||||
|
category: existing.category.isNotEmpty ? existing.category : '기타',
|
||||||
|
subCategory: existing.subCategory.isNotEmpty
|
||||||
|
? existing.subCategory
|
||||||
|
: '기타',
|
||||||
|
description: description,
|
||||||
|
roadAddress: seed.roadAddress,
|
||||||
|
jibunAddress: seed.address.isNotEmpty
|
||||||
|
? seed.address
|
||||||
|
: seed.roadAddress,
|
||||||
|
latitude: seed.latitude,
|
||||||
|
longitude: seed.longitude,
|
||||||
|
updatedAt: generatedAt,
|
||||||
|
);
|
||||||
|
await restaurantBox.put(id, restaurant);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final unchanged = restaurantBox.length - added - updated;
|
||||||
|
AppLogger.debug(
|
||||||
|
'스토어 시드 결과 - 추가: $added, 업데이트: $updated, 기존 유지: '
|
||||||
|
'$unchanged',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Restaurant _buildRestaurant(StoreSeedItem seed, DateTime generatedAt) {
|
||||||
|
return Restaurant(
|
||||||
|
id: _buildId(seed.storeId),
|
||||||
|
name: seed.name,
|
||||||
|
category: '기타',
|
||||||
|
subCategory: '기타',
|
||||||
|
description: _buildDescription(seed, null),
|
||||||
|
phoneNumber: null,
|
||||||
|
roadAddress: seed.roadAddress,
|
||||||
|
jibunAddress: seed.address.isNotEmpty ? seed.address : seed.roadAddress,
|
||||||
|
latitude: seed.latitude,
|
||||||
|
longitude: seed.longitude,
|
||||||
|
lastVisitDate: null,
|
||||||
|
source: DataSource.PRESET,
|
||||||
|
createdAt: generatedAt,
|
||||||
|
updatedAt: generatedAt,
|
||||||
|
naverPlaceId: null,
|
||||||
|
naverUrl: null,
|
||||||
|
businessHours: null,
|
||||||
|
lastVisited: null,
|
||||||
|
visitCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildId(int storeId) => 'store-$storeId';
|
||||||
|
|
||||||
|
String? _buildDescription(StoreSeedItem seed, String? existingDescription) {
|
||||||
|
if (existingDescription != null && existingDescription.isNotEmpty) {
|
||||||
|
return existingDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seed.title.isNotEmpty && seed.title != seed.name) {
|
||||||
|
return seed.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ class Restaurant extends HiveObject {
|
|||||||
@HiveField(18)
|
@HiveField(18)
|
||||||
final int visitCount;
|
final int visitCount;
|
||||||
|
|
||||||
|
@HiveField(19)
|
||||||
|
final bool needsAddressVerification;
|
||||||
|
|
||||||
Restaurant({
|
Restaurant({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -81,6 +84,7 @@ class Restaurant extends HiveObject {
|
|||||||
this.businessHours,
|
this.businessHours,
|
||||||
this.lastVisited,
|
this.lastVisited,
|
||||||
this.visitCount = 0,
|
this.visitCount = 0,
|
||||||
|
this.needsAddressVerification = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
Restaurant copyWith({
|
Restaurant copyWith({
|
||||||
@@ -103,6 +107,7 @@ class Restaurant extends HiveObject {
|
|||||||
String? businessHours,
|
String? businessHours,
|
||||||
DateTime? lastVisited,
|
DateTime? lastVisited,
|
||||||
int? visitCount,
|
int? visitCount,
|
||||||
|
bool? needsAddressVerification,
|
||||||
}) {
|
}) {
|
||||||
return Restaurant(
|
return Restaurant(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -124,6 +129,8 @@ class Restaurant extends HiveObject {
|
|||||||
businessHours: businessHours ?? this.businessHours,
|
businessHours: businessHours ?? this.businessHours,
|
||||||
lastVisited: lastVisited ?? this.lastVisited,
|
lastVisited: lastVisited ?? this.lastVisited,
|
||||||
visitCount: visitCount ?? this.visitCount,
|
visitCount: visitCount ?? this.visitCount,
|
||||||
|
needsAddressVerification:
|
||||||
|
needsAddressVerification ?? this.needsAddressVerification,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,4 +142,7 @@ enum DataSource {
|
|||||||
|
|
||||||
@HiveField(1)
|
@HiveField(1)
|
||||||
USER_INPUT,
|
USER_INPUT,
|
||||||
|
|
||||||
|
@HiveField(2)
|
||||||
|
PRESET,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ abstract class SettingsRepository {
|
|||||||
/// 알림 활성화 여부를 설정합니다
|
/// 알림 활성화 여부를 설정합니다
|
||||||
Future<void> setNotificationEnabled(bool enabled);
|
Future<void> setNotificationEnabled(bool enabled);
|
||||||
|
|
||||||
|
/// 스크린샷 모드 활성화 여부를 가져옵니다
|
||||||
|
Future<bool> isScreenshotModeEnabled();
|
||||||
|
|
||||||
|
/// 스크린샷 모드 활성화 여부를 설정합니다
|
||||||
|
Future<void> setScreenshotModeEnabled(bool enabled);
|
||||||
|
|
||||||
/// 다크모드 설정을 가져옵니다
|
/// 다크모드 설정을 가져옵니다
|
||||||
Future<bool> isDarkModeEnabled();
|
Future<bool> isDarkModeEnabled();
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,34 @@ class RecommendationEngine {
|
|||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
// 최근 방문하지 않은 식당만 필터링
|
// 최근 방문하지 않은 식당만 필터링
|
||||||
return restaurants.where((restaurant) {
|
final filtered = restaurants.where((restaurant) {
|
||||||
return !recentlyVisitedIds.contains(restaurant.id);
|
return !recentlyVisitedIds.contains(restaurant.id);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
if (filtered.isNotEmpty) return filtered;
|
||||||
|
|
||||||
|
// 모든 식당이 제외되면 가장 오래전에 방문한 식당을 반환
|
||||||
|
final lastVisitByRestaurant = <String, DateTime>{};
|
||||||
|
for (final visit in recentVisits) {
|
||||||
|
final current = lastVisitByRestaurant[visit.restaurantId];
|
||||||
|
if (current == null || visit.visitDate.isAfter(current)) {
|
||||||
|
lastVisitByRestaurant[visit.restaurantId] = visit.visitDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Restaurant? oldestRestaurant;
|
||||||
|
DateTime? oldestVisitDate;
|
||||||
|
for (final restaurant in restaurants) {
|
||||||
|
final lastVisit = lastVisitByRestaurant[restaurant.id];
|
||||||
|
if (lastVisit == null) continue;
|
||||||
|
|
||||||
|
if (oldestVisitDate == null || lastVisit.isBefore(oldestVisitDate)) {
|
||||||
|
oldestVisitDate = lastVisit;
|
||||||
|
oldestRestaurant = restaurant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldestRestaurant != null ? [oldestRestaurant] : restaurants;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 카테고리 필터링
|
/// 카테고리 필터링
|
||||||
@@ -123,142 +148,7 @@ class RecommendationEngine {
|
|||||||
) {
|
) {
|
||||||
if (restaurants.isEmpty) return null;
|
if (restaurants.isEmpty) return null;
|
||||||
|
|
||||||
// 각 식당에 대한 가중치 계산
|
// 가중치 미적용: 거리/방문 필터를 통과한 식당 중 균등 무작위 선택
|
||||||
final weightedRestaurants = restaurants.map((restaurant) {
|
return restaurants[_random.nextInt(restaurants.length)];
|
||||||
double weight = 1.0;
|
|
||||||
|
|
||||||
// 카테고리 가중치 적용
|
|
||||||
final categoryWeight =
|
|
||||||
config.userSettings.categoryWeights[restaurant.category];
|
|
||||||
if (categoryWeight != null) {
|
|
||||||
weight *= categoryWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 거리 가중치 적용 (가까울수록 높은 가중치)
|
|
||||||
final distance = DistanceCalculator.calculateDistance(
|
|
||||||
lat1: config.userLatitude,
|
|
||||||
lon1: config.userLongitude,
|
|
||||||
lat2: restaurant.latitude,
|
|
||||||
lon2: restaurant.longitude,
|
|
||||||
);
|
|
||||||
final distanceWeight = 1.0 - (distance / config.maxDistance);
|
|
||||||
weight *= (0.5 + distanceWeight * 0.5); // 50% ~ 100% 범위
|
|
||||||
|
|
||||||
// 시간대별 가중치 적용
|
|
||||||
weight *= _getTimeBasedWeight(restaurant, config.currentTime);
|
|
||||||
|
|
||||||
// 날씨 기반 가중치 적용
|
|
||||||
if (config.weather != null) {
|
|
||||||
weight *= _getWeatherBasedWeight(restaurant, config.weather!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _WeightedRestaurant(restaurant, weight);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// 가중치 기반 랜덤 선택
|
|
||||||
return _weightedRandomSelection(weightedRestaurants);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 시간대별 가중치 계산
|
|
||||||
double _getTimeBasedWeight(Restaurant restaurant, DateTime currentTime) {
|
|
||||||
final hour = currentTime.hour;
|
|
||||||
|
|
||||||
// 아침 시간대 (7-10시)
|
|
||||||
if (hour >= 7 && hour < 10) {
|
|
||||||
if (restaurant.category == 'cafe' || restaurant.category == 'korean') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
if (restaurant.category == 'bar') {
|
|
||||||
return 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 점심 시간대 (11-14시)
|
|
||||||
else if (hour >= 11 && hour < 14) {
|
|
||||||
if (restaurant.category == 'korean' ||
|
|
||||||
restaurant.category == 'chinese' ||
|
|
||||||
restaurant.category == 'japanese') {
|
|
||||||
return 1.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 저녁 시간대 (17-21시)
|
|
||||||
else if (hour >= 17 && hour < 21) {
|
|
||||||
if (restaurant.category == 'bar' || restaurant.category == 'western') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 늦은 저녁 (21시 이후)
|
|
||||||
else if (hour >= 21) {
|
|
||||||
if (restaurant.category == 'bar' || restaurant.category == 'fastfood') {
|
|
||||||
return 1.3;
|
|
||||||
}
|
|
||||||
if (restaurant.category == 'cafe') {
|
|
||||||
return 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 날씨 기반 가중치 계산
|
|
||||||
double _getWeatherBasedWeight(Restaurant restaurant, WeatherInfo weather) {
|
|
||||||
if (weather.current.isRainy) {
|
|
||||||
// 비가 올 때는 가까운 식당 선호
|
|
||||||
// 이미 거리 가중치에서 처리했으므로 여기서는 실내 카테고리 선호
|
|
||||||
if (restaurant.category == 'cafe' || restaurant.category == 'fastfood') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 더운 날씨 (25도 이상)
|
|
||||||
if (weather.current.temperature >= 25) {
|
|
||||||
if (restaurant.category == 'cafe' || restaurant.category == 'japanese') {
|
|
||||||
return 1.1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추운 날씨 (10도 이하)
|
|
||||||
if (weather.current.temperature <= 10) {
|
|
||||||
if (restaurant.category == 'korean' || restaurant.category == 'chinese') {
|
|
||||||
return 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가중치 기반 랜덤 선택
|
|
||||||
Restaurant? _weightedRandomSelection(
|
|
||||||
List<_WeightedRestaurant> weightedRestaurants,
|
|
||||||
) {
|
|
||||||
if (weightedRestaurants.isEmpty) return null;
|
|
||||||
|
|
||||||
// 전체 가중치 합계 계산
|
|
||||||
final totalWeight = weightedRestaurants.fold<double>(
|
|
||||||
0,
|
|
||||||
(sum, item) => sum + item.weight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 랜덤 값 생성
|
|
||||||
final randomValue = _random.nextDouble() * totalWeight;
|
|
||||||
|
|
||||||
// 누적 가중치로 선택
|
|
||||||
double cumulativeWeight = 0;
|
|
||||||
for (final weightedRestaurant in weightedRestaurants) {
|
|
||||||
cumulativeWeight += weightedRestaurant.weight;
|
|
||||||
if (randomValue <= cumulativeWeight) {
|
|
||||||
return weightedRestaurant.restaurant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 예외 처리 (여기에 도달하면 안됨)
|
|
||||||
return weightedRestaurants.last.restaurant;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 가중치가 적용된 식당 모델
|
|
||||||
class _WeightedRestaurant {
|
|
||||||
final Restaurant restaurant;
|
|
||||||
final double weight;
|
|
||||||
|
|
||||||
_WeightedRestaurant(this.restaurant, this.weight);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||||
import 'package:go_router/go_router.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 'package:timezone/data/latest_all.dart' as tz;
|
||||||
|
|
||||||
import 'core/constants/app_colors.dart';
|
import 'core/constants/app_colors.dart';
|
||||||
import 'core/constants/app_constants.dart';
|
import 'core/constants/app_constants.dart';
|
||||||
import 'core/services/notification_service.dart';
|
import 'core/services/notification_service.dart';
|
||||||
|
import 'core/utils/ad_helper.dart';
|
||||||
import 'domain/entities/restaurant.dart';
|
import 'domain/entities/restaurant.dart';
|
||||||
import 'domain/entities/visit_record.dart';
|
import 'domain/entities/visit_record.dart';
|
||||||
import 'domain/entities/recommendation_record.dart';
|
import 'domain/entities/recommendation_record.dart';
|
||||||
@@ -20,10 +22,28 @@ import 'data/sample/sample_data_initializer.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Initialize timezone
|
// Initialize timezone (동기, 빠름)
|
||||||
tz.initializeTimeZones();
|
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();
|
await Hive.initFlutter();
|
||||||
|
|
||||||
// Register Hive Adapters
|
// Register Hive Adapters
|
||||||
@@ -33,25 +53,21 @@ void main() async {
|
|||||||
Hive.registerAdapter(RecommendationRecordAdapter());
|
Hive.registerAdapter(RecommendationRecordAdapter());
|
||||||
Hive.registerAdapter(UserSettingsAdapter());
|
Hive.registerAdapter(UserSettingsAdapter());
|
||||||
|
|
||||||
// Open Hive Boxes
|
// Open Hive Boxes (병렬 오픈)
|
||||||
await Hive.openBox<Restaurant>(AppConstants.restaurantBox);
|
await Future.wait([
|
||||||
await Hive.openBox<VisitRecord>(AppConstants.visitRecordBox);
|
Hive.openBox<Restaurant>(AppConstants.restaurantBox),
|
||||||
await Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox);
|
Hive.openBox<VisitRecord>(AppConstants.visitRecordBox),
|
||||||
await Hive.openBox(AppConstants.settingsBox);
|
Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox),
|
||||||
await Hive.openBox<UserSettings>('user_settings');
|
Hive.openBox(AppConstants.settingsBox),
|
||||||
await SampleDataInitializer.seedManualRestaurantsIfNeeded();
|
Hive.openBox<UserSettings>('user_settings'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Notification Service (only for non-web platforms)
|
/// 알림 서비스 초기화 (비-웹 플랫폼)
|
||||||
if (!kIsWeb) {
|
Future<void> _initializeNotifications() async {
|
||||||
final notificationService = NotificationService();
|
if (kIsWeb) return;
|
||||||
await notificationService.initialize();
|
final notificationService = NotificationService();
|
||||||
await notificationService.requestPermission();
|
await notificationService.ensureInitialized(requestPermission: true);
|
||||||
}
|
|
||||||
|
|
||||||
// Get saved theme mode
|
|
||||||
final savedThemeMode = await AdaptiveTheme.getThemeMode();
|
|
||||||
|
|
||||||
runApp(ProviderScope(child: LunchPickApp(savedThemeMode: savedThemeMode)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LunchPickApp extends StatelessWidget {
|
class LunchPickApp extends StatelessWidget {
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
|
import '../../../core/widgets/info_row.dart';
|
||||||
|
import '../../../domain/entities/restaurant.dart';
|
||||||
|
import '../../../domain/entities/recommendation_record.dart';
|
||||||
import '../../../domain/entities/visit_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 '../../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/visit_record_card.dart';
|
||||||
|
import 'widgets/recommendation_record_card.dart';
|
||||||
import 'widgets/visit_statistics.dart';
|
import 'widgets/visit_statistics.dart';
|
||||||
|
|
||||||
class CalendarScreen extends ConsumerStatefulWidget {
|
class CalendarScreen extends ConsumerStatefulWidget {
|
||||||
@@ -19,15 +28,22 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late DateTime _selectedDay;
|
late DateTime _selectedDay;
|
||||||
late DateTime _focusedDay;
|
late DateTime _focusedDay;
|
||||||
|
late DateTime _selectedMonth;
|
||||||
|
late DateTime _firstDay;
|
||||||
|
late DateTime _lastDay;
|
||||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
Map<DateTime, List<VisitRecord>> _visitRecordEvents = {};
|
Map<DateTime, List<_CalendarEvent>> _events = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedDay = DateTime.now();
|
final now = DateTime.now();
|
||||||
_focusedDay = 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);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +53,30 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<VisitRecord> _getEventsForDay(DateTime day) {
|
List<_CalendarEvent> _getEventsForDay(DateTime day) {
|
||||||
final normalizedDay = DateTime(day.year, day.month, day.day);
|
final normalizedDay = DateTime(day.year, day.month, day.day);
|
||||||
return _visitRecordEvents[normalizedDay] ?? [];
|
return _events[normalizedDay] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -71,160 +103,196 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
// 캘린더 탭
|
// 캘린더 탭
|
||||||
_buildCalendarTab(isDark),
|
_buildCalendarTab(isDark: isDark, adsEnabled: !screenshotModeEnabled),
|
||||||
// 통계 탭
|
// 통계 탭
|
||||||
VisitStatistics(selectedMonth: _focusedDay),
|
VisitStatistics(
|
||||||
|
selectedMonth: _selectedMonth,
|
||||||
|
availableMonths: monthOptions,
|
||||||
|
onMonthChanged: _onMonthChanged,
|
||||||
|
adsEnabled: !screenshotModeEnabled,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendarTab(bool isDark) {
|
Widget _buildCalendarTab({required bool isDark, bool adsEnabled = true}) {
|
||||||
return Consumer(
|
final visitColor = _visitMarkerColor(isDark);
|
||||||
builder: (context, ref, child) {
|
final recommendationColor = _recommendationMarkerColor(isDark);
|
||||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
|
||||||
|
|
||||||
// 방문 기록을 날짜별로 그룹화
|
return LayoutBuilder(
|
||||||
visitRecordsAsync.whenData((records) {
|
builder: (context, constraints) {
|
||||||
_visitRecordEvents = {};
|
return SingleChildScrollView(
|
||||||
for (final record in records) {
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
final normalizedDate = DateTime(
|
child: ConstrainedBox(
|
||||||
record.visitDate.year,
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
record.visitDate.month,
|
child: Column(
|
||||||
record.visitDate.day,
|
children: [
|
||||||
);
|
Card(
|
||||||
_visitRecordEvents[normalizedDate] = [
|
margin: const EdgeInsets.all(16),
|
||||||
...(_visitRecordEvents[normalizedDate] ?? []),
|
color: isDark
|
||||||
record,
|
? AppColors.darkSurface
|
||||||
];
|
: AppColors.lightSurface,
|
||||||
}
|
elevation: 2,
|
||||||
});
|
shape: RoundedRectangleBorder(
|
||||||
|
|
||||||
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),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
formatButtonTextStyle: const TextStyle(
|
child: TableCalendar(
|
||||||
color: AppColors.lightPrimary,
|
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) {
|
Widget _buildLegend(
|
||||||
return Row(
|
String label,
|
||||||
|
Color color,
|
||||||
|
bool isDark, {
|
||||||
|
String? tooltip,
|
||||||
|
IconData? icon,
|
||||||
|
}) {
|
||||||
|
final content = Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -232,13 +300,25 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
Text(label, style: AppTypography.body2(isDark)),
|
Text(label, style: AppTypography.body2(isDark)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tooltip == null) return content;
|
||||||
|
return Tooltip(message: tooltip, child: content);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDayRecords(DateTime day, bool isDark) {
|
Widget _buildDayRecords(DateTime day, bool isDark) {
|
||||||
final events = _getEventsForDay(day);
|
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) {
|
if (events.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -274,14 +354,14 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'${day.month}월 ${day.day}일 방문 기록',
|
'${day.month}월 ${day.day}일 기록',
|
||||||
style: AppTypography.body1(
|
style: AppTypography.body1(
|
||||||
isDark,
|
isDark,
|
||||||
).copyWith(fontWeight: FontWeight.bold),
|
).copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'${events.length}건',
|
'${events.length}건 · 방문 $visitCount/추천 $recommendationCount',
|
||||||
style: AppTypography.body2(isDark).copyWith(
|
style: AppTypography.body2(isDark).copyWith(
|
||||||
color: AppColors.lightPrimary,
|
color: AppColors.lightPrimary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -290,22 +370,431 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
ListView.builder(
|
||||||
child: ListView.builder(
|
shrinkWrap: true,
|
||||||
itemCount: events.length,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemBuilder: (context, index) {
|
itemCount: events.length,
|
||||||
final sortedEvents = events
|
itemBuilder: (context, index) {
|
||||||
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
final event = events[index];
|
||||||
|
if (event.visitRecord != null) {
|
||||||
return VisitRecordCard(
|
return VisitRecordCard(
|
||||||
visitRecord: sortedEvents[index],
|
visitRecord: event.visitRecord!,
|
||||||
onTap: () {
|
onTap: () =>
|
||||||
// TODO: 맛집 상세 페이지로 이동
|
_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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,21 @@ import 'package:lunchpick/core/constants/app_colors.dart';
|
|||||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||||
|
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||||
|
|
||||||
class VisitStatistics extends ConsumerWidget {
|
class VisitStatistics extends ConsumerWidget {
|
||||||
final DateTime selectedMonth;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -21,6 +31,12 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
month: selectedMonth.month,
|
month: selectedMonth.month,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
final monthlyCategoryStatsAsync = ref.watch(
|
||||||
|
monthlyCategoryVisitStatsProvider((
|
||||||
|
year: selectedMonth.year,
|
||||||
|
month: selectedMonth.month,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
// 자주 방문한 맛집
|
// 자주 방문한 맛집
|
||||||
final frequentRestaurantsAsync = ref.watch(frequentRestaurantsProvider);
|
final frequentRestaurantsAsync = ref.watch(frequentRestaurantsProvider);
|
||||||
@@ -33,7 +49,14 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 이번 달 통계
|
// 이번 달 통계
|
||||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
_buildMonthlyStats(
|
||||||
|
monthlyStatsAsync,
|
||||||
|
monthlyCategoryStatsAsync,
|
||||||
|
isDark,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
NativeAdPlaceholder(height: 360, enabled: adsEnabled),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 주간 통계 차트
|
// 주간 통계 차트
|
||||||
@@ -49,8 +72,11 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget _buildMonthlyStats(
|
Widget _buildMonthlyStats(
|
||||||
AsyncValue<Map<String, int>> statsAsync,
|
AsyncValue<Map<String, int>> statsAsync,
|
||||||
|
AsyncValue<Map<String, int>> categoryStatsAsync,
|
||||||
bool isDark,
|
bool isDark,
|
||||||
) {
|
) {
|
||||||
|
final monthList = _normalizeMonths();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -60,10 +86,7 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildMonthSelector(monthList, isDark),
|
||||||
'${selectedMonth.month}월 방문 통계',
|
|
||||||
style: AppTypography.heading2(isDark),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
statsAsync.when(
|
statsAsync.when(
|
||||||
data: (stats) {
|
data: (stats) {
|
||||||
@@ -71,9 +94,17 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
0,
|
0,
|
||||||
(sum, count) => sum + count,
|
(sum, count) => sum + count,
|
||||||
);
|
);
|
||||||
final categoryCounts =
|
final categoryCounts = categoryStatsAsync.maybeWhen(
|
||||||
stats.entries.where((e) => !e.key.contains('/')).toList()
|
data: (data) {
|
||||||
|
final entries = data.entries.toList()
|
||||||
..sort((a, b) => b.value.compareTo(a.value));
|
..sort((a, b) => b.value.compareTo(a.value));
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
orElse: () => <MapEntry<String, int>>[],
|
||||||
|
);
|
||||||
|
final topCategory = categoryCounts.isNotEmpty
|
||||||
|
? categoryCounts.first
|
||||||
|
: null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -85,12 +116,21 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (categoryCounts.isNotEmpty) ...[
|
if (topCategory != null) ...[
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
icon: Icons.favorite,
|
icon: Icons.favorite,
|
||||||
label: '가장 많이 간 카테고리',
|
label: '가장 많이 간 카테고리',
|
||||||
value:
|
value: '${topCategory.key} (${topCategory.value}회)',
|
||||||
'${categoryCounts.first.key} (${categoryCounts.first.value}회)',
|
color: AppColors.lightSecondary,
|
||||||
|
isDark: isDark,
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
_buildStatItem(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
label: '가장 많이 간 카테고리',
|
||||||
|
value: categoryStatsAsync.isLoading
|
||||||
|
? '집계 중...'
|
||||||
|
: '데이터 없음',
|
||||||
color: AppColors.lightSecondary,
|
color: AppColors.lightSecondary,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
),
|
),
|
||||||
@@ -130,7 +170,7 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
: stats.values.reduce((a, b) => a > b ? a : b);
|
: stats.values.reduce((a, b) => a > b ? a : b);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 120,
|
height: 140,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
@@ -325,4 +365,79 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMonthSelector(List<DateTime> months, bool isDark) {
|
||||||
|
final currentMonth = DateTime(selectedMonth.year, selectedMonth.month, 1);
|
||||||
|
final monthIndex = months.indexWhere(
|
||||||
|
(month) => _isSameMonth(month, currentMonth),
|
||||||
|
);
|
||||||
|
final resolvedIndex = monthIndex == -1 ? 0 : monthIndex;
|
||||||
|
final hasPrevious = resolvedIndex < months.length - 1;
|
||||||
|
final hasNext = resolvedIndex > 0;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${_formatMonth(currentMonth)} 방문 통계',
|
||||||
|
style: AppTypography.heading2(isDark),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: hasPrevious
|
||||||
|
? () => onMonthChanged(months[resolvedIndex + 1])
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
),
|
||||||
|
DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<DateTime>(
|
||||||
|
value: months[resolvedIndex],
|
||||||
|
onChanged: (month) {
|
||||||
|
if (month != null) {
|
||||||
|
onMonthChanged(month);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: months
|
||||||
|
.map(
|
||||||
|
(month) => DropdownMenuItem(
|
||||||
|
value: month,
|
||||||
|
child: Text(_formatMonth(month)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: hasNext
|
||||||
|
? () => onMonthChanged(months[resolvedIndex - 1])
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DateTime> _normalizeMonths() {
|
||||||
|
final normalized =
|
||||||
|
availableMonths
|
||||||
|
.map((month) => DateTime(month.year, month.month, 1))
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.compareTo(a));
|
||||||
|
|
||||||
|
final currentMonth = DateTime(selectedMonth.year, selectedMonth.month, 1);
|
||||||
|
final exists = normalized.any((month) => _isSameMonth(month, currentMonth));
|
||||||
|
if (!exists) {
|
||||||
|
normalized.insert(0, currentMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSameMonth(DateTime a, DateTime b) =>
|
||||||
|
a.year == b.year && a.month == b.month;
|
||||||
|
|
||||||
|
String _formatMonth(DateTime month) =>
|
||||||
|
'${month.year}.${month.month.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant MainScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.initialTab != widget.initialTab &&
|
||||||
|
_selectedIndex != widget.initialTab) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIndex = widget.initialTab;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
NotificationService.onNotificationTap = null;
|
NotificationService.onNotificationTap = null;
|
||||||
|
|||||||
@@ -1,215 +1,306 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||||
|
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||||
import 'package:lunchpick/domain/entities/restaurant.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 Restaurant restaurant;
|
||||||
final Future<void> Function() onReroll;
|
final Duration autoConfirmDuration;
|
||||||
final Future<void> Function() onClose;
|
final double? currentLatitude;
|
||||||
|
final double? currentLongitude;
|
||||||
|
|
||||||
const RecommendationResultDialog({
|
const RecommendationResultDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.restaurant,
|
required this.restaurant,
|
||||||
required this.onReroll,
|
this.autoConfirmDuration = const Duration(seconds: 12),
|
||||||
required this.onClose,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Dialog(
|
return WillPopScope(
|
||||||
backgroundColor: Colors.transparent,
|
onWillPop: () async {
|
||||||
child: Container(
|
await _handleResult(RecommendationDialogResult.confirm);
|
||||||
decoration: BoxDecoration(
|
return true;
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
child: Dialog(
|
||||||
),
|
backgroundColor: Colors.transparent,
|
||||||
child: Column(
|
child: Container(
|
||||||
mainAxisSize: MainAxisSize.min,
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
// 상단 이미지 영역
|
borderRadius: BorderRadius.circular(20),
|
||||||
Container(
|
),
|
||||||
height: 150,
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: AppColors.lightPrimary,
|
children: [
|
||||||
borderRadius: const BorderRadius.vertical(
|
Container(
|
||||||
top: Radius.circular(20),
|
height: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.lightPrimary,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(20),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Stack(
|
||||||
child: Stack(
|
children: [
|
||||||
children: [
|
Center(
|
||||||
Center(
|
child: Column(
|
||||||
child: Column(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
const Icon(
|
||||||
const Icon(
|
Icons.restaurant_menu,
|
||||||
Icons.restaurant_menu,
|
size: 64,
|
||||||
size: 64,
|
color: Colors.white,
|
||||||
color: Colors.white,
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
'오늘의 추천!',
|
||||||
'오늘의 추천!',
|
style: AppTypography.heading2(
|
||||||
style: AppTypography.heading2(
|
false,
|
||||||
false,
|
).copyWith(color: Colors.white),
|
||||||
).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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
|
top: 8,
|
||||||
if (restaurant.description != null) ...[
|
right: 8,
|
||||||
const SizedBox(height: 16),
|
child: IconButton(
|
||||||
Text(
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
restaurant.description!,
|
onPressed: () async {
|
||||||
style: AppTypography.body2(isDark),
|
await _handleResult(
|
||||||
textAlign: TextAlign.center,
|
RecommendationDialogResult.confirm,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
const Divider(),
|
Padding(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
// 주소
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Center(
|
||||||
Icon(
|
child: Text(
|
||||||
Icons.location_on,
|
widget.restaurant.name,
|
||||||
size: 20,
|
style: AppTypography.heading1(isDark),
|
||||||
color: isDark
|
textAlign: TextAlign.center,
|
||||||
? AppColors.darkTextSecondary
|
|
||||||
: AppColors.lightTextSecondary,
|
|
||||||
),
|
),
|
||||||
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(
|
child: Text(
|
||||||
restaurant.roadAddress,
|
'${widget.restaurant.category} > ${widget.restaurant.subCategory}',
|
||||||
style: AppTypography.body2(isDark),
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
if (restaurant.phoneNumber != null) ...[
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.phone,
|
Icons.location_on,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isDark
|
color: isDark
|
||||||
? AppColors.darkTextSecondary
|
? AppColors.darkTextSecondary
|
||||||
: AppColors.lightTextSecondary,
|
: AppColors.lightTextSecondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
restaurant.phoneNumber!,
|
child: Text(
|
||||||
style: AppTypography.body2(isDark),
|
widget.restaurant.roadAddress,
|
||||||
|
style: AppTypography.body2(isDark),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
if (_distanceKm != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 24),
|
Row(
|
||||||
|
children: [
|
||||||
// 버튼들
|
Icon(
|
||||||
Row(
|
Icons.place,
|
||||||
children: [
|
size: 20,
|
||||||
Expanded(
|
color: AppColors.lightPrimary,
|
||||||
child: OutlinedButton(
|
),
|
||||||
onPressed: () async {
|
const SizedBox(width: 8),
|
||||||
await onReroll();
|
Text(
|
||||||
},
|
_formatDistance(_distanceKm!),
|
||||||
style: OutlinedButton.styleFrom(
|
style: AppTypography.body2(isDark).copyWith(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
side: const BorderSide(
|
|
||||||
color: AppColors.lightPrimary,
|
color: AppColors.lightPrimary,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
],
|
||||||
'다시 뽑기',
|
|
||||||
style: TextStyle(color: AppColors.lightPrimary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await onClose();
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
backgroundColor: AppColors.lightPrimary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('닫기'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
if (widget.restaurant.phoneNumber != null) ...[
|
||||||
],
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.phone,
|
||||||
|
size: 20,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.darkTextSecondary
|
||||||
|
: AppColors.lightTextSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
widget.restaurant.phoneNumber!,
|
||||||
|
style: AppTypography.body2(isDark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await _handleResult(
|
||||||
|
RecommendationDialogResult.reroll,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
side: const BorderSide(
|
||||||
|
color: AppColors.lightPrimary,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'다시 뽑기',
|
||||||
|
style: TextStyle(color: AppColors.lightPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await _handleResult(
|
||||||
|
RecommendationDialogResult.confirm,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
backgroundColor: AppColors.lightPrimary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('오늘의 선택!'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'앱을 종료하면 자동으로 선택이 확정됩니다.',
|
||||||
|
style: AppTypography.caption(isDark),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
|
import '../../providers/restaurant_provider.dart';
|
||||||
import '../../view_models/add_restaurant_view_model.dart';
|
import '../../view_models/add_restaurant_view_model.dart';
|
||||||
import 'widgets/add_restaurant_form.dart';
|
import 'widgets/add_restaurant_form.dart';
|
||||||
|
|
||||||
@@ -121,6 +122,12 @@ class _ManualRestaurantInputScreenState
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final state = ref.watch(addRestaurantViewModelProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -150,6 +157,9 @@ class _ManualRestaurantInputScreenState
|
|||||||
latitudeController: _latitudeController,
|
latitudeController: _latitudeController,
|
||||||
longitudeController: _longitudeController,
|
longitudeController: _longitudeController,
|
||||||
onFieldChanged: _onFieldChanged,
|
onFieldChanged: _onFieldChanged,
|
||||||
|
categories: categories,
|
||||||
|
subCategories: subCategories,
|
||||||
|
geocodingStatus: state.geocodingStatus,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
|
import '../../../core/constants/app_dimensions.dart';
|
||||||
import '../../../core/constants/app_typography.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/restaurant_provider.dart';
|
||||||
|
import '../../providers/settings_provider.dart';
|
||||||
|
import '../../providers/visit_provider.dart';
|
||||||
import '../../widgets/category_selector.dart';
|
import '../../widgets/category_selector.dart';
|
||||||
|
import '../../widgets/native_ad_placeholder.dart';
|
||||||
import 'manual_restaurant_input_screen.dart';
|
import 'manual_restaurant_input_screen.dart';
|
||||||
import 'widgets/restaurant_card.dart';
|
import 'widgets/restaurant_card.dart';
|
||||||
import 'widgets/add_restaurant_dialog.dart';
|
import 'widgets/add_restaurant_dialog.dart';
|
||||||
@@ -31,11 +38,13 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final searchQuery = ref.watch(searchQueryProvider);
|
final searchQuery = ref.watch(searchQueryProvider);
|
||||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||||
final restaurantsAsync = ref.watch(
|
final screenshotModeEnabled = ref
|
||||||
searchQuery.isNotEmpty || selectedCategory != null
|
.watch(screenshotModeEnabledProvider)
|
||||||
? filteredRestaurantsProvider
|
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||||
: restaurantListProvider,
|
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||||
);
|
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
|
||||||
|
final lastVisitDates =
|
||||||
|
ref.watch(allLastVisitDatesProvider).valueOrNull ?? {};
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -66,6 +75,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
if (_isSearching) ...[
|
if (_isSearching) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: '검색 닫기',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
@@ -77,6 +87,7 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
] else ...[
|
] else ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: '맛집 검색',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearching = true;
|
_isSearching = true;
|
||||||
@@ -103,51 +114,143 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
// 맛집 목록
|
// 맛집 목록
|
||||||
Expanded(
|
Expanded(
|
||||||
child: restaurantsAsync.when(
|
child: restaurantsAsync.when(
|
||||||
data: (restaurants) {
|
data: (restaurantsData) {
|
||||||
if (restaurants.isEmpty) {
|
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);
|
return _buildEmptyState(isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adInterval = AppDimensions.adInterval;
|
||||||
|
const adOffset = AppDimensions.adOffset;
|
||||||
|
final adCount = (items.length ~/ adOffset);
|
||||||
|
final totalCount = items.length + adCount;
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: restaurants.length,
|
itemCount: totalCount,
|
||||||
itemBuilder: (context, index) {
|
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(
|
loading: () {
|
||||||
child: CircularProgressIndicator(color: AppColors.lightPrimary),
|
AppLogger.debug('[restaurant_list_ui] loading...');
|
||||||
),
|
return const RestaurantListSkeleton();
|
||||||
|
},
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Column(
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.all(AppDimensions.paddingDefault),
|
||||||
children: [
|
child: Column(
|
||||||
Icon(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
Icons.error_outline,
|
children: [
|
||||||
size: 64,
|
Icon(
|
||||||
color: isDark
|
Icons.error_outline,
|
||||||
? AppColors.darkError
|
size: AppDimensions.iconXxl,
|
||||||
: AppColors.lightError,
|
color: isDark
|
||||||
),
|
? AppColors.darkError
|
||||||
const SizedBox(height: 16),
|
: AppColors.lightError,
|
||||||
Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: AppDimensions.paddingDefault),
|
||||||
Text(
|
Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)),
|
||||||
error.toString(),
|
const SizedBox(height: AppDimensions.paddingSm),
|
||||||
style: AppTypography.body2(isDark),
|
Text(
|
||||||
textAlign: TextAlign.center,
|
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(
|
floatingActionButton: Semantics(
|
||||||
onPressed: _showAddOptions,
|
label: '맛집 추가하기',
|
||||||
backgroundColor: AppColors.lightPrimary,
|
button: true,
|
||||||
child: const Icon(Icons.add, color: Colors.white),
|
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();
|
_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(
|
ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
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 {
|
Future<void> _addManually() async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const ManualRestaurantInputScreen()),
|
MaterialPageRoute(builder: (_) => const ManualRestaurantInputScreen()),
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog> {
|
|||||||
_jibunAddressController.text = formData.jibunAddress;
|
_jibunAddressController.text = formData.jibunAddress;
|
||||||
_latitudeController.text = formData.latitude;
|
_latitudeController.text = formData.latitude;
|
||||||
_longitudeController.text = formData.longitude;
|
_longitudeController.text = formData.longitude;
|
||||||
_naverUrlController.text = formData.naverUrl;
|
// naverUrlController는 사용자 입력을 그대로 유지
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveRestaurant() async {
|
Future<void> _saveRestaurant() async {
|
||||||
@@ -234,6 +234,7 @@ class _AddRestaurantDialogState extends ConsumerState<AddRestaurantDialog> {
|
|||||||
longitudeController: _longitudeController,
|
longitudeController: _longitudeController,
|
||||||
naverUrlController: _naverUrlController,
|
naverUrlController: _naverUrlController,
|
||||||
onFieldChanged: _onFormDataChanged,
|
onFieldChanged: _onFormDataChanged,
|
||||||
|
naverUrl: state.formData.naverUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../services/restaurant_form_validator.dart';
|
import '../../../services/restaurant_form_validator.dart';
|
||||||
|
|
||||||
/// 식당 추가 폼 위젯
|
/// 식당 추가 폼 위젯
|
||||||
class AddRestaurantForm extends StatelessWidget {
|
class AddRestaurantForm extends StatefulWidget {
|
||||||
final GlobalKey<FormState> formKey;
|
final GlobalKey<FormState> formKey;
|
||||||
final TextEditingController nameController;
|
final TextEditingController nameController;
|
||||||
final TextEditingController categoryController;
|
final TextEditingController categoryController;
|
||||||
@@ -14,6 +17,10 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
final TextEditingController latitudeController;
|
final TextEditingController latitudeController;
|
||||||
final TextEditingController longitudeController;
|
final TextEditingController longitudeController;
|
||||||
final Function(String) onFieldChanged;
|
final Function(String) onFieldChanged;
|
||||||
|
final List<String> categories;
|
||||||
|
final List<String> subCategories;
|
||||||
|
final String geocodingStatus;
|
||||||
|
final TextEditingController? naverUrlController; // 네이버 지도 URL 입력
|
||||||
|
|
||||||
const AddRestaurantForm({
|
const AddRestaurantForm({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -28,18 +35,77 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
required this.latitudeController,
|
required this.latitudeController,
|
||||||
required this.longitudeController,
|
required this.longitudeController,
|
||||||
required this.onFieldChanged,
|
required this.onFieldChanged,
|
||||||
|
this.categories = const <String>[],
|
||||||
|
this.subCategories = const <String>[],
|
||||||
|
this.geocodingStatus = '',
|
||||||
|
this.naverUrlController,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Form(
|
return Form(
|
||||||
key: formKey,
|
key: widget.formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// 가게 이름
|
// 가게 이름
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: nameController,
|
controller: widget.nameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '가게 이름 *',
|
labelText: '가게 이름 *',
|
||||||
hintText: '예: 맛있는 한식당',
|
hintText: '예: 맛있는 한식당',
|
||||||
@@ -48,7 +114,7 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return '가게 이름을 입력해주세요';
|
return '가게 이름을 입력해주세요';
|
||||||
@@ -61,43 +127,16 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
// 카테고리
|
// 카테고리
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(child: _buildCategoryField(context)),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(child: _buildSubCategoryField(context)),
|
||||||
child: TextFormField(
|
|
||||||
controller: subCategoryController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: '세부 카테고리',
|
|
||||||
hintText: '예: 갈비',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onChanged: onFieldChanged,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 설명
|
// 설명
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: descriptionController,
|
controller: widget.descriptionController,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '설명',
|
labelText: '설명',
|
||||||
@@ -107,13 +146,13 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 전화번호
|
// 전화번호
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: phoneController,
|
controller: widget.phoneController,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '전화번호',
|
labelText: '전화번호',
|
||||||
@@ -123,7 +162,7 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
RestaurantFormValidator.validatePhoneNumber(value),
|
RestaurantFormValidator.validatePhoneNumber(value),
|
||||||
),
|
),
|
||||||
@@ -131,7 +170,7 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
|
|
||||||
// 도로명 주소
|
// 도로명 주소
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: roadAddressController,
|
controller: widget.roadAddressController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '도로명 주소 *',
|
labelText: '도로명 주소 *',
|
||||||
hintText: '예: 서울시 중구 세종대로 110',
|
hintText: '예: 서울시 중구 세종대로 110',
|
||||||
@@ -140,7 +179,7 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
RestaurantFormValidator.validateAddress(value),
|
RestaurantFormValidator.validateAddress(value),
|
||||||
),
|
),
|
||||||
@@ -148,7 +187,7 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
|
|
||||||
// 지번 주소
|
// 지번 주소
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: jibunAddressController,
|
controller: widget.jibunAddressController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '지번 주소',
|
labelText: '지번 주소',
|
||||||
hintText: '예: 서울시 중구 태평로1가 31',
|
hintText: '예: 서울시 중구 태평로1가 31',
|
||||||
@@ -157,80 +196,265 @@ class AddRestaurantForm extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 위도/경도 입력
|
// 네이버 지도 URL (컨트롤러가 있는 경우 항상 표시)
|
||||||
Row(
|
if (widget.naverUrlController != null)
|
||||||
children: [
|
_buildNaverUrlField(context),
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
if (widget.geocodingStatus.isNotEmpty)
|
||||||
controller: latitudeController,
|
Padding(
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
padding: const EdgeInsets.only(top: 8),
|
||||||
decimal: true,
|
child: Text(
|
||||||
),
|
widget.geocodingStatus,
|
||||||
decoration: InputDecoration(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
labelText: '위도',
|
color: Colors.blueGrey,
|
||||||
hintText: '37.5665',
|
fontWeight: FontWeight.w600,
|
||||||
prefixIcon: const Icon(Icons.explore),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onChanged: onFieldChanged,
|
|
||||||
validator: (value) {
|
|
||||||
if (value != null && value.isNotEmpty) {
|
|
||||||
final latitude = double.tryParse(value);
|
|
||||||
if (latitude == null || latitude < -90 || latitude > 90) {
|
|
||||||
return '올바른 위도값을 입력해주세요';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Expanded(
|
],
|
||||||
child: TextFormField(
|
),
|
||||||
controller: longitudeController,
|
);
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
}
|
||||||
decimal: true,
|
|
||||||
),
|
Widget _buildCategoryField(BuildContext context) {
|
||||||
decoration: InputDecoration(
|
return RawAutocomplete<String>(
|
||||||
labelText: '경도',
|
textEditingController: widget.categoryController,
|
||||||
hintText: '126.9780',
|
focusNode: _categoryFocusNode,
|
||||||
prefixIcon: const Icon(Icons.explore),
|
optionsBuilder: (TextEditingValue value) {
|
||||||
border: OutlineInputBorder(
|
final query = value.text.trim();
|
||||||
borderRadius: BorderRadius.circular(8),
|
if (query.isEmpty) {
|
||||||
),
|
return _availableCategories;
|
||||||
),
|
}
|
||||||
onChanged: onFieldChanged,
|
|
||||||
validator: (value) {
|
final lowerQuery = query.toLowerCase();
|
||||||
if (value != null && value.isNotEmpty) {
|
final matches = _availableCategories
|
||||||
final longitude = double.tryParse(value);
|
.where((c) => c.toLowerCase().contains(lowerQuery))
|
||||||
if (longitude == null ||
|
.toList();
|
||||||
longitude < -180 ||
|
|
||||||
longitude > 180) {
|
final hasExactMatch = _availableCategories.any(
|
||||||
return '올바른 경도값을 입력해주세요';
|
(c) => c.toLowerCase() == lowerQuery,
|
||||||
}
|
);
|
||||||
}
|
if (!hasExactMatch) {
|
||||||
return null;
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNaverUrlField(BuildContext context) {
|
||||||
|
final url = widget.naverUrlController!.text.trim();
|
||||||
|
final hasUrl = url.isNotEmpty && url.startsWith('http');
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: widget.naverUrlController,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '네이버 지도 링크',
|
||||||
|
hintText: '네이버 지도 공유 링크를 붙여넣으세요',
|
||||||
|
prefixIcon: const Icon(Icons.link),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
suffixIcon: hasUrl
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(Icons.open_in_new, color: Colors.blue[700]),
|
||||||
|
onPressed: () => _launchNaverUrl(url),
|
||||||
|
tooltip: '네이버 지도에서 열기',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: widget.onFieldChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
'공유 텍스트 전체를 붙여넣으면 URL만 자동 추출됩니다.',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
context,
|
color: Colors.grey,
|
||||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _launchNaverUrl(String text) async {
|
||||||
|
// URL 추출
|
||||||
|
final urlRegex = RegExp(
|
||||||
|
r'(https?://(?:map\.naver\.com|naver\.me)[^\s]+)',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final match = urlRegex.firstMatch(text);
|
||||||
|
final url = match?.group(0) ?? text;
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} catch (_) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// Row(
|
||||||
children: [
|
// children: [
|
||||||
Icon(
|
// Icon(
|
||||||
Icons.info_outline,
|
// Icons.info_outline,
|
||||||
size: 20,
|
// size: 20,
|
||||||
color: isDark
|
// color: isDark
|
||||||
? AppColors.darkPrimary
|
// ? AppColors.darkPrimary
|
||||||
: AppColors.lightPrimary,
|
// : AppColors.lightPrimary,
|
||||||
),
|
// ),
|
||||||
const SizedBox(width: 8),
|
// const SizedBox(width: 8),
|
||||||
Text(
|
// Text(
|
||||||
'네이버 지도에서 맛집 정보 가져오기',
|
// '네이버 지도에서 맛집 정보 가져오기',
|
||||||
style: AppTypography.body1(
|
// style: AppTypography.body1(
|
||||||
isDark,
|
// isDark,
|
||||||
).copyWith(fontWeight: FontWeight.bold),
|
// ).copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
// ),
|
||||||
],
|
// ],
|
||||||
),
|
// ),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
'1. 네이버 지도에서 맛집을 검색합니다\n'
|
||||||
@@ -71,14 +71,18 @@ class AddRestaurantUrlTab extends StatelessWidget {
|
|||||||
// URL 입력 필드
|
// URL 입력 필드
|
||||||
TextField(
|
TextField(
|
||||||
controller: urlController,
|
controller: urlController,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 6,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '네이버 지도 URL',
|
labelText: '네이버 지도 링크',
|
||||||
hintText: kIsWeb
|
hintText: kIsWeb
|
||||||
? 'https://map.naver.com/...'
|
? 'https://map.naver.com/...'
|
||||||
: 'https://naver.me/...',
|
: 'https://naver.me/...',
|
||||||
prefixIcon: const Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
errorText: errorMessage,
|
errorText: errorMessage,
|
||||||
|
errorMaxLines: 8,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => onFetchPressed(),
|
onSubmitted: (_) => onFetchPressed(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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;
|
||||||
|
late final TextEditingController _naverUrlController;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
_naverUrlController = TextEditingController(
|
||||||
|
text: restaurant.naverUrl ?? '',
|
||||||
|
);
|
||||||
|
_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();
|
||||||
|
_naverUrlController.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();
|
||||||
|
|
||||||
|
// URL 추출: 공유 텍스트에서 URL만 추출
|
||||||
|
final extractedUrl = _extractNaverUrl(_naverUrlController.text.trim());
|
||||||
|
|
||||||
|
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,
|
||||||
|
naverUrl: extractedUrl.isEmpty ? null : extractedUrl,
|
||||||
|
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,
|
||||||
|
naverUrlController: _naverUrlController,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 공유 텍스트에서 네이버 지도 URL만 추출
|
||||||
|
String _extractNaverUrl(String text) {
|
||||||
|
if (text.isEmpty) return '';
|
||||||
|
|
||||||
|
// URL 패턴 추출
|
||||||
|
final urlRegex = RegExp(
|
||||||
|
r'(https?://(?:map\.naver\.com|naver\.me)[^\s]+)',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final match = urlRegex.firstMatch(text);
|
||||||
|
return match?.group(0) ?? text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/app_colors.dart';
|
import '../../../../core/constants/app_colors.dart';
|
||||||
import '../../../../core/constants/app_typography.dart';
|
import '../../../../core/constants/app_typography.dart';
|
||||||
import '../../../services/restaurant_form_validator.dart';
|
import '../../../services/restaurant_form_validator.dart';
|
||||||
|
|
||||||
class FetchedRestaurantJsonView extends StatelessWidget {
|
class FetchedRestaurantJsonView extends StatefulWidget {
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
final TextEditingController nameController;
|
final TextEditingController nameController;
|
||||||
final TextEditingController categoryController;
|
final TextEditingController categoryController;
|
||||||
@@ -17,6 +18,7 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
final TextEditingController longitudeController;
|
final TextEditingController longitudeController;
|
||||||
final TextEditingController naverUrlController;
|
final TextEditingController naverUrlController;
|
||||||
final ValueChanged<String> onFieldChanged;
|
final ValueChanged<String> onFieldChanged;
|
||||||
|
final String? naverUrl; // 순수 URL (클릭 가능한 링크용)
|
||||||
|
|
||||||
const FetchedRestaurantJsonView({
|
const FetchedRestaurantJsonView({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -32,19 +34,62 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
required this.longitudeController,
|
required this.longitudeController,
|
||||||
required this.naverUrlController,
|
required this.naverUrlController,
|
||||||
required this.onFieldChanged,
|
required this.onFieldChanged,
|
||||||
|
this.naverUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FetchedRestaurantJsonView> createState() =>
|
||||||
|
_FetchedRestaurantJsonViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
|
||||||
|
late final FocusNode _categoryFocusNode;
|
||||||
|
late final FocusNode _subCategoryFocusNode;
|
||||||
|
late Set<String> _availableCategories;
|
||||||
|
late Set<String> _availableSubCategories;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_categoryFocusNode = FocusNode();
|
||||||
|
_subCategoryFocusNode = FocusNode();
|
||||||
|
_availableCategories = {
|
||||||
|
'기타',
|
||||||
|
if (widget.categoryController.text.trim().isNotEmpty)
|
||||||
|
widget.categoryController.text.trim(),
|
||||||
|
};
|
||||||
|
_availableSubCategories = {
|
||||||
|
'기타',
|
||||||
|
if (widget.subCategoryController.text.trim().isNotEmpty)
|
||||||
|
widget.subCategoryController.text.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (widget.categoryController.text.trim().isEmpty) {
|
||||||
|
widget.categoryController.text = '기타';
|
||||||
|
}
|
||||||
|
if (widget.subCategoryController.text.trim().isEmpty) {
|
||||||
|
widget.subCategoryController.text = '기타';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_categoryFocusNode.dispose();
|
||||||
|
_subCategoryFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark
|
color: widget.isDark
|
||||||
? AppColors.darkBackground
|
? AppColors.darkBackground
|
||||||
: AppColors.lightBackground.withOpacity(0.5),
|
: AppColors.lightBackground.withOpacity(0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -57,155 +102,295 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'가져온 정보',
|
'가져온 정보',
|
||||||
style: AppTypography.body1(
|
style: AppTypography.body1(
|
||||||
isDark,
|
widget.isDark,
|
||||||
).copyWith(fontWeight: FontWeight.w600),
|
).copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
|
||||||
'{',
|
|
||||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'name',
|
label: '상호',
|
||||||
controller: nameController,
|
controller: widget.nameController,
|
||||||
icon: Icons.store,
|
icon: Icons.store,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
|
||||||
),
|
),
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'category',
|
label: '도로명 주소',
|
||||||
controller: categoryController,
|
controller: widget.roadAddressController,
|
||||||
icon: Icons.category,
|
|
||||||
validator: RestaurantFormValidator.validateCategory,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'subCategory',
|
|
||||||
controller: subCategoryController,
|
|
||||||
icon: Icons.label_outline,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'description',
|
|
||||||
controller: descriptionController,
|
|
||||||
icon: Icons.description,
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'phoneNumber',
|
|
||||||
controller: phoneController,
|
|
||||||
icon: Icons.phone,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
validator: RestaurantFormValidator.validatePhoneNumber,
|
|
||||||
),
|
|
||||||
_buildJsonField(
|
|
||||||
context,
|
|
||||||
label: 'roadAddress',
|
|
||||||
controller: roadAddressController,
|
|
||||||
icon: Icons.location_on,
|
icon: Icons.location_on,
|
||||||
validator: RestaurantFormValidator.validateAddress,
|
validator: RestaurantFormValidator.validateAddress,
|
||||||
),
|
),
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'jibunAddress',
|
label: '지번 주소',
|
||||||
controller: jibunAddressController,
|
controller: widget.jibunAddressController,
|
||||||
icon: Icons.map,
|
icon: Icons.map,
|
||||||
),
|
),
|
||||||
_buildCoordinateFields(context),
|
_buildNaverUrlField(context),
|
||||||
_buildJsonField(
|
_buildJsonField(
|
||||||
context,
|
context,
|
||||||
label: 'naverUrl',
|
label: '전화번호',
|
||||||
controller: naverUrlController,
|
controller: widget.phoneController,
|
||||||
icon: Icons.link,
|
icon: Icons.phone,
|
||||||
monospace: true,
|
keyboardType: TextInputType.phone,
|
||||||
|
validator: RestaurantFormValidator.validatePhoneNumber,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
Row(
|
||||||
const Text(
|
children: [
|
||||||
'}',
|
Expanded(child: _buildCategoryField(context)),
|
||||||
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _buildSubCategoryField(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildJsonField(
|
||||||
|
context,
|
||||||
|
label: '설명',
|
||||||
|
controller: widget.descriptionController,
|
||||||
|
icon: Icons.description,
|
||||||
|
maxLines: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCoordinateFields(BuildContext context) {
|
Widget _buildNaverUrlField(BuildContext context) {
|
||||||
final border = OutlineInputBorder(borderRadius: BorderRadius.circular(8));
|
final url = widget.naverUrl ?? '';
|
||||||
|
if (url.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Column(
|
return Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
children: [
|
child: Column(
|
||||||
Row(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: const [
|
children: [
|
||||||
Icon(Icons.my_location, size: 16),
|
Row(
|
||||||
SizedBox(width: 8),
|
children: const [
|
||||||
Text('coordinates'),
|
Icon(Icons.link, size: 16),
|
||||||
],
|
SizedBox(width: 8),
|
||||||
),
|
Text('네이버 지도:'),
|
||||||
const SizedBox(height: 6),
|
],
|
||||||
Row(
|
),
|
||||||
children: [
|
const SizedBox(height: 6),
|
||||||
Expanded(
|
InkWell(
|
||||||
child: TextFormField(
|
onTap: () => _launchNaverUrl(url),
|
||||||
controller: latitudeController,
|
borderRadius: BorderRadius.circular(8),
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
child: Container(
|
||||||
decimal: true,
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: widget.isDark
|
||||||
|
? AppColors.darkDivider
|
||||||
|
: AppColors.lightDivider,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
borderRadius: BorderRadius.circular(8),
|
||||||
labelText: 'latitude',
|
color: widget.isDark
|
||||||
border: border,
|
? AppColors.darkSurface
|
||||||
isDense: true,
|
: AppColors.lightSurface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
url,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
Icons.open_in_new,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.blue[700],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchNaverUrl(String url) async {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} catch (_) {
|
||||||
|
// 웹 환경에서 실패 시 platformDefault 모드로 재시도
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
onChanged: onFieldChanged,
|
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||||
validator: (value) {
|
),
|
||||||
if (value == null || value.isEmpty) {
|
onChanged: widget.onFieldChanged,
|
||||||
return '위도를 입력해주세요';
|
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||||
}
|
validator: RestaurantFormValidator.validateCategory,
|
||||||
final latitude = double.tryParse(value);
|
);
|
||||||
if (latitude == null || latitude < -90 || latitude > 90) {
|
},
|
||||||
return '올바른 위도값을 입력해주세요';
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
}
|
return Align(
|
||||||
return null;
|
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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Expanded(
|
);
|
||||||
child: TextFormField(
|
},
|
||||||
controller: longitudeController,
|
);
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
}
|
||||||
decimal: true,
|
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||||
labelText: 'longitude',
|
),
|
||||||
border: border,
|
onChanged: widget.onFieldChanged,
|
||||||
isDense: true,
|
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||||
),
|
);
|
||||||
onChanged: onFieldChanged,
|
},
|
||||||
validator: (value) {
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
if (value == null || value.isEmpty) {
|
return Align(
|
||||||
return '경도를 입력해주세요';
|
alignment: Alignment.topLeft,
|
||||||
}
|
child: Material(
|
||||||
final longitude = double.tryParse(value);
|
elevation: 4,
|
||||||
if (longitude == null ||
|
borderRadius: BorderRadius.circular(8),
|
||||||
longitude < -180 ||
|
child: ConstrainedBox(
|
||||||
longitude > 180) {
|
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
|
||||||
return '올바른 경도값을 입력해주세요';
|
child: ListView.builder(
|
||||||
}
|
padding: EdgeInsets.zero,
|
||||||
return null;
|
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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 12),
|
},
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,17 +421,18 @@ class FetchedRestaurantJsonView extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
onChanged: onFieldChanged,
|
onChanged: widget.onFieldChanged,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
style: monospace
|
|
||||||
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
|
|
||||||
: null,
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
labelText: label,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
|
isDense: true,
|
||||||
),
|
),
|
||||||
|
style: monospace
|
||||||
|
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||||
import 'package:lunchpick/presentation/providers/restaurant_provider.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 {
|
class RestaurantCard extends ConsumerWidget {
|
||||||
final Restaurant restaurant;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final lastVisitAsync = ref.watch(lastVisitDateProvider(restaurant.id));
|
|
||||||
|
final hasNaverUrl = restaurant.naverUrl != null &&
|
||||||
|
restaurant.naverUrl!.isNotEmpty;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _showRestaurantDetail(context, isDark),
|
onTap: hasNaverUrl ? () => _openNaverUrl(restaurant.naverUrl!) : null,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -49,41 +62,94 @@ class RestaurantCard extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
restaurant.name,
|
|
||||||
style: AppTypography.heading2(isDark),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
restaurant.category,
|
child: Column(
|
||||||
style: AppTypography.body2(isDark),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
if (restaurant.subCategory !=
|
Text(
|
||||||
restaurant.category) ...[
|
restaurant.name,
|
||||||
Text(' • ', style: AppTypography.body2(isDark)),
|
style: AppTypography.heading2(isDark),
|
||||||
Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
restaurant.subCategory,
|
),
|
||||||
style: AppTypography.body2(isDark),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 더보기 버튼
|
Column(
|
||||||
IconButton(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
icon: Icon(
|
children: [
|
||||||
Icons.more_vert,
|
_BadgesRow(
|
||||||
color: isDark
|
distanceKm: distanceKm,
|
||||||
? AppColors.darkTextSecondary
|
needsAddressVerification:
|
||||||
: AppColors.lightTextSecondary,
|
restaurant.needsAddressVerification,
|
||||||
),
|
isDark: isDark,
|
||||||
onPressed: () => _showOptions(context, ref, 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 +188,26 @@ class RestaurantCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 마지막 방문일
|
// 마지막 방문일
|
||||||
lastVisitAsync.when(
|
if (lastVisitDate != null)
|
||||||
data: (lastVisit) {
|
Padding(
|
||||||
if (lastVisit != null) {
|
padding: const EdgeInsets.only(top: 8),
|
||||||
final daysSinceVisit = DateTime.now()
|
child: Row(
|
||||||
.difference(lastVisit)
|
children: [
|
||||||
.inDays;
|
Icon(
|
||||||
return Padding(
|
Icons.schedule,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
size: 16,
|
||||||
child: Row(
|
color: isDark
|
||||||
children: [
|
? AppColors.darkTextSecondary
|
||||||
Icon(
|
: AppColors.lightTextSecondary,
|
||||||
Icons.schedule,
|
|
||||||
size: 16,
|
|
||||||
color: isDark
|
|
||||||
? AppColors.darkTextSecondary
|
|
||||||
: AppColors.lightTextSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
daysSinceVisit == 0
|
|
||||||
? '오늘 방문'
|
|
||||||
: '$daysSinceVisit일 전 방문',
|
|
||||||
style: AppTypography.caption(isDark),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(width: 4),
|
||||||
}
|
Text(
|
||||||
return const SizedBox.shrink();
|
_formatLastVisit(lastVisitDate!),
|
||||||
},
|
style: AppTypography.caption(isDark),
|
||||||
loading: () => const SizedBox.shrink(),
|
),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -162,6 +215,18 @@ class RestaurantCard extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openNaverUrl(String url) async {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} catch (_) {
|
||||||
|
// 웹 환경에서 실패 시 platformDefault 모드로 재시도
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconData _getCategoryIcon(String category) {
|
IconData _getCategoryIcon(String category) {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case '한식':
|
case '한식':
|
||||||
@@ -185,130 +250,177 @@ class RestaurantCard extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRestaurantDetail(BuildContext context, bool isDark) {
|
String _formatLastVisit(DateTime date) {
|
||||||
showDialog(
|
final daysSinceVisit = DateTime.now().difference(date).inDays;
|
||||||
context: context,
|
return daysSinceVisit == 0 ? '오늘 방문' : '$daysSinceVisit일 전 방문';
|
||||||
builder: (context) => AlertDialog(
|
}
|
||||||
backgroundColor: isDark
|
|
||||||
? AppColors.darkSurface
|
void _handleMenuAction(
|
||||||
: AppColors.lightSurface,
|
_RestaurantMenuAction action,
|
||||||
title: Text(restaurant.name),
|
BuildContext context,
|
||||||
content: Column(
|
WidgetRef ref,
|
||||||
mainAxisSize: MainAxisSize.min,
|
) async {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
switch (action) {
|
||||||
children: [
|
case _RestaurantMenuAction.edit:
|
||||||
_buildDetailRow(
|
await showDialog<bool>(
|
||||||
'카테고리',
|
context: context,
|
||||||
'${restaurant.category} > ${restaurant.subCategory}',
|
builder: (context) => EditRestaurantDialog(restaurant: restaurant),
|
||||||
isDark,
|
);
|
||||||
),
|
break;
|
||||||
if (restaurant.description != null)
|
case _RestaurantMenuAction.delete:
|
||||||
_buildDetailRow('설명', restaurant.description!, isDark),
|
final confirmed = await showDialog<bool>(
|
||||||
if (restaurant.phoneNumber != null)
|
context: context,
|
||||||
_buildDetailRow('전화번호', restaurant.phoneNumber!, isDark),
|
builder: (context) => AlertDialog(
|
||||||
_buildDetailRow('도로명 주소', restaurant.roadAddress, isDark),
|
title: const Text('맛집 삭제'),
|
||||||
_buildDetailRow('지번 주소', restaurant.jibunAddress, isDark),
|
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
|
||||||
if (restaurant.lastVisitDate != null)
|
actions: [
|
||||||
_buildDetailRow(
|
TextButton(
|
||||||
'마지막 방문',
|
onPressed: () => Navigator.pop(context, false),
|
||||||
'${restaurant.lastVisitDate!.year}년 ${restaurant.lastVisitDate!.month}월 ${restaurant.lastVisitDate!.day}일',
|
child: const Text('취소'),
|
||||||
isDark,
|
|
||||||
),
|
),
|
||||||
],
|
TextButton(
|
||||||
),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
actions: [
|
child: const Text(
|
||||||
TextButton(
|
'삭제',
|
||||||
onPressed: () => Navigator.pop(context),
|
style: TextStyle(color: AppColors.lightError),
|
||||||
child: const Text('닫기'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(String label, String value, bool isDark) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label, style: AppTypography.caption(isDark)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(value, style: AppTypography.body2(isDark)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true) {
|
|
||||||
await ref
|
|
||||||
.read(restaurantNotifierProvider.notifier)
|
|
||||||
.deleteRestaurant(restaurant.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await ref
|
||||||
|
.read(restaurantNotifierProvider.notifier)
|
||||||
|
.deleteRestaurant(restaurant.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DistanceBadge extends StatelessWidget {
|
||||||
|
final double distanceKm;
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
const _DistanceBadge({required this.distanceKm, required this.isDark});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final text = _formatDistance(distanceKm);
|
||||||
|
final isFar = distanceKm * 1000 >= 2000;
|
||||||
|
final Color bgColor;
|
||||||
|
final Color textColor;
|
||||||
|
|
||||||
|
if (isFar) {
|
||||||
|
bgColor = isDark
|
||||||
|
? AppColors.darkError.withOpacity(0.15)
|
||||||
|
: AppColors.lightError.withOpacity(0.15);
|
||||||
|
textColor = AppColors.lightError;
|
||||||
|
} else {
|
||||||
|
bgColor = isDark
|
||||||
|
? AppColors.darkPrimary.withOpacity(0.12)
|
||||||
|
: AppColors.lightPrimary.withOpacity(0.12);
|
||||||
|
textColor = AppColors.lightPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.place, size: 16, color: textColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: AppTypography.caption(
|
||||||
|
isDark,
|
||||||
|
).copyWith(color: textColor, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDistance(double distanceKm) {
|
||||||
|
final meters = distanceKm * 1000;
|
||||||
|
if (meters >= 2000) {
|
||||||
|
return '2.0km 이상';
|
||||||
|
}
|
||||||
|
if (meters >= 1000) {
|
||||||
|
return '${distanceKm.toStringAsFixed(1)}km';
|
||||||
|
}
|
||||||
|
return '${meters.round()}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _RestaurantMenuAction { edit, delete }
|
||||||
|
|
||||||
|
class _BadgesRow extends StatelessWidget {
|
||||||
|
final double? distanceKm;
|
||||||
|
final bool needsAddressVerification;
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
const _BadgesRow({
|
||||||
|
required this.distanceKm,
|
||||||
|
required this.needsAddressVerification,
|
||||||
|
required this.isDark,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final badges = <Widget>[];
|
||||||
|
if (needsAddressVerification) {
|
||||||
|
badges.add(_AddressVerificationChip(isDark: isDark));
|
||||||
|
}
|
||||||
|
if (distanceKm != null) {
|
||||||
|
badges.add(_DistanceBadge(distanceKm: distanceKm!, isDark: isDark));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badges.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
children: badges,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressVerificationChip extends StatelessWidget {
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
const _AddressVerificationChip({required this.isDark});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bgColor = isDark
|
||||||
|
? AppColors.darkError.withOpacity(0.12)
|
||||||
|
: AppColors.lightError.withOpacity(0.12);
|
||||||
|
final textColor = isDark ? AppColors.darkError : AppColors.lightError;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 16, color: textColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'주소확인',
|
||||||
|
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import 'package:adaptive_theme/adaptive_theme.dart';
|
|||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
|
import '../../providers/debug_share_preview_provider.dart';
|
||||||
|
import '../../providers/debug_test_data_provider.dart';
|
||||||
import '../../providers/settings_provider.dart';
|
import '../../providers/settings_provider.dart';
|
||||||
|
import '../../providers/notification_provider.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerStatefulWidget {
|
class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -23,6 +26,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
|
if (kDebugMode) {
|
||||||
|
Future.microtask(
|
||||||
|
() => ref.read(debugTestDataNotifierProvider.notifier).initialize(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
@@ -46,6 +54,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -72,49 +84,52 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: const Text('중복 방문 제외 기간'),
|
title: const Text('중복 방문 제외 기간'),
|
||||||
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
|
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
|
||||||
trailing: Row(
|
trailing: FittedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
child: Row(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(Icons.remove_circle_outline),
|
children: [
|
||||||
onPressed: _daysToExclude > 1
|
IconButton(
|
||||||
? () async {
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
setState(() => _daysToExclude--);
|
onPressed: _daysToExclude > 1
|
||||||
await ref
|
? () async {
|
||||||
.read(settingsNotifierProvider.notifier)
|
setState(() => _daysToExclude--);
|
||||||
.setDaysToExclude(_daysToExclude);
|
await ref
|
||||||
}
|
.read(settingsNotifierProvider.notifier)
|
||||||
: null,
|
.setDaysToExclude(_daysToExclude);
|
||||||
color: AppColors.lightPrimary,
|
}
|
||||||
),
|
: null,
|
||||||
Container(
|
color: AppColors.lightPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
padding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(8),
|
horizontal: 12,
|
||||||
),
|
vertical: 4,
|
||||||
child: Text(
|
),
|
||||||
'$_daysToExclude일',
|
decoration: BoxDecoration(
|
||||||
style: const TextStyle(
|
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||||
fontWeight: FontWeight.bold,
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: AppColors.lightPrimary,
|
),
|
||||||
|
child: Text(
|
||||||
|
'$_daysToExclude일',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.lightPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
onPressed: () async {
|
||||||
onPressed: () async {
|
setState(() => _daysToExclude++);
|
||||||
setState(() => _daysToExclude++);
|
await ref
|
||||||
await ref
|
.read(settingsNotifierProvider.notifier)
|
||||||
.read(settingsNotifierProvider.notifier)
|
.setDaysToExclude(_daysToExclude);
|
||||||
.setDaysToExclude(_daysToExclude);
|
},
|
||||||
},
|
color: AppColors.lightPrimary,
|
||||||
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),
|
], isDark),
|
||||||
|
|
||||||
// 알림 설정
|
// 알림 설정
|
||||||
@@ -204,59 +242,62 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
enabled: _notificationEnabled,
|
enabled: _notificationEnabled,
|
||||||
title: const Text('방문 확인 알림 시간'),
|
title: const Text('방문 확인 알림 시간'),
|
||||||
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
|
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
|
||||||
trailing: Row(
|
trailing: FittedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
child: Row(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(Icons.remove_circle_outline),
|
children: [
|
||||||
onPressed:
|
IconButton(
|
||||||
_notificationEnabled && _notificationMinutes > 60
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
? () async {
|
onPressed:
|
||||||
setState(() => _notificationMinutes -= 30);
|
_notificationEnabled && _notificationMinutes > 60
|
||||||
await ref
|
? () async {
|
||||||
.read(settingsNotifierProvider.notifier)
|
setState(() => _notificationMinutes -= 30);
|
||||||
.setNotificationDelayMinutes(
|
await ref
|
||||||
_notificationMinutes,
|
.read(settingsNotifierProvider.notifier)
|
||||||
);
|
.setNotificationDelayMinutes(
|
||||||
}
|
_notificationMinutes,
|
||||||
: null,
|
);
|
||||||
color: AppColors.lightPrimary,
|
}
|
||||||
),
|
: null,
|
||||||
Container(
|
color: AppColors.lightPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
padding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(8),
|
horizontal: 12,
|
||||||
),
|
vertical: 4,
|
||||||
child: Text(
|
),
|
||||||
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}분',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||||
fontWeight: FontWeight.bold,
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: _notificationEnabled
|
),
|
||||||
? AppColors.lightPrimary
|
child: Text(
|
||||||
: Colors.grey,
|
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}분',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _notificationEnabled
|
||||||
|
? AppColors.lightPrimary
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
onPressed:
|
||||||
onPressed:
|
_notificationEnabled && _notificationMinutes < 360
|
||||||
_notificationEnabled && _notificationMinutes < 360
|
? () async {
|
||||||
? () async {
|
setState(() => _notificationMinutes += 30);
|
||||||
setState(() => _notificationMinutes += 30);
|
await ref
|
||||||
await ref
|
.read(settingsNotifierProvider.notifier)
|
||||||
.read(settingsNotifierProvider.notifier)
|
.setNotificationDelayMinutes(
|
||||||
.setNotificationDelayMinutes(
|
_notificationMinutes,
|
||||||
_notificationMinutes,
|
);
|
||||||
);
|
}
|
||||||
}
|
: null,
|
||||||
: null,
|
color: AppColors.lightPrimary,
|
||||||
color: AppColors.lightPrimary,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -310,33 +351,33 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
title: Text('버전'),
|
title: Text('버전'),
|
||||||
subtitle: Text('1.0.0'),
|
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),
|
], isDark),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
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) {
|
void _showPermissionDialog(String permissionName) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lunchpick/core/constants/app_colors.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/domain/entities/share_device.dart';
|
||||||
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
import 'package:lunchpick/presentation/providers/ad_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
|
||||||
|
import 'package:lunchpick/presentation/providers/debug_share_preview_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/restaurant_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';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class ShareScreen extends ConsumerStatefulWidget {
|
class ShareScreen extends ConsumerStatefulWidget {
|
||||||
@@ -21,12 +25,72 @@ class ShareScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<ShareScreen> createState() => _ShareScreenState();
|
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> {
|
class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||||
String? _shareCode;
|
String? _shareCode;
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
List<ShareDevice>? _nearbyDevices;
|
List<ShareDevice>? _nearbyDevices;
|
||||||
StreamSubscription<String>? _dataSubscription;
|
StreamSubscription<String>? _dataSubscription;
|
||||||
|
ProviderSubscription<bool>? _debugPreviewSub;
|
||||||
final _uuid = const Uuid();
|
final _uuid = const Uuid();
|
||||||
|
bool _debugPreviewEnabled = false;
|
||||||
|
Timer? _debugPreviewTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -35,18 +99,36 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
_dataSubscription = bluetoothService.onDataReceived.listen((payload) {
|
_dataSubscription = bluetoothService.onDataReceived.listen((payload) {
|
||||||
_handleIncomingData(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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_dataSubscription?.cancel();
|
_dataSubscription?.cancel();
|
||||||
|
_debugPreviewSub?.close();
|
||||||
ref.read(bluetoothServiceProvider).stopListening();
|
ref.read(bluetoothServiceProvider).stopListening();
|
||||||
|
_debugPreviewTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final screenshotModeEnabled = ref
|
||||||
|
.watch(screenshotModeEnabledProvider)
|
||||||
|
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -62,254 +144,237 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Center(
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
// 공유받기 섹션
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
Card(
|
child: Column(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
elevation: 2,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
shape: RoundedRectangleBorder(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(16),
|
_ShareCard(
|
||||||
),
|
isDark: isDark,
|
||||||
child: Padding(
|
icon: Icons.upload_rounded,
|
||||||
padding: const EdgeInsets.all(24),
|
iconColor: AppColors.lightSecondary,
|
||||||
child: Column(
|
iconBgColor: AppColors.lightSecondary.withOpacity(0.1),
|
||||||
children: [
|
title: '내 리스트 공유하기',
|
||||||
Container(
|
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
|
||||||
padding: const EdgeInsets.all(16),
|
child: _buildSendSection(isDark),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
NativeAdPlaceholder(
|
||||||
|
height: 360,
|
||||||
const SizedBox(height: 16),
|
enabled: !screenshotModeEnabled,
|
||||||
|
|
||||||
// 공유하기 섹션
|
|
||||||
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),
|
||||||
|
_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 {
|
Future<void> _generateShareCode() async {
|
||||||
final adService = ref.read(adServiceProvider);
|
final adWatched = await ref
|
||||||
final adWatched = await adService.showInterstitialAd(context);
|
.read(adServiceProvider)
|
||||||
|
.showInterstitialAd(context);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!adWatched) {
|
if (!adWatched) {
|
||||||
_showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.');
|
_showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.');
|
||||||
return;
|
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 random = Random();
|
||||||
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
||||||
|
|
||||||
setState(() {
|
try {
|
||||||
_shareCode = code;
|
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||||
});
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
_shareCode = code;
|
||||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
});
|
||||||
|
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scanDevices() async {
|
Future<void> _scanDevices() async {
|
||||||
|
if (kDebugMode && _debugPreviewEnabled) {
|
||||||
|
setState(() {
|
||||||
|
_isScanning = true;
|
||||||
|
_nearbyDevices = _buildDebugDevices();
|
||||||
|
});
|
||||||
|
_scheduleDebugReceive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final hasPermission =
|
final hasPermission =
|
||||||
await PermissionService.checkAndRequestBluetoothPermission();
|
await PermissionService.checkAndRequestBluetoothPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
@@ -341,6 +406,28 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendList(String targetCode) async {
|
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);
|
final restaurants = await ref.read(restaurantListProvider.future);
|
||||||
if (!mounted) return;
|
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;
|
if (!mounted) return;
|
||||||
final adWatched = await ref
|
if (!skipAd) {
|
||||||
.read(adServiceProvider)
|
final adWatched = await ref
|
||||||
.showInterstitialAd(context);
|
.read(adServiceProvider)
|
||||||
if (!mounted) return;
|
.showInterstitialAd(context);
|
||||||
if (!adWatched) {
|
if (!mounted) return;
|
||||||
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
if (!adWatched) {
|
||||||
return;
|
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -502,4 +594,158 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
SnackBar(content: Text(message), backgroundColor: AppColors.lightError),
|
SnackBar(content: Text(message), backgroundColor: AppColors.lightError),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDebugToggleChange(bool enabled) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_debugPreviewEnabled = enabled;
|
||||||
|
});
|
||||||
|
if (enabled) {
|
||||||
|
await _startDebugPreviewFlow();
|
||||||
|
} else {
|
||||||
|
_stopDebugPreviewFlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startDebugPreviewFlow() async {
|
||||||
|
_debugPreviewTimer?.cancel();
|
||||||
|
final code = _buildDebugShareCode();
|
||||||
|
setState(() {
|
||||||
|
_shareCode = code;
|
||||||
|
_isScanning = true;
|
||||||
|
_nearbyDevices = _buildDebugDevices();
|
||||||
|
});
|
||||||
|
_scheduleDebugReceive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopDebugPreviewFlow() {
|
||||||
|
_debugPreviewTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_shareCode = null;
|
||||||
|
_isScanning = false;
|
||||||
|
_nearbyDevices = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleDebugReceive() {
|
||||||
|
_debugPreviewTimer?.cancel();
|
||||||
|
_debugPreviewTimer = Timer(const Duration(seconds: 1), () {
|
||||||
|
if (!mounted || !_debugPreviewEnabled) return;
|
||||||
|
final payload = _buildDebugPayload();
|
||||||
|
_handleIncomingData(payload, skipAd: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildDebugShareCode() => 'DBG${Random().nextInt(900000) + 100000}';
|
||||||
|
|
||||||
|
List<ShareDevice> _buildDebugDevices() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return [
|
||||||
|
ShareDevice(code: 'DBG-ALPHA', deviceId: 'LP-DEBUG-1', discoveredAt: now),
|
||||||
|
ShareDevice(
|
||||||
|
code: 'DBG-BETA',
|
||||||
|
deviceId: 'LP-DEBUG-2',
|
||||||
|
discoveredAt: now.subtract(const Duration(seconds: 10)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildDebugPayload() {
|
||||||
|
final samples = _buildDebugRestaurants();
|
||||||
|
final list = samples
|
||||||
|
.map(
|
||||||
|
(restaurant) => {
|
||||||
|
'id': restaurant.id,
|
||||||
|
'name': restaurant.name,
|
||||||
|
'category': restaurant.category,
|
||||||
|
'subCategory': restaurant.subCategory,
|
||||||
|
'description': restaurant.description,
|
||||||
|
'phoneNumber': restaurant.phoneNumber,
|
||||||
|
'roadAddress': restaurant.roadAddress,
|
||||||
|
'jibunAddress': restaurant.jibunAddress,
|
||||||
|
'latitude': restaurant.latitude,
|
||||||
|
'longitude': restaurant.longitude,
|
||||||
|
'lastVisitDate': restaurant.lastVisitDate?.toIso8601String(),
|
||||||
|
'source': restaurant.source.name,
|
||||||
|
'createdAt': restaurant.createdAt.toIso8601String(),
|
||||||
|
'updatedAt': restaurant.updatedAt.toIso8601String(),
|
||||||
|
'naverPlaceId': restaurant.naverPlaceId,
|
||||||
|
'naverUrl': restaurant.naverUrl,
|
||||||
|
'businessHours': restaurant.businessHours,
|
||||||
|
'lastVisited': restaurant.lastVisited?.toIso8601String(),
|
||||||
|
'visitCount': restaurant.visitCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return jsonEncode(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Restaurant> _buildDebugRestaurants() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return [
|
||||||
|
Restaurant(
|
||||||
|
id: 'debug-share-ramen',
|
||||||
|
name: '디버그 라멘바',
|
||||||
|
category: 'Japanese',
|
||||||
|
subCategory: 'Ramen',
|
||||||
|
description: '테스트용 라멘 바. 실제 전송 없이 미리보기 용도입니다.',
|
||||||
|
phoneNumber: '02-111-1111',
|
||||||
|
roadAddress: '서울 특별시 테스트로 1',
|
||||||
|
jibunAddress: '서울 테스트동 1-1',
|
||||||
|
latitude: 37.566,
|
||||||
|
longitude: 126.9784,
|
||||||
|
lastVisitDate: now.subtract(const Duration(days: 2)),
|
||||||
|
source: DataSource.PRESET,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
naverPlaceId: null,
|
||||||
|
naverUrl: null,
|
||||||
|
businessHours: '11:00 - 21:00',
|
||||||
|
lastVisited: now.subtract(const Duration(days: 2)),
|
||||||
|
visitCount: 3,
|
||||||
|
),
|
||||||
|
Restaurant(
|
||||||
|
id: 'debug-share-burger',
|
||||||
|
name: '샘플 버거샵',
|
||||||
|
category: 'Fastfood',
|
||||||
|
subCategory: 'Burger',
|
||||||
|
description: '광고·권한 없이 교환 흐름을 확인하는 샘플 버거 가게.',
|
||||||
|
phoneNumber: '02-222-2222',
|
||||||
|
roadAddress: '서울 특별시 디버그길 22',
|
||||||
|
jibunAddress: '서울 디버그동 22-2',
|
||||||
|
latitude: 37.57,
|
||||||
|
longitude: 126.982,
|
||||||
|
lastVisitDate: now.subtract(const Duration(days: 5)),
|
||||||
|
source: DataSource.PRESET,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
naverPlaceId: null,
|
||||||
|
naverUrl: null,
|
||||||
|
businessHours: '10:00 - 23:00',
|
||||||
|
lastVisited: now.subtract(const Duration(days: 5)),
|
||||||
|
visitCount: 1,
|
||||||
|
),
|
||||||
|
Restaurant(
|
||||||
|
id: 'debug-share-brunch',
|
||||||
|
name: '프리뷰 브런치 카페',
|
||||||
|
category: 'Cafe',
|
||||||
|
subCategory: 'Brunch',
|
||||||
|
description: '리스트 공유 수신 UI를 확인하기 위한 브런치 카페 샘플.',
|
||||||
|
phoneNumber: '02-333-3333',
|
||||||
|
roadAddress: '서울 특별시 미리보기로 33',
|
||||||
|
jibunAddress: '서울 미리보기동 33-3',
|
||||||
|
latitude: 37.561,
|
||||||
|
longitude: 126.99,
|
||||||
|
lastVisitDate: now.subtract(const Duration(days: 1)),
|
||||||
|
source: DataSource.PRESET,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
naverPlaceId: null,
|
||||||
|
naverUrl: null,
|
||||||
|
businessHours: '09:00 - 18:00',
|
||||||
|
lastVisited: now.subtract(const Duration(days: 1)),
|
||||||
|
visitCount: 4,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../core/services/permission_service.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
@@ -13,10 +15,10 @@ class SplashScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen>
|
class _SplashScreenState extends State<SplashScreen>
|
||||||
with TickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late List<AnimationController> _foodControllers;
|
late AnimationController _animationController;
|
||||||
late AnimationController _questionMarkController;
|
List<Offset>? _iconPositions;
|
||||||
late AnimationController _centerIconController;
|
Size? _lastScreenSize;
|
||||||
|
|
||||||
final List<IconData> foodIcons = [
|
final List<IconData> foodIcons = [
|
||||||
Icons.rice_bowl,
|
Icons.rice_bowl,
|
||||||
@@ -38,28 +40,71 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initializeAnimations() {
|
void _initializeAnimations() {
|
||||||
// 음식 아이콘 애니메이션 (여러 개)
|
// 단일 컨트롤러로 모든 애니메이션 제어 (메모리 최적화)
|
||||||
_foodControllers = List.generate(
|
_animationController = AnimationController(
|
||||||
foodIcons.length,
|
duration: const Duration(seconds: 2),
|
||||||
(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),
|
|
||||||
vsync: this,
|
vsync: this,
|
||||||
)..repeat(reverse: true);
|
)..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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
@@ -80,9 +125,9 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
children: [
|
children: [
|
||||||
// 선택 아이콘
|
// 선택 아이콘
|
||||||
ScaleTransition(
|
ScaleTransition(
|
||||||
scale: Tween(begin: 0.8, end: 1.2).animate(
|
scale: Tween(begin: 0.9, end: 1.1).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
parent: _centerIconController,
|
parent: _animationController,
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -102,11 +147,11 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
children: [
|
children: [
|
||||||
Text('오늘 뭐 먹Z', style: AppTypography.heading1(isDark)),
|
Text('오늘 뭐 먹Z', style: AppTypography.heading1(isDark)),
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _questionMarkController,
|
animation: _animationController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final questionMarks =
|
final questionMarks =
|
||||||
'?' *
|
'?' *
|
||||||
(((_questionMarkController.value * 3).floor() % 3) +
|
(((_animationController.value * 3).floor() % 3) +
|
||||||
1);
|
1);
|
||||||
return Text(
|
return Text(
|
||||||
questionMarks,
|
questionMarks,
|
||||||
@@ -143,34 +188,42 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildFoodIcons() {
|
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) {
|
return List.generate(foodIcons.length, (index) {
|
||||||
final left = random.nextDouble() * 0.8 + 0.1;
|
final position = _iconPositions![index];
|
||||||
final top = random.nextDouble() * 0.7 + 0.1;
|
// 각 아이콘마다 위상(phase)을 다르게 적용
|
||||||
|
final phase = index / foodIcons.length;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: MediaQuery.of(context).size.width * left,
|
left: position.dx,
|
||||||
top: MediaQuery.of(context).size.height * top,
|
top: position.dy,
|
||||||
child: FadeTransition(
|
child: AnimatedBuilder(
|
||||||
opacity: Tween(begin: 0.2, end: 0.8).animate(
|
animation: _animationController,
|
||||||
CurvedAnimation(
|
builder: (context, child) {
|
||||||
parent: _foodControllers[index],
|
// 위상 차이로 각 아이콘이 다른 타이밍에 애니메이션
|
||||||
curve: Curves.easeInOut,
|
final value =
|
||||||
),
|
((_animationController.value + phase) % 1.0 - 0.5).abs() * 2;
|
||||||
),
|
return Opacity(
|
||||||
child: ScaleTransition(
|
opacity: 0.2 + value * 0.4,
|
||||||
scale: Tween(begin: 0.5, end: 1.5).animate(
|
child: Transform.scale(
|
||||||
CurvedAnimation(
|
scale: 0.7 + value * 0.5,
|
||||||
parent: _foodControllers[index],
|
child: child,
|
||||||
curve: Curves.easeInOut,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
child: Icon(
|
},
|
||||||
foodIcons[index],
|
child: Icon(
|
||||||
size: 40,
|
foodIcons[index],
|
||||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
size: 40,
|
||||||
),
|
color: AppColors.lightPrimary.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -178,20 +231,34 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToHome() {
|
void _navigateToHome() {
|
||||||
Future.delayed(AppConstants.splashAnimationDuration, () {
|
// 권한 요청이 지연되어도 스플래시(Splash) 화면이 멈추지 않도록 최대 3초만 대기한다.
|
||||||
if (mounted) {
|
final permissionFuture = _ensurePermissions().timeout(
|
||||||
context.go('/home');
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
for (final controller in _foodControllers) {
|
_animationController.dispose();
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
_questionMarkController.dispose();
|
|
||||||
_centerIconController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||