diff --git a/.github/workflows/PROJECT-FLUTTER-CI.yaml b/.github/workflows/PROJECT-FLUTTER-CI.yaml index f279c15..90fa68b 100644 --- a/.github/workflows/PROJECT-FLUTTER-CI.yaml +++ b/.github/workflows/PROJECT-FLUTTER-CI.yaml @@ -69,7 +69,7 @@ on: default: true permissions: - contents: read + contents: write pull-requests: write # ============================================ @@ -283,6 +283,78 @@ jobs: flutter pub get echo "✅ Dependencies installed" + # 코드 생성 (Riverpod, Freezed, Retrofit 등) + - name: Run build_runner + run: | + echo "🔧 코드 생성 시작 (build_runner)..." + dart run build_runner build --delete-conflicting-outputs + echo "✅ 코드 생성 완료" + + # 생성된 코드 파일을 현재 브랜치에 커밋 + # - PR 이벤트: PR 소스 브랜치에 커밋 (로컬에서 pull 가능) + # - Push 이벤트: 푸시된 브랜치에 커밋 + # - [skip ci]로 무한 루프 방지, 최대 5회 재시도로 race condition 방지 + - name: Commit generated files + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # 현재 이벤트에 따라 대상 브랜치 결정 + if [ "${{ github.event_name }}" == "pull_request" ]; then + TARGET_BRANCH="${{ github.head_ref }}" + echo "📌 PR 이벤트: 소스 브랜치 '$TARGET_BRANCH'에 커밋" + # PR 이벤트에서는 merge ref(refs/pull/N/merge)를 checkout하므로 + # 실제 소스 브랜치로 전환 후 build_runner 재실행 필요 + git fetch origin "$TARGET_BRANCH" + git checkout "$TARGET_BRANCH" + flutter pub get + dart run build_runner build --delete-conflicting-outputs + else + TARGET_BRANCH="${{ github.ref_name }}" + echo "📌 Push 이벤트: '$TARGET_BRANCH' 브랜치에 커밋" + fi + + # 생성 파일 스테이징 + git add "*.g.dart" "*.freezed.dart" + + if git diff --cached --quiet; then + echo "ℹ️ 생성 파일 변경 없음, 커밋 스킵" + exit 0 + fi + + git commit -m "chore: update generated files (build_runner) [skip ci]" + + # Race Condition 방지: pull-rebase 후 push (최대 5회 재시도) + MAX_RETRIES=5 + RETRY_COUNT=0 + PUSH_SUCCESS=false + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "🔄 Push 시도 $RETRY_COUNT/$MAX_RETRIES..." + + if git push origin HEAD:"$TARGET_BRANCH"; then + PUSH_SUCCESS=true + echo "✅ 생성 파일 커밋 완료 ($TARGET_BRANCH)" + break + else + echo "⚠️ Push 실패, remote 변경사항 동기화 중..." + + if git pull --rebase origin "$TARGET_BRANCH"; then + echo "✅ Rebase 성공, 다시 push 시도..." + else + echo "❌ Rebase 실패, 충돌 해결 필요" + git rebase --abort 2>/dev/null || true + echo "⚠️ 생성 파일 커밋을 건너뜁니다 (수동 실행 필요)" + exit 0 + fi + fi + done + + if [ "$PUSH_SUCCESS" = false ]; then + echo "⚠️ $MAX_RETRIES회 시도 후에도 push 실패, 생성 파일 커밋을 건너뜁니다" + fi + # Flutter Analyze 실행 - name: Run Flutter Analyze id: analyze @@ -382,6 +454,13 @@ jobs: flutter pub get echo "✅ Dependencies installed" + # 코드 생성 (Riverpod, Freezed, Retrofit 등) + - name: Run build_runner + run: | + echo "🔧 코드 생성 시작 (build_runner)..." + dart run build_runner build --delete-conflicting-outputs + echo "✅ 코드 생성 완료" + # Java 설정 - name: Set up Java uses: actions/setup-java@v4 @@ -493,6 +572,13 @@ jobs: flutter pub get echo "✅ Dependencies installed" + # 코드 생성 (Riverpod, Freezed, Retrofit 등) + - name: Run build_runner + run: | + echo "🔧 코드 생성 시작 (build_runner)..." + dart run build_runner build --delete-conflicting-outputs + echo "✅ 코드 생성 완료" + # Ruby 및 CocoaPods 설정 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/CHANGELOG.json b/CHANGELOG.json index 33571dc..d4f6464 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,35 @@ { "metadata": { - "lastUpdated": "2026-02-10T22:24:23Z", - "currentVersion": "1.0.32", + "lastUpdated": "2026-02-23T01:03:56Z", + "currentVersion": "1.0.39", "projectType": "flutter", - "totalReleases": 9 + "totalReleases": 10 }, "releases": [ + { + "version": "1.0.39", + "project_type": "flutter", + "date": "2026-02-23", + "pr_number": 35, + "raw_summary": "## Summary by CodeRabbit\n\n## Release Notes\n\n* **New Features**\n * Home feed 리디자인 - 밝은 미니멀 테마와 탭 기반 네비게이션 적용\n * 개선된 플레이스 카드 - 이미지, 태그, 주소 표시 및 페이지네이션 지원\n * 로딩, 빈 상태, 오류 상태 UI 재설계\n\n* **Chores**\n * 앱 버전 v1.0.39로 업데이트", + "parsed_changes": { + "new_features": { + "title": "New Features", + "items": [ + "Home feed 리디자인 - 밝은 미니멀 테마와 탭 기반 네비게이션 적용", + "개선된 플레이스 카드 - 이미지, 태그, 주소 표시 및 페이지네이션 지원", + "로딩, 빈 상태, 오류 상태 UI 재설계" + ] + }, + "chores": { + "title": "Chores", + "items": [ + "앱 버전 v1.0.39로 업데이트" + ] + } + }, + "parse_method": "markdown" + }, { "version": "1.0.32", "project_type": "flutter", diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc7d01..5f8bbe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog -**현재 버전:** 1.0.32 -**마지막 업데이트:** 2026-02-10T22:24:23Z +**현재 버전:** 1.0.39 +**마지막 업데이트:** 2026-02-23T01:03:56Z + +--- + +## [1.0.39] - 2026-02-23 + +**PR:** #35 + +**New Features** +- Home feed 리디자인 - 밝은 미니멀 테마와 탭 기반 네비게이션 적용 +- 개선된 플레이스 카드 - 이미지, 태그, 주소 표시 및 페이지네이션 지원 +- 로딩, 빈 상태, 오류 상태 UI 재설계 + +**Chores** +- 앱 버전 v1.0.39로 업데이트 --- diff --git a/README.md b/README.md index c5ea66e..5bed247 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,6 @@ samples, guidance on mobile development, and a full API reference. --- -## 최신 버전 : v1.0.31 (2026-02-10) +## 최신 버전 : v1.0.32 (2026-02-10) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md b/SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md deleted file mode 100644 index 0b29c18..0000000 --- a/SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md +++ /dev/null @@ -1,309 +0,0 @@ -# 🚀 SUH-DEVOPS-TEMPLATE 빠른 시작 가이드 - -> **3가지만 하면 끝!** 나머지는 모두 자동화됩니다. - -이 템플릿은 GitHub 템플릿 또는 원격 스크립트로 설치하면 **자동으로 초기화**됩니다. -사용자는 단 **3가지 설정**만 하면 모든 자동화가 작동합니다. - ---- - -## 🎯 3가지 필수 작업 - -### 1️⃣ GitHub Personal Access Token 설정 - -#### 토큰 생성 -1. **GitHub** → **Settings** → **Developer settings** → **Personal access tokens (Classic)** -2. **Generate new token (classic)** 클릭 -3. 토큰 설정: - - **Name**: `_GITHUB_PAT_TOKEN` - - **Expiration**: 90 days (또는 조직 정책에 따라) - - **Scopes**: ✅ `repo` (Full control), ✅ `workflow` (Update workflows) -4. **Generate token** 클릭 후 토큰 복사 - -#### Secret 등록 -1. **프로젝트 저장소** → **Settings** → **Secrets and variables** → **Actions** -2. **New repository secret** 클릭 -3. **Name**: `_GITHUB_PAT_TOKEN` -4. **Secret**: [위에서 복사한 토큰 값 붙여넣기] -5. **Add secret** 클릭 - ---- - -### 2️⃣ deploy 브랜치 생성 - -```bash -# deploy 브랜치 생성 및 푸시 -git checkout -b deploy -git push -u origin deploy - -# main 브랜치로 돌아가기 -git checkout main -``` - -**설명**: `deploy` 브랜치는 체인지로그 자동 생성 및 배포 워크플로우가 실행되는 브랜치입니다. - ---- - -### 3️⃣ CodeRabbit 활성화 - -1. [CodeRabbit 웹사이트](https://coderabbit.ai) 접속 -2. GitHub 계정으로 로그인 -3. 저장소 목록에서 프로젝트 선택하여 활성화 -4. `.coderabbit.yaml` 파일이 프로젝트에 있으면 자동으로 설정 적용됨 - -**설명**: CodeRabbit은 AI 기반 코드 리뷰 및 체인지로그 자동 생성을 담당합니다. - ---- - -## 🎉 완료! - -**이제 코드를 푸시하면 모든 자동화가 작동합니다.** - -- ✅ 버전 자동 증가 (1.0.0 → 1.0.1) -- ✅ README 버전 자동 업데이트 -- ✅ Git 태그 자동 생성 -- ✅ 체인지로그 자동 생성 - ---- - -## ✨ 자동으로 처리되는 것들 - -### 템플릿 사용 시 (GitHub "Use this template") - -**프로젝트 생성 즉시 자동 실행**: -- ✅ `version.yml` 자동 생성 (v0.0.0, basic 타입) -- ✅ 공통 워크플로우 5개 자동 설치: - - `PROJECT-COMMON-VERSION-CONTROL.yaml` (버전 자동 관리) - - `PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml` (체인지로그 생성) - - `PROJECT-COMMON-README-VERSION-UPDATE.yaml` (README 업데이트) - - `PROJECT-COMMON-ISSUE-COMMENT.yaml` (이슈 자동화) - - `PROJECT-COMMON-SYNC-ISSUE-LABELS.yaml` (라벨 동기화) -- ✅ README 버전 섹션 자동 추가 -- ✅ 불필요한 템플릿 파일 자동 삭제 -- ✅ Default 브랜치 자동 감지 - ---- - -### 원격 스크립트 사용 시 (기존 프로젝트에 통합) - -```bash -# 대화형 모드 (권장) -bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/main/template_integrator.sh") - -# 비대화형 모드 (Spring Boot 전체 통합 예시) -bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/main/template_integrator.sh") \ - --mode full --type spring --version 1.0.0 --force -``` - -**자동으로 수행되는 작업**: -- ✅ 프로젝트 타입 자동 감지 (Spring, Flutter, React, Node, Python 등) -- ✅ 현재 버전 자동 감지 (Git 태그, build.gradle, package.json 등) -- ✅ 프로젝트 타입에 맞는 워크플로우만 선택 복사 -- ✅ `version.yml` 자동 생성 -- ✅ README에 버전 섹션 자동 추가 -- ✅ 버전 관리 스크립트 자동 설치 - -**지원하는 프로젝트 타입**: -- `spring` - Spring Boot / Java / Gradle -- `flutter` - Flutter / Dart -- `react` - React.js / Next.js -- `react-native` - React Native (iOS + Android) -- `react-native-expo` - Expo 기반 React Native -- `node` - Node.js / Express -- `python` - Python / FastAPI / Django -- `basic` - 기본 타입 (버전 관리만) - ---- - -### 매 커밋마다 자동 실행 - -#### main 브랜치 푸시 시 -- ✅ 커밋 메시지 분석 (`feat:`, `fix:`, `docs:` 등) -- ✅ 버전 자동 증가 (1.0.0 → 1.0.1) -- ✅ README 버전 자동 업데이트 -- ✅ Git 태그 자동 생성 (`v1.0.1`) -- ✅ 프로젝트별 버전 파일 동기화: - - Spring: `build.gradle` 또는 `pom.xml` - - Flutter: `pubspec.yaml` - - React/Node: `package.json` - - React Native: `package.json`, `ios/Info.plist`, `android/build.gradle` - - Python: `pyproject.toml` - -#### deploy 브랜치로 PR 생성/머지 시 -- ✅ CodeRabbit AI 자동 코드 리뷰 -- ✅ 체인지로그 자동 생성 (`CHANGELOG.json`, `CHANGELOG.md`) -- ✅ PR 자동 머지 (리뷰 통과 시) - ---- - -## 🏢 Organization 사용 시 추가 설정 - -Organization 저장소에서는 아래 3가지 추가 설정이 필요합니다: - -### 1. Actions 설정 -``` -Organization Settings → Actions → General -├── ✅ Allow GitHub Actions to create and approve pull requests -└── ✅ Allow GitHub Actions to merge pull requests -``` - -### 2. Repository 설정 -``` -Repository Settings → General → Pull Requests -├── ✅ Allow auto-merge -└── ✅ Allow squash merging -``` - -### 3. Member 권한 확인 -``` -Organization Settings → Member privileges -└── Personal access token expiration policy: 조직 정책에 맞게 설정 -``` - -💡 **개인 저장소는 추가 설정 불필요합니다.** - ---- - -## 🧪 동작 확인 - -### 첫 번째 테스트: 버전 자동 증가 - -```bash -# main 브랜치에 테스트 커밋 -echo "# 테스트" >> TEST.md -git add TEST.md -git commit -m "test: 자동화 테스트" -git push origin main -``` - -**예상 결과** (GitHub Actions 탭에서 확인): -1. `PROJECT-COMMON-VERSION-CONTROL` 워크플로우 실행 -2. 버전 자동 증가 (예: v0.0.0 → v0.0.1) -3. Git 태그 `v0.0.1` 자동 생성 -4. README 버전 자동 업데이트 - ---- - -### 두 번째 테스트: 체인지로그 자동 생성 - -```bash -# feature 브랜치 생성 및 작업 -git checkout -b feature/test-changelog -echo "# 체인지로그 테스트" >> TEST2.md -git add TEST2.md -git commit -m "feat: 체인지로그 테스트 기능" -git push origin feature/test-changelog - -# GitHub에서 deploy 브랜치로 PR 생성 -``` - -**예상 결과**: -1. CodeRabbit AI 자동 리뷰 -2. `CHANGELOG.json`, `CHANGELOG.md` 자동 생성 -3. PR 자동 머지 (리뷰 통과 시) - ---- - -## 🚨 자주 묻는 질문 - -### Q1: 워크플로우가 실행되지 않아요 - -**원인**: `deploy` 브랜치에 워크플로우 파일이 없을 수 있습니다. - -**해결**: -```bash -git checkout deploy -ls .github/workflows/ - -# 파일이 없다면 main에서 복사 -git checkout main -git checkout deploy -git merge main -git push origin deploy -``` - ---- - -### Q2: 토큰 권한 오류가 발생해요 - -**증상**: -``` -remote: Permission to ... denied to github-actions[bot] -``` - -**해결**: -1. 토큰이 **Classic** 타입인지 확인 (Fine-grained 아님) -2. `repo`, `workflow` 권한 **모두** 체크되었는지 확인 -3. Organization 설정에서 PAT 정책 확인 -4. 토큰 만료 날짜 확인 - ---- - -### Q3: 버전이 동기화되지 않아요 - -**해결**: 수동 동기화 실행 -```bash -# 현재 버전 상태 확인 -.github/scripts/version_manager.sh get - -# 모든 파일 동기화 -.github/scripts/version_manager.sh sync - -# 특정 버전으로 강제 설정 -.github/scripts/version_manager.sh set 1.0.0 -``` - ---- - -### Q4: CodeRabbit 리뷰가 동작하지 않아요 - -**확인 사항**: -1. `.coderabbit.yaml` 파일이 프로젝트 루트에 있는지 확인 -2. CodeRabbit이 저장소에 액세스 권한이 있는지 확인 -3. PR에 충분한 변경사항이 있는지 확인 (1줄 이상) - ---- - -### Q5: Spring Boot 프로젝트인데 Nexus 배포 워크플로우가 없어요 - -**답변**: Nexus 워크플로우는 Spring 타입 선택 시 자동으로 복사됩니다: -- `PROJECT-SPRING-NEXUS-CI.yml` -- `PROJECT-SPRING-NEXUS-PUBLISH.yml` - -**추가 설정 필요**: -1. GitHub Secrets에 Nexus 인증 정보 등록: - - `NEXUS_USERNAME` - - `NEXUS_PASSWORD` - - `NEXUS_URL` -2. `gradle.properties` 또는 `build.gradle`에 Nexus 저장소 설정 - ---- - -## 📚 추가 문서 - -- [CONTRIBUTING.md](CONTRIBUTING.md) - 상세 기여 가이드 및 워크플로우 설명 -- [CHANGELOG.md](CHANGELOG.md) - 전체 변경 이력 -- [README.md](README.md) - 프로젝트 개요 및 빠른 시작 - ---- - -## 💡 다음 단계 - -### 프로젝트 타입 변경하려면? - -```bash -# 원격 스크립트로 타입 변경 -bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/main/template_integrator.sh") -``` - -### 고급 기능 활용 - -1. **수동 워크플로우 실행**: GitHub Actions 탭에서 `workflow_dispatch` 트리거 활용 -2. **멀티 환경 배포**: `version.yml`에 환경별 설정 추가 -3. **커스텀 워크플로우**: `.github/workflows/`에 프로젝트별 워크플로우 추가 가능 - ---- - -**🎉 축하합니다! 이제 완전 자동화된 DevOps 환경이 구축되었습니다.** - -추가 질문이나 문제가 있다면 [이슈를 생성](https://github.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/issues/new/choose)해 주세요. diff --git a/docs/plans/2026-02-23-home-feed-sseum-redesign.md b/docs/plans/2026-02-23-home-feed-sseum-redesign.md new file mode 100644 index 0000000..bdf6c5e --- /dev/null +++ b/docs/plans/2026-02-23-home-feed-sseum-redesign.md @@ -0,0 +1,758 @@ +# 홈 피드 UI 씀(Sseum) 스타일 리디자인 - 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 현재 다크 테마의 홈 피드를 씀 앱 스타일의 라이트 모노톤 미니멀 디자인으로 전면 리디자인한다. + +**Architecture:** UI/컬러 레이어만 변경. data 레이어(models, datasource, repository, provider 로직)는 그대로 유지. HomeColors 컬러 팔레트를 다크→라이트로 교체하고, HomePage를 2섹션 스크롤에서 TabBar+TabBarView 탭 전환 구조로 변경한다. + +**Tech Stack:** Flutter 3.9.2, Riverpod (riverpod_annotation), Freezed, flutter_screenutil, GoRouter + +**제약:** `flutter format`만 로컬 실행 가능. `build_runner` 실행 불가. 코드 생성 파일은 직접 작성 불요(이번 변경에서 provider/model 코드 변경 없음). + +--- + +## Task 1: HomeColors 라이트 모노톤으로 교체 + +**Files:** +- Modify: `lib/common/constants/home_colors.dart` + +**Step 1: HomeColors 전체 교체** + +`cardOverlayStart`, `cardOverlayEnd`, `border` 삭제. `cardBorder` 신규 추가. 나머지 모든 값을 라이트 모노톤으로 변경. + +```dart +import 'package:flutter/material.dart'; + +/// 홈 화면 전용 컬러 상수 +/// Home Screen Color Palette (씀 스타일 라이트 모노톤 미니멀 디자인) +/// +/// 사용법: +/// - Container(color: HomeColors.background) +class HomeColors { + HomeColors._(); + + // ============================================ + // 배경 색상 (Background Colors) + // ============================================ + + /// 메인 배경색 (순수 화이트) + static const Color background = Color(0xFFFFFFFF); + + /// 서피스 배경색 (미세 구분용) + static const Color surface = Color(0xFFFAFAFA); + + /// 서피스 밝은 톤 (스켈레톤 베이스, placeholder) + static const Color surfaceLight = Color(0xFFF5F5F5); + + // ============================================ + // 텍스트 색상 (Text Colors) + // ============================================ + + /// 주요 텍스트 (거의 블랙) + static const Color textPrimary = Color(0xFF1A1A1A); + + /// 보조 텍스트 (미디엄 그레이) + static const Color textSecondary = Color(0xFF888888); + + /// 비활성 텍스트 (연한 그레이) + static const Color textDisabled = Color(0xFFBDBDBD); + + // ============================================ + // 카드 색상 (Card Colors) + // ============================================ + + /// 카드 배경색 + static const Color cardBackground = Color(0xFFFFFFFF); + + /// 카드 보더 색상 + static const Color cardBorder = Color(0xFFE5E5E5); + + // ============================================ + // 태그/칩 색상 (Tag/Chip Colors) + // ============================================ + + /// 태그 배경색 + static const Color tagBackground = Color(0xFFF5F5F5); + + /// 태그 텍스트 색상 + static const Color tagText = Color(0xFF666666); + + // ============================================ + // 구분선 색상 (Divider Colors) + // ============================================ + + /// 구분선 색상 + static const Color divider = Color(0xFFF0F0F0); + + // ============================================ + // 스켈레톤 색상 (Shimmer/Skeleton Colors) + // ============================================ + + /// 스켈레톤 기본 색상 + static const Color shimmerBase = Color(0xFFF5F5F5); + + /// 스켈레톤 하이라이트 색상 + static const Color shimmerHighlight = Color(0xFFEBEBEB); + + // ============================================ + // 아이콘 색상 (Icon Colors) + // ============================================ + + /// 아이콘 기본 색상 + static const Color iconPrimary = Color(0xFF1A1A1A); + + /// 아이콘 보조 색상 + static const Color iconSecondary = Color(0xFFAAAAAA); + + // ============================================ + // 에러/상태 색상 (Status Colors) + // ============================================ + + /// 에러 색상 + static const Color error = Color(0xFFD32F2F); + + /// 재시도 버튼 색상 + static const Color retryButton = Color(0xFF1A1A1A); +} +``` + +**Step 2: 커밋** + +```bash +git add lib/common/constants/home_colors.dart +git commit -m "최신/인기 장소 목록 UI : refactor : HomeColors 씀 스타일 라이트 모노톤으로 교체 https://github.com/MapSee-Lab/MapSy-FE/issues/18" +``` + +--- + +## Task 2: PlaceCard 씀 스타일로 수정 + +**Files:** +- Modify: `lib/features/home/presentation/widgets/place_card.dart` + +**Step 1: PlaceCard 전체 교체** + +변경사항: +- `borderRadius: 12.r` → `0` (직각) +- `BoxDecoration` 에 `border: Border.all(color: HomeColors.cardBorder)` 추가 +- `margin` 좌우 16 → 20, 상하 8 → 수직 8 유지 +- `AspectRatio` 16:9 → 3:2 +- 내부 패딩 `12.w` → 좌우 `20.w`, 상하 `16.h` +- 태그 `borderRadius: 4.r` → `100.r` (pill shape) +- placeholder 배경 `HomeColors.surface` → `HomeColors.surfaceLight` + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../data/models/place_model.dart'; + +/// 장소 카드 (씀 스타일: 직각 보더, 넓은 여백) +class PlaceCard extends StatelessWidget { + final PlaceModel place; + final VoidCallback? onTap; + + const PlaceCard({ + super.key, + required this.place, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), + decoration: BoxDecoration( + color: HomeColors.cardBackground, + border: Border.all(color: HomeColors.cardBorder, width: 1), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 썸네일 (3:2) + AspectRatio( + aspectRatio: 3 / 2, + child: _buildThumbnail(), + ), + // 정보 영역 + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 장소명 + Text( + place.placeName, + style: TextStyle( + color: HomeColors.textPrimary, + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (place.address != null) ...[ + SizedBox(height: 4.h), + // 주소 + Text( + place.address!, + style: TextStyle( + color: HomeColors.textSecondary, + fontSize: 13.sp, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (place.tags.isNotEmpty) ...[ + SizedBox(height: 8.h), + // 태그 + Wrap( + spacing: 6.w, + runSpacing: 4.h, + children: place.tags.take(3).map((tag) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 4.h, + ), + decoration: BoxDecoration( + color: HomeColors.tagBackground, + borderRadius: BorderRadius.circular(100.r), + ), + child: Text( + '#$tag', + style: TextStyle( + color: HomeColors.tagText, + fontSize: 12.sp, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildThumbnail() { + if (place.imageUrl != null && place.imageUrl!.isNotEmpty) { + return Image.network( + place.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPlaceholder(), + ); + } + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + color: HomeColors.surfaceLight, + child: Center( + child: Icon( + Icons.place_outlined, + color: HomeColors.iconSecondary, + size: 48.sp, + ), + ), + ); + } +} +``` + +**Step 2: 커밋** + +```bash +git add lib/features/home/presentation/widgets/place_card.dart +git commit -m "최신/인기 장소 목록 UI : refactor : PlaceCard 씀 스타일 적용 (직각 보더, 넓은 여백, pill 태그) https://github.com/MapSee-Lab/MapSy-FE/issues/18" +``` + +--- + +## Task 3: 상태 위젯 씀 스타일로 수정 + +**Files:** +- Modify: `lib/features/home/presentation/widgets/home_loading_shimmer.dart` +- Modify: `lib/features/home/presentation/widgets/home_empty_state.dart` +- Modify: `lib/features/home/presentation/widgets/home_error_state.dart` + +**Step 1: HomeLoadingShimmer 수정** + +변경사항: +- 그리드 스켈레톤 제거 (탭 전환 구조에서 그리드 미사용) +- 카드 스켈레톤만 3개 (직각, 씀 스타일 여백) +- `borderRadius: 12.r` → `0` +- 마진 좌우 16 → 20 + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; + +/// 홈 화면 로딩 스켈레톤 (씀 스타일) +class HomeLoadingShimmer extends StatelessWidget { + const HomeLoadingShimmer({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(3, (_) => _buildCardShimmer()), + ); + } + + Widget _buildCardShimmer() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), + decoration: BoxDecoration( + color: HomeColors.shimmerBase, + border: Border.all(color: HomeColors.cardBorder, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 썸네일 영역 (3:2) + AspectRatio( + aspectRatio: 3 / 2, + child: Container(color: HomeColors.shimmerHighlight), + ), + // 텍스트 영역 + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16.h, + width: 160.w, + decoration: BoxDecoration( + color: HomeColors.shimmerHighlight, + borderRadius: BorderRadius.circular(2.r), + ), + ), + SizedBox(height: 8.h), + Container( + height: 12.h, + width: 120.w, + decoration: BoxDecoration( + color: HomeColors.shimmerHighlight, + borderRadius: BorderRadius.circular(2.r), + ), + ), + ], + ), + ), + ], + ), + ); + } +} +``` + +**Step 2: HomeEmptyState 수정** + +변경사항: +- 아이콘 64sp → 48sp +- `Icons.explore_off_outlined` → `Icons.explore_outlined` +- 아이콘 색상 → `HomeColors.textDisabled` (#BDBDBD) +- 텍스트 15sp → 14sp + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; + +/// 빈 상태 위젯 (씀 스타일: 미니멀) +class HomeEmptyState extends StatelessWidget { + final String message; + + const HomeEmptyState({ + super.key, + this.message = '아직 등록된 장소가 없습니다', + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.explore_outlined, + size: 48.sp, + color: HomeColors.textDisabled, + ), + SizedBox(height: 16.h), + Text( + message, + style: TextStyle( + color: HomeColors.textSecondary, + fontSize: 14.sp, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} +``` + +**Step 3: HomeErrorState 수정** + +변경사항: +- 에러 아이콘 제거 (텍스트 중심) +- 재시도 버튼: TextButton → 언더라인 텍스트 스타일 +- 텍스트 15sp → 14sp + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; + +/// 에러 상태 위젯 (씀 스타일: 텍스트 중심, 아이콘 없음) +class HomeErrorState extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const HomeErrorState({ + super.key, + this.message = '불러오기에 실패했습니다', + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + style: TextStyle( + color: HomeColors.textSecondary, + fontSize: 14.sp, + ), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + SizedBox(height: 16.h), + GestureDetector( + onTap: onRetry, + child: Text( + '다시 시도', + style: TextStyle( + color: HomeColors.retryButton, + fontSize: 14.sp, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ], + ), + ), + ); + } +} +``` + +**Step 4: 커밋** + +```bash +git add lib/features/home/presentation/widgets/home_loading_shimmer.dart lib/features/home/presentation/widgets/home_empty_state.dart lib/features/home/presentation/widgets/home_error_state.dart +git commit -m "최신/인기 장소 목록 UI : refactor : 상태 위젯 씀 스타일 적용 (스켈레톤, 빈 상태, 에러) https://github.com/MapSee-Lab/MapSy-FE/issues/18" +``` + +--- + +## Task 4: HomePage 탭 전환 구조로 재작성 + +**Files:** +- Modify: `lib/features/home/presentation/pages/home_page.dart` + +**Step 1: HomePage 전체 재작성** + +변경사항: +- `ConsumerStatefulWidget` 유지 (무한 스크롤 컨트롤러 필요) +- `SingleTickerProviderStateMixin` 추가 (TabController) +- 2섹션 `CustomScrollView` → `TabBar` + `TabBarView` +- 각 탭은 같은 `PlaceCard` 위젯 사용하는 리스트 +- `PopularPlaceTile` import 제거 +- 씀 스타일: AppBar elevation 0, 구분선 없음 + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../../../routing/route_paths.dart'; +import '../../../auth/presentation/auth_provider.dart'; +import '../home_provider.dart'; +import '../widgets/home_empty_state.dart'; +import '../widgets/home_error_state.dart'; +import '../widgets/home_loading_shimmer.dart'; +import '../widgets/place_card.dart'; + +/// 홈 화면 (씀 스타일: 탭 전환 + 미니멀 카드 리스트) +class HomePage extends ConsumerStatefulWidget { + const HomePage({super.key}); + + @override + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + final _recentScrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _recentScrollController.addListener(_onRecentScroll); + } + + @override + void dispose() { + _tabController.dispose(); + _recentScrollController.removeListener(_onRecentScroll); + _recentScrollController.dispose(); + super.dispose(); + } + + void _onRecentScroll() { + if (_recentScrollController.position.pixels >= + _recentScrollController.position.maxScrollExtent - 200) { + ref.read(homeNotifierProvider.notifier).fetchMoreRecentPlaces(); + } + } + + @override + Widget build(BuildContext context) { + final homeState = ref.watch(homeNotifierProvider); + + return Scaffold( + backgroundColor: HomeColors.background, + appBar: AppBar( + backgroundColor: HomeColors.background, + elevation: 0, + scrolledUnderElevation: 0, + title: Text( + 'MapSy', + style: TextStyle( + color: HomeColors.textPrimary, + fontSize: 20.sp, + fontWeight: FontWeight.w600, + ), + ), + actions: [ + IconButton( + icon: Icon(Icons.logout, color: HomeColors.iconPrimary, size: 22.sp), + onPressed: () async { + await ref.read(authNotifierProvider.notifier).signOut(); + if (context.mounted) { + context.go(RoutePaths.login); + } + }, + tooltip: '로그아웃', + ), + ], + bottom: TabBar( + controller: _tabController, + labelColor: HomeColors.textPrimary, + unselectedLabelColor: HomeColors.iconSecondary, + labelStyle: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w400, + ), + indicatorColor: HomeColors.textPrimary, + indicatorWeight: 2, + dividerColor: HomeColors.divider, + tabs: const [ + Tab(text: '최신순'), + Tab(text: '인기순'), + ], + ), + ), + body: _buildBody(homeState), + ); + } + + Widget _buildBody(HomeState state) { + // 초기 로딩 + if (!state.isInitialized && + (state.isLoadingRecent || state.isLoadingPopular)) { + return const SingleChildScrollView( + child: HomeLoadingShimmer(), + ); + } + + // 에러 상태 (데이터 없이 에러) + if (state.errorMessage != null && + state.recentPlaces.isEmpty && + state.popularPlaces.isEmpty) { + return HomeErrorState( + message: state.errorMessage!, + onRetry: () => ref.read(homeNotifierProvider.notifier).refresh(), + ); + } + + return TabBarView( + controller: _tabController, + children: [ + _buildRecentTab(state), + _buildPopularTab(state), + ], + ); + } + + /// 최신순 탭 + Widget _buildRecentTab(HomeState state) { + if (state.recentPlaces.isEmpty && !state.isLoadingRecent) { + return const HomeEmptyState(message: '아직 등록된 장소가 없습니다'); + } + + return RefreshIndicator( + onRefresh: () => ref.read(homeNotifierProvider.notifier).refresh(), + color: HomeColors.textPrimary, + backgroundColor: HomeColors.background, + child: ListView.builder( + controller: _recentScrollController, + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: state.recentPlaces.length + (state.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index < state.recentPlaces.length) { + return PlaceCard(place: state.recentPlaces[index]); + } + // 무한 스크롤 로딩 + return Padding( + padding: EdgeInsets.all(16.w), + child: Center( + child: SizedBox( + width: 20.w, + height: 20.w, + child: CircularProgressIndicator( + color: HomeColors.textPrimary, + strokeWidth: 2, + ), + ), + ), + ); + }, + ), + ); + } + + /// 인기순 탭 + Widget _buildPopularTab(HomeState state) { + if (state.popularPlaces.isEmpty && !state.isLoadingPopular) { + return const HomeEmptyState(message: '아직 인기 장소가 없습니다'); + } + + return RefreshIndicator( + onRefresh: () => ref.read(homeNotifierProvider.notifier).refresh(), + color: HomeColors.textPrimary, + backgroundColor: HomeColors.background, + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: state.popularPlaces.length, + itemBuilder: (context, index) { + return PlaceCard(place: state.popularPlaces[index]); + }, + ), + ); + } +} +``` + +**Step 2: 커밋** + +```bash +git add lib/features/home/presentation/pages/home_page.dart +git commit -m "최신/인기 장소 목록 UI : refactor : HomePage 씀 스타일 탭 전환 구조로 재작성 https://github.com/MapSee-Lab/MapSy-FE/issues/18" +``` + +--- + +## Task 5: PopularPlaceTile 삭제 + +**Files:** +- Delete: `lib/features/home/presentation/widgets/popular_place_tile.dart` + +**Step 1: 파일 삭제** + +탭 전환 구조에서 인기 장소도 PlaceCard를 사용하므로 PopularPlaceTile은 불필요. + +```bash +git rm lib/features/home/presentation/widgets/popular_place_tile.dart +``` + +**Step 2: 커밋** + +```bash +git commit -m "최신/인기 장소 목록 UI : refactor : PopularPlaceTile 삭제 (탭 구조에서 PlaceCard 통합 사용) https://github.com/MapSee-Lab/MapSy-FE/issues/18" +``` + +--- + +## Task 6: flutter format 실행 및 최종 확인 + +**Step 1: 포맷팅 실행** + +```bash +flutter format lib/common/constants/home_colors.dart lib/features/home/presentation/pages/home_page.dart lib/features/home/presentation/widgets/ +``` + +**Step 2: 변경 있으면 커밋** + +```bash +git add -A +git commit -m "최신/인기 장소 목록 UI : style : flutter format 적용 https://github.com/MapSee-Lab/MapSy-FE/issues/18" +``` + +--- + +## 구현 순서 요약 + +| Task | 설명 | 의존성 | +|------|------|--------| +| 1 | HomeColors 라이트 모노톤 교체 | 없음 | +| 2 | PlaceCard 씀 스타일 수정 | Task 1 | +| 3 | 상태 위젯 (로딩/빈/에러) 수정 | Task 1 | +| 4 | HomePage 탭 전환 구조 재작성 | Task 2, 3 | +| 5 | PopularPlaceTile 삭제 | Task 4 | +| 6 | flutter format + 최종 확인 | Task 5 | + +``` +Task 1 (HomeColors) + ├──→ Task 2 (PlaceCard) ──┐ + └──→ Task 3 (상태 위젯) ──┤ + ├──→ Task 4 (HomePage) → Task 5 (삭제) → Task 6 (format) +``` diff --git a/lib/core/constants/api_endpoints.dart b/lib/common/constants/api_endpoints.dart similarity index 100% rename from lib/core/constants/api_endpoints.dart rename to lib/common/constants/api_endpoints.dart diff --git a/lib/core/constants/app_colors.dart b/lib/common/constants/app_colors.dart similarity index 100% rename from lib/core/constants/app_colors.dart rename to lib/common/constants/app_colors.dart diff --git a/lib/common/constants/home_colors.dart b/lib/common/constants/home_colors.dart new file mode 100644 index 0000000..bea3f68 --- /dev/null +++ b/lib/common/constants/home_colors.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +/// 홈 화면 전용 컬러 상수 +/// Home Screen Color Palette (씀 스타일 라이트 모노톤 미니멀 디자인) +/// +/// 사용법: +/// - Container(color: HomeColors.background) +class HomeColors { + HomeColors._(); + + // ============================================ + // 배경 색상 (Background Colors) + // ============================================ + + /// 메인 배경색 (순수 화이트) + static const Color background = Color(0xFFFFFFFF); + + /// 서피스 배경색 (미세 구분용) + static const Color surface = Color(0xFFFAFAFA); + + /// 서피스 밝은 톤 (스켈레톤 베이스, placeholder) + static const Color surfaceLight = Color(0xFFF5F5F5); + + // ============================================ + // 텍스트 색상 (Text Colors) + // ============================================ + + /// 주요 텍스트 (거의 블랙) + static const Color textPrimary = Color(0xFF1A1A1A); + + /// 보조 텍스트 (미디엄 그레이) + static const Color textSecondary = Color(0xFF888888); + + /// 비활성 텍스트 (연한 그레이) + static const Color textDisabled = Color(0xFFBDBDBD); + + // ============================================ + // 카드 색상 (Card Colors) + // ============================================ + + /// 카드 배경색 + static const Color cardBackground = Color(0xFFFFFFFF); + + /// 카드 보더 색상 + static const Color cardBorder = Color(0xFFE5E5E5); + + // ============================================ + // 태그/칩 색상 (Tag/Chip Colors) + // ============================================ + + /// 태그 배경색 + static const Color tagBackground = Color(0xFFF5F5F5); + + /// 태그 텍스트 색상 + static const Color tagText = Color(0xFF666666); + + // ============================================ + // 구분선 색상 (Divider Colors) + // ============================================ + + /// 구분선 색상 + static const Color divider = Color(0xFFF0F0F0); + + // ============================================ + // 스켈레톤 색상 (Shimmer/Skeleton Colors) + // ============================================ + + /// 스켈레톤 기본 색상 + static const Color shimmerBase = Color(0xFFF5F5F5); + + /// 스켈레톤 하이라이트 색상 + static const Color shimmerHighlight = Color(0xFFEBEBEB); + + // ============================================ + // 아이콘 색상 (Icon Colors) + // ============================================ + + /// 아이콘 기본 색상 + static const Color iconPrimary = Color(0xFF1A1A1A); + + /// 아이콘 보조 색상 + static const Color iconSecondary = Color(0xFFAAAAAA); + + // ============================================ + // 에러/상태 색상 (Status Colors) + // ============================================ + + /// 에러 색상 + static const Color error = Color(0xFFD32F2F); + + /// 재시도 버튼 색상 + static const Color retryButton = Color(0xFF1A1A1A); +} diff --git a/lib/core/constants/spacing_and_radius.dart b/lib/common/constants/spacing_and_radius.dart similarity index 100% rename from lib/core/constants/spacing_and_radius.dart rename to lib/common/constants/spacing_and_radius.dart diff --git a/lib/core/constants/text_styles.dart b/lib/common/constants/text_styles.dart similarity index 100% rename from lib/core/constants/text_styles.dart rename to lib/common/constants/text_styles.dart diff --git a/lib/core/errors/app_exception.dart b/lib/common/exceptions/app_exception.dart similarity index 100% rename from lib/core/errors/app_exception.dart rename to lib/common/exceptions/app_exception.dart diff --git a/lib/core/errors/failure.dart b/lib/common/exceptions/failure.dart similarity index 100% rename from lib/core/errors/failure.dart rename to lib/common/exceptions/failure.dart diff --git a/lib/core/network/api_client.dart b/lib/common/services/api_client.dart similarity index 100% rename from lib/core/network/api_client.dart rename to lib/common/services/api_client.dart diff --git a/lib/core/network/api_client.g.dart b/lib/common/services/api_client.g.dart similarity index 100% rename from lib/core/network/api_client.g.dart rename to lib/common/services/api_client.g.dart diff --git a/lib/core/network/auth_interceptor.dart b/lib/common/services/auth_interceptor.dart similarity index 100% rename from lib/core/network/auth_interceptor.dart rename to lib/common/services/auth_interceptor.dart diff --git a/lib/core/services/device/device_id_manager.dart b/lib/common/services/device_id_manager.dart similarity index 100% rename from lib/core/services/device/device_id_manager.dart rename to lib/common/services/device_id_manager.dart diff --git a/lib/core/services/device/device_info_service.dart b/lib/common/services/device_info_service.dart similarity index 100% rename from lib/core/services/device/device_info_service.dart rename to lib/common/services/device_info_service.dart diff --git a/lib/core/services/device/device_info_service.g.dart b/lib/common/services/device_info_service.g.dart similarity index 100% rename from lib/core/services/device/device_info_service.g.dart rename to lib/common/services/device_info_service.g.dart diff --git a/lib/core/network/error_interceptor.dart b/lib/common/services/error_interceptor.dart similarity index 99% rename from lib/core/network/error_interceptor.dart rename to lib/common/services/error_interceptor.dart index 03e399d..e4b0648 100644 --- a/lib/core/network/error_interceptor.dart +++ b/lib/common/services/error_interceptor.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import '../errors/app_exception.dart'; +import '../exceptions/app_exception.dart'; /// 에러 인터셉터 /// diff --git a/lib/core/services/fcm/firebase_messaging_service.dart b/lib/common/services/firebase_messaging_service.dart similarity index 99% rename from lib/core/services/fcm/firebase_messaging_service.dart rename to lib/common/services/firebase_messaging_service.dart index 046963e..994d3b9 100644 --- a/lib/core/services/fcm/firebase_messaging_service.dart +++ b/lib/common/services/firebase_messaging_service.dart @@ -5,8 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'local_notifications_service.dart'; -import '../device/device_info_service.dart'; -import '../device/device_id_manager.dart'; +import 'device_info_service.dart'; +import 'device_id_manager.dart'; part 'firebase_messaging_service.g.dart'; diff --git a/lib/core/services/fcm/firebase_messaging_service.g.dart b/lib/common/services/firebase_messaging_service.g.dart similarity index 100% rename from lib/core/services/fcm/firebase_messaging_service.g.dart rename to lib/common/services/firebase_messaging_service.g.dart diff --git a/lib/core/services/fcm/local_notifications_service.dart b/lib/common/services/local_notifications_service.dart similarity index 100% rename from lib/core/services/fcm/local_notifications_service.dart rename to lib/common/services/local_notifications_service.dart diff --git a/lib/core/network/token_storage.dart b/lib/common/services/token_storage.dart similarity index 100% rename from lib/core/network/token_storage.dart rename to lib/common/services/token_storage.dart diff --git a/lib/core/network/token_storage.g.dart b/lib/common/services/token_storage.g.dart similarity index 100% rename from lib/core/network/token_storage.g.dart rename to lib/common/services/token_storage.g.dart diff --git a/lib/common/widgets/.gitkeep b/lib/common/widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/utils/_placeholder.dart b/lib/core/utils/_placeholder.dart deleted file mode 100644 index afda586..0000000 --- a/lib/core/utils/_placeholder.dart +++ /dev/null @@ -1,18 +0,0 @@ -// 유틸리티 함수 -// Utility Functions -// -// 이 폴더의 용도: -// - 공통 유틸리티 함수 -// - 날짜/시간 포맷터 -// - 검증 함수 -// -// 예상 파일: -// - date_formatter.dart -// - validators.dart -// - logger.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class UtilsPlaceholder { - UtilsPlaceholder._(); -} diff --git a/lib/core/utils/extensions/_placeholder.dart b/lib/core/utils/extensions/_placeholder.dart deleted file mode 100644 index d54603b..0000000 --- a/lib/core/utils/extensions/_placeholder.dart +++ /dev/null @@ -1,18 +0,0 @@ -// 확장 함수 -// Extension Functions -// -// 이 폴더의 용도: -// - String, DateTime 등 기본 타입 확장 -// - BuildContext 확장 -// - 편의 메서드 제공 -// -// 예상 파일: -// - string_extensions.dart -// - datetime_extensions.dart -// - context_extensions.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class ExtensionsPlaceholder { - ExtensionsPlaceholder._(); -} diff --git a/lib/core/widgets/_placeholder.dart b/lib/core/widgets/_placeholder.dart deleted file mode 100644 index d6abe5f..0000000 --- a/lib/core/widgets/_placeholder.dart +++ /dev/null @@ -1,20 +0,0 @@ -// 공통 위젯 -// Common Widgets -// -// 이 폴더의 용도: -// - 앱 전역에서 재사용되는 위젯 -// - 로딩, 에러 등 공통 UI 컴포넌트 -// - 커스텀 버튼, 입력 필드 등 -// -// 예상 파일: -// - loading_indicator.dart -// - error_widget.dart -// - app_button.dart -// - app_text_field.dart -// - app_dialog.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class WidgetsPlaceholder { - WidgetsPlaceholder._(); -} diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/auth_remote_datasource.dart similarity index 89% rename from lib/features/auth/data/datasources/auth_remote_datasource.dart rename to lib/features/auth/data/auth_remote_datasource.dart index 4a5d661..3c58068 100644 --- a/lib/features/auth/data/datasources/auth_remote_datasource.dart +++ b/lib/features/auth/data/auth_remote_datasource.dart @@ -3,13 +3,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/constants/api_endpoints.dart'; -import '../../../../core/network/api_client.dart'; -import '../models/auth_request.dart'; -import '../models/sign_in_request.dart'; -import '../models/sign_in_response.dart'; -import '../models/reissue_request.dart'; -import '../models/reissue_response.dart'; +import '../../../common/constants/api_endpoints.dart'; +import '../../../common/services/api_client.dart'; +import 'models/auth_request.dart'; +import 'models/sign_in_request.dart'; +import 'models/sign_in_response.dart'; +import 'models/reissue_request.dart'; +import 'models/reissue_response.dart'; part 'auth_remote_datasource.g.dart'; diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.g.dart b/lib/features/auth/data/auth_remote_datasource.g.dart similarity index 100% rename from lib/features/auth/data/datasources/auth_remote_datasource.g.dart rename to lib/features/auth/data/auth_remote_datasource.g.dart diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/data/auth_repository.dart similarity index 97% rename from lib/features/auth/domain/repositories/auth_repository.dart rename to lib/features/auth/data/auth_repository.dart index 924eb9b..027a688 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -1,4 +1,4 @@ -import '../../../auth/data/models/sign_in_response.dart'; +import 'models/sign_in_response.dart'; /// 인증 Repository 인터페이스 /// diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/auth_repository_impl.dart similarity index 92% rename from lib/features/auth/data/repositories/auth_repository_impl.dart rename to lib/features/auth/data/auth_repository_impl.dart index 69ace15..bebc338 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/auth_repository_impl.dart @@ -2,13 +2,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/network/token_storage.dart'; -import '../../domain/repositories/auth_repository.dart'; -import '../datasources/auth_remote_datasource.dart'; -import '../models/auth_request.dart'; -import '../models/sign_in_request.dart'; -import '../models/sign_in_response.dart'; -import '../models/reissue_request.dart'; +import '../../../common/services/token_storage.dart'; +import 'auth_repository.dart'; +import 'auth_remote_datasource.dart'; +import 'models/auth_request.dart'; +import 'models/sign_in_request.dart'; +import 'models/sign_in_response.dart'; +import 'models/reissue_request.dart'; part 'auth_repository_impl.g.dart'; diff --git a/lib/features/auth/data/repositories/auth_repository_impl.g.dart b/lib/features/auth/data/auth_repository_impl.g.dart similarity index 100% rename from lib/features/auth/data/repositories/auth_repository_impl.g.dart rename to lib/features/auth/data/auth_repository_impl.g.dart diff --git a/lib/features/auth/data/datasources/firebase_auth_datasource.dart b/lib/features/auth/data/firebase_auth_datasource.dart similarity index 100% rename from lib/features/auth/data/datasources/firebase_auth_datasource.dart rename to lib/features/auth/data/firebase_auth_datasource.dart diff --git a/lib/features/auth/domain/utils/firebase_auth_error_handler.dart b/lib/features/auth/data/firebase_auth_error_handler.dart similarity index 98% rename from lib/features/auth/domain/utils/firebase_auth_error_handler.dart rename to lib/features/auth/data/firebase_auth_error_handler.dart index 85e7dcf..71b695d 100644 --- a/lib/features/auth/domain/utils/firebase_auth_error_handler.dart +++ b/lib/features/auth/data/firebase_auth_error_handler.dart @@ -1,6 +1,6 @@ import 'package:firebase_auth/firebase_auth.dart'; -import '../../../../core/errors/app_exception.dart'; +import '../../../common/exceptions/app_exception.dart'; /// Firebase Authentication 에러를 처리하는 유틸리티 클래스 /// diff --git a/lib/features/auth/data/models/_placeholder.dart b/lib/features/auth/data/models/_placeholder.dart deleted file mode 100644 index 7dfa1a2..0000000 --- a/lib/features/auth/data/models/_placeholder.dart +++ /dev/null @@ -1,18 +0,0 @@ -// 인증 데이터 모델 -// Authentication Data Models -// -// 이 폴더의 용도: -// - API 응답/요청 DTO (Data Transfer Object) -// - Freezed 기반 불변 모델 -// - JSON 직렬화/역직렬화 -// -// 예상 파일: -// - user_model.dart -// - auth_token_model.dart -// - login_response_model.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class AuthDataModelsPlaceholder { - AuthDataModelsPlaceholder._(); -} diff --git a/lib/features/auth/data/models/sign_in_response.dart b/lib/features/auth/data/models/sign_in_response.dart index afbcffe..ed4f7c6 100644 --- a/lib/features/auth/data/models/sign_in_response.dart +++ b/lib/features/auth/data/models/sign_in_response.dart @@ -1,7 +1,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; // OnboardingStep은 통합 enum 사용 -export '../../domain/entities/onboarding_step.dart'; +export '../onboarding_step.dart'; part 'sign_in_response.freezed.dart'; part 'sign_in_response.g.dart'; diff --git a/lib/features/auth/domain/entities/onboarding_step.dart b/lib/features/auth/data/onboarding_step.dart similarity index 100% rename from lib/features/auth/domain/entities/onboarding_step.dart rename to lib/features/auth/data/onboarding_step.dart diff --git a/lib/features/auth/data/repositories/_placeholder.dart b/lib/features/auth/data/repositories/_placeholder.dart deleted file mode 100644 index 3bd05ff..0000000 --- a/lib/features/auth/data/repositories/_placeholder.dart +++ /dev/null @@ -1,16 +0,0 @@ -// 인증 리포지토리 구현 -// Authentication Repository Implementation -// -// 이 폴더의 용도: -// - Domain 레이어 리포지토리 인터페이스 구현 -// - 데이터 소스 조합 및 에러 처리 -// - Either 패턴으로 결과 반환 -// -// 예상 파일: -// - auth_repository_impl.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class AuthRepositoriesPlaceholder { - AuthRepositoriesPlaceholder._(); -} diff --git a/lib/features/auth/domain/entities/_placeholder.dart b/lib/features/auth/domain/entities/_placeholder.dart deleted file mode 100644 index 190586e..0000000 --- a/lib/features/auth/domain/entities/_placeholder.dart +++ /dev/null @@ -1,17 +0,0 @@ -// 인증 엔티티 -// Authentication Entities -// -// 이 폴더의 용도: -// - 순수 비즈니스 로직 객체 -// - 프레임워크 독립적 모델 -// - 불변 객체로 설계 -// -// 예상 파일: -// - user_entity.dart -// - auth_token_entity.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class AuthEntitiesPlaceholder { - AuthEntitiesPlaceholder._(); -} diff --git a/lib/features/auth/domain/repositories/_placeholder.dart b/lib/features/auth/domain/repositories/_placeholder.dart deleted file mode 100644 index 2c40699..0000000 --- a/lib/features/auth/domain/repositories/_placeholder.dart +++ /dev/null @@ -1,16 +0,0 @@ -// 인증 리포지토리 인터페이스 -// Authentication Repository Interface -// -// 이 폴더의 용도: -// - 추상 리포지토리 인터페이스 정의 -// - Data 레이어 구현 계약 -// - Either 반환 타입 -// -// 예상 파일: -// - auth_repository.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class AuthRepositoryInterfacePlaceholder { - AuthRepositoryInterfacePlaceholder._(); -} diff --git a/lib/features/auth/domain/usecases/_placeholder.dart b/lib/features/auth/domain/usecases/_placeholder.dart deleted file mode 100644 index ed28009..0000000 --- a/lib/features/auth/domain/usecases/_placeholder.dart +++ /dev/null @@ -1,18 +0,0 @@ -// 인증 유스케이스 -// Authentication Use Cases -// -// 이 폴더의 용도: -// - 단일 비즈니스 로직 실행 -// - Google 로그인, 로그아웃, 토큰 갱신 -// - 리포지토리 호출 및 결과 반환 -// -// 예상 파일: -// - google_sign_in_usecase.dart -// - sign_out_usecase.dart -// - refresh_token_usecase.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class AuthUseCasesPlaceholder { - AuthUseCasesPlaceholder._(); -} diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/auth_provider.dart similarity index 95% rename from lib/features/auth/presentation/providers/auth_provider.dart rename to lib/features/auth/presentation/auth_provider.dart index 9596c2e..0e399ae 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/auth_provider.dart @@ -4,14 +4,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/errors/app_exception.dart'; -import '../../../../core/network/token_storage.dart'; -import '../../../../core/services/device/device_info_service.dart'; -import '../../../../core/services/fcm/firebase_messaging_service.dart'; -import '../../data/datasources/firebase_auth_datasource.dart'; -import '../../data/models/sign_in_response.dart'; -import '../../data/repositories/auth_repository_impl.dart'; -import '../../domain/utils/firebase_auth_error_handler.dart'; +import '../../../common/exceptions/app_exception.dart'; +import '../../../common/services/token_storage.dart'; +import '../../../common/services/device_info_service.dart'; +import '../../../common/services/firebase_messaging_service.dart'; +import '../data/firebase_auth_datasource.dart'; +import '../data/models/sign_in_response.dart'; +import '../data/auth_repository_impl.dart'; +import '../data/firebase_auth_error_handler.dart'; part 'auth_provider.g.dart'; diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/auth_provider.g.dart similarity index 100% rename from lib/features/auth/presentation/providers/auth_provider.g.dart rename to lib/features/auth/presentation/auth_provider.g.dart diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index 7acf55b..583df79 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -4,12 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/constants/spacing_and_radius.dart'; -import '../../../../core/constants/text_styles.dart'; -import '../../../../core/errors/app_exception.dart'; -import '../../../../router/route_paths.dart'; +import '../../../../common/constants/spacing_and_radius.dart'; +import '../../../../common/constants/text_styles.dart'; +import '../../../../common/exceptions/app_exception.dart'; +import '../../../../routing/route_paths.dart'; import '../../data/models/sign_in_response.dart'; -import '../providers/auth_provider.dart'; +import '../../data/onboarding_step.dart'; +import '../auth_provider.dart'; /// Google 로그인 화면 /// diff --git a/lib/features/auth/presentation/pages/splash_page.dart b/lib/features/auth/presentation/pages/splash_page.dart index 950facb..2e66b05 100644 --- a/lib/features/auth/presentation/pages/splash_page.dart +++ b/lib/features/auth/presentation/pages/splash_page.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../../router/route_paths.dart'; -import '../../domain/entities/onboarding_step.dart'; -import '../providers/auth_provider.dart'; +import '../../../../routing/route_paths.dart'; +import '../../data/onboarding_step.dart'; +import '../auth_provider.dart'; /// 앱 시작 시 초기 화면 /// diff --git a/lib/features/auth/presentation/widgets/.gitkeep b/lib/features/auth/presentation/widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/auth/presentation/widgets/_placeholder.dart b/lib/features/auth/presentation/widgets/_placeholder.dart deleted file mode 100644 index 3054952..0000000 --- a/lib/features/auth/presentation/widgets/_placeholder.dart +++ /dev/null @@ -1,16 +0,0 @@ -// 인증 위젯 -// Authentication Widgets -// -// 이 폴더의 용도: -// - 재사용 가능한 인증 UI 컴포넌트 -// - Google 로그인 버튼 등 -// - 페이지 내부에서 사용되는 위젯 -// -// 예상 파일: -// - google_sign_in_button.dart -// -// TODO: 이 파일을 삭제하고 실제 구현을 시작하세요. - -class AuthWidgetsPlaceholder { - AuthWidgetsPlaceholder._(); -} diff --git a/lib/features/home/data/content_status.dart b/lib/features/home/data/content_status.dart new file mode 100644 index 0000000..41104c9 --- /dev/null +++ b/lib/features/home/data/content_status.dart @@ -0,0 +1,25 @@ +/// 콘텐츠 처리 상태 +enum ContentStatus { + /// AI 분석 대기 중 + pending, + + /// AI 분석 중 + processing, + + /// 분석 완료 + completed, + + /// 분석 실패 + failed; + + /// JSON 문자열에서 ContentStatus로 변환 + static ContentStatus fromString(String value) { + return ContentStatus.values.firstWhere( + (e) => e.name.toUpperCase() == value.toUpperCase(), + orElse: () => ContentStatus.pending, + ); + } + + /// ContentStatus를 JSON 문자열로 변환 + String toJson() => name.toUpperCase(); +} diff --git a/lib/features/home/data/home_remote_datasource.dart b/lib/features/home/data/home_remote_datasource.dart new file mode 100644 index 0000000..7d59383 --- /dev/null +++ b/lib/features/home/data/home_remote_datasource.dart @@ -0,0 +1,74 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../common/constants/api_endpoints.dart'; +import '../../../common/services/api_client.dart'; +import 'models/content_response.dart'; + +part 'home_remote_datasource.g.dart'; + +/// HomeRemoteDataSource Provider +@riverpod +HomeRemoteDataSource homeRemoteDataSource(Ref ref) { + final dio = ref.watch(dioProvider); + return HomeRemoteDataSource(dio); +} + +/// 홈 화면 Remote DataSource +/// +/// 홈 피드에 필요한 백엔드 API를 호출합니다. +class HomeRemoteDataSource { + final Dio _dio; + + HomeRemoteDataSource(this._dio); + + /// 최근 콘텐츠 목록 조회 (커서 기반 페이지네이션) + /// + /// GET /api/content/recent?cursor={cursor}&size={size} + Future fetchRecentContent({ + int? cursor, + int size = 20, + }) async { + debugPrint('📤 HomeRemoteDataSource: Fetching recent content...'); + + final response = await _dio.get( + ApiEndpoints.recentContent, + queryParameters: { + if (cursor != null) 'cursor': cursor, + 'size': size, + }, + ); + + final result = ContentListResponse.fromJson( + response.data as Map, + ); + debugPrint('✅ Recent content fetched: ${result.content.length} items'); + return result; + } + + /// 회원 콘텐츠 목록 조회 (커서 기반 페이지네이션) + /// + /// GET /api/content/member?cursor={cursor}&size={size} + Future fetchMemberContent({ + int? cursor, + int size = 30, + }) async { + debugPrint('📤 HomeRemoteDataSource: Fetching member content...'); + + final response = await _dio.get( + ApiEndpoints.memberContent, + queryParameters: { + if (cursor != null) 'cursor': cursor, + 'size': size, + }, + ); + + final result = ContentListResponse.fromJson( + response.data as Map, + ); + debugPrint('✅ Member content fetched: ${result.content.length} items'); + return result; + } +} diff --git a/lib/features/home/data/home_remote_datasource.g.dart b/lib/features/home/data/home_remote_datasource.g.dart new file mode 100644 index 0000000..5dd2b13 --- /dev/null +++ b/lib/features/home/data/home_remote_datasource.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_remote_datasource.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$homeRemoteDataSourceHash() => + r'f9169530282256d0ebe446410526e2531fbb2286'; + +/// HomeRemoteDataSource Provider +/// +/// Copied from [homeRemoteDataSource]. +@ProviderFor(homeRemoteDataSource) +final homeRemoteDataSourceProvider = + AutoDisposeProvider.internal( + homeRemoteDataSource, + name: r'homeRemoteDataSourceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$homeRemoteDataSourceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef HomeRemoteDataSourceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/home/data/home_repository.dart b/lib/features/home/data/home_repository.dart new file mode 100644 index 0000000..6ee3165 --- /dev/null +++ b/lib/features/home/data/home_repository.dart @@ -0,0 +1,10 @@ +import 'models/content_response.dart'; + +/// 홈 화면 Repository 인터페이스 +abstract class HomeRepository { + /// 최근 콘텐츠 목록 조회 + Future getRecentContent({int? cursor, int size = 20}); + + /// 회원 콘텐츠 목록 조회 (인기 장소) + Future getMemberContent({int? cursor, int size = 30}); +} diff --git a/lib/features/home/data/home_repository_impl.dart b/lib/features/home/data/home_repository_impl.dart new file mode 100644 index 0000000..5ee6340 --- /dev/null +++ b/lib/features/home/data/home_repository_impl.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'home_repository.dart'; +import 'home_remote_datasource.dart'; +import 'models/content_response.dart'; + +part 'home_repository_impl.g.dart'; + +/// HomeRepository Provider +@riverpod +HomeRepository homeRepository(Ref ref) { + final remoteDataSource = ref.watch(homeRemoteDataSourceProvider); + return HomeRepositoryImpl(remoteDataSource); +} + +/// 홈 화면 Repository 구현체 +class HomeRepositoryImpl implements HomeRepository { + final HomeRemoteDataSource _remoteDataSource; + + HomeRepositoryImpl(this._remoteDataSource); + + @override + Future getRecentContent({ + int? cursor, + int size = 20, + }) async { + debugPrint('📝 HomeRepository: Getting recent content...'); + return await _remoteDataSource.fetchRecentContent( + cursor: cursor, + size: size, + ); + } + + @override + Future getMemberContent({ + int? cursor, + int size = 30, + }) async { + debugPrint('📝 HomeRepository: Getting member content...'); + return await _remoteDataSource.fetchMemberContent( + cursor: cursor, + size: size, + ); + } +} diff --git a/lib/features/home/data/home_repository_impl.g.dart b/lib/features/home/data/home_repository_impl.g.dart new file mode 100644 index 0000000..f3b5ec1 --- /dev/null +++ b/lib/features/home/data/home_repository_impl.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_repository_impl.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$homeRepositoryHash() => r'9acc6b90c62b3a1aed4bbeb288df5642c1761bfd'; + +/// HomeRepository Provider +/// +/// Copied from [homeRepository]. +@ProviderFor(homeRepository) +final homeRepositoryProvider = AutoDisposeProvider.internal( + homeRepository, + name: r'homeRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$homeRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef HomeRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/home/data/models/content_response.dart b/lib/features/home/data/models/content_response.dart new file mode 100644 index 0000000..4bd13b8 --- /dev/null +++ b/lib/features/home/data/models/content_response.dart @@ -0,0 +1,46 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'cursor_model.dart'; +import 'place_model.dart'; + +part 'content_response.freezed.dart'; +part 'content_response.g.dart'; + +/// 콘텐츠 아이템 (API 응답 단위) +@freezed +class ContentItem with _$ContentItem { + const factory ContentItem({ + /// 콘텐츠 ID + required int contentId, + + /// 원본 URL + String? sourceUrl, + + /// 콘텐츠 상태 + String? status, + + /// 생성일시 + String? createdAt, + + /// 콘텐츠에 포함된 장소 목록 + @Default([]) List places, + }) = _ContentItem; + + factory ContentItem.fromJson(Map json) => + _$ContentItemFromJson(json); +} + +/// 콘텐츠 목록 응답 (페이지네이션 포함) +@freezed +class ContentListResponse with _$ContentListResponse { + const factory ContentListResponse({ + /// 콘텐츠 아이템 목록 + @Default([]) List content, + + /// 페이지네이션 정보 + CursorModel? cursor, + }) = _ContentListResponse; + + factory ContentListResponse.fromJson(Map json) => + _$ContentListResponseFromJson(json); +} diff --git a/lib/features/home/data/models/content_response.freezed.dart b/lib/features/home/data/models/content_response.freezed.dart new file mode 100644 index 0000000..22965ed --- /dev/null +++ b/lib/features/home/data/models/content_response.freezed.dart @@ -0,0 +1,518 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'content_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +ContentItem _$ContentItemFromJson(Map json) { + return _ContentItem.fromJson(json); +} + +/// @nodoc +mixin _$ContentItem { + /// 콘텐츠 ID + int get contentId => throw _privateConstructorUsedError; + + /// 원본 URL + String? get sourceUrl => throw _privateConstructorUsedError; + + /// 콘텐츠 상태 + String? get status => throw _privateConstructorUsedError; + + /// 생성일시 + String? get createdAt => throw _privateConstructorUsedError; + + /// 콘텐츠에 포함된 장소 목록 + List get places => throw _privateConstructorUsedError; + + /// Serializes this ContentItem to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ContentItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ContentItemCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ContentItemCopyWith<$Res> { + factory $ContentItemCopyWith( + ContentItem value, + $Res Function(ContentItem) then, + ) = _$ContentItemCopyWithImpl<$Res, ContentItem>; + @useResult + $Res call({ + int contentId, + String? sourceUrl, + String? status, + String? createdAt, + List places, + }); +} + +/// @nodoc +class _$ContentItemCopyWithImpl<$Res, $Val extends ContentItem> + implements $ContentItemCopyWith<$Res> { + _$ContentItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ContentItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? contentId = null, + Object? sourceUrl = freezed, + Object? status = freezed, + Object? createdAt = freezed, + Object? places = null, + }) { + return _then( + _value.copyWith( + contentId: null == contentId + ? _value.contentId + : contentId // ignore: cast_nullable_to_non_nullable + as int, + sourceUrl: freezed == sourceUrl + ? _value.sourceUrl + : sourceUrl // ignore: cast_nullable_to_non_nullable + as String?, + status: freezed == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as String?, + places: null == places + ? _value.places + : places // ignore: cast_nullable_to_non_nullable + as List, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ContentItemImplCopyWith<$Res> + implements $ContentItemCopyWith<$Res> { + factory _$$ContentItemImplCopyWith( + _$ContentItemImpl value, + $Res Function(_$ContentItemImpl) then, + ) = __$$ContentItemImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int contentId, + String? sourceUrl, + String? status, + String? createdAt, + List places, + }); +} + +/// @nodoc +class __$$ContentItemImplCopyWithImpl<$Res> + extends _$ContentItemCopyWithImpl<$Res, _$ContentItemImpl> + implements _$$ContentItemImplCopyWith<$Res> { + __$$ContentItemImplCopyWithImpl( + _$ContentItemImpl _value, + $Res Function(_$ContentItemImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ContentItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? contentId = null, + Object? sourceUrl = freezed, + Object? status = freezed, + Object? createdAt = freezed, + Object? places = null, + }) { + return _then( + _$ContentItemImpl( + contentId: null == contentId + ? _value.contentId + : contentId // ignore: cast_nullable_to_non_nullable + as int, + sourceUrl: freezed == sourceUrl + ? _value.sourceUrl + : sourceUrl // ignore: cast_nullable_to_non_nullable + as String?, + status: freezed == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as String?, + places: null == places + ? _value._places + : places // ignore: cast_nullable_to_non_nullable + as List, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ContentItemImpl implements _ContentItem { + const _$ContentItemImpl({ + required this.contentId, + this.sourceUrl, + this.status, + this.createdAt, + final List places = const [], + }) : _places = places; + + factory _$ContentItemImpl.fromJson(Map json) => + _$$ContentItemImplFromJson(json); + + /// 콘텐츠 ID + @override + final int contentId; + + /// 원본 URL + @override + final String? sourceUrl; + + /// 콘텐츠 상태 + @override + final String? status; + + /// 생성일시 + @override + final String? createdAt; + + /// 콘텐츠에 포함된 장소 목록 + final List _places; + + /// 콘텐츠에 포함된 장소 목록 + @override + @JsonKey() + List get places { + if (_places is EqualUnmodifiableListView) return _places; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_places); + } + + @override + String toString() { + return 'ContentItem(contentId: $contentId, sourceUrl: $sourceUrl, status: $status, createdAt: $createdAt, places: $places)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ContentItemImpl && + (identical(other.contentId, contentId) || + other.contentId == contentId) && + (identical(other.sourceUrl, sourceUrl) || + other.sourceUrl == sourceUrl) && + (identical(other.status, status) || other.status == status) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + const DeepCollectionEquality().equals(other._places, _places)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + contentId, + sourceUrl, + status, + createdAt, + const DeepCollectionEquality().hash(_places), + ); + + /// Create a copy of ContentItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ContentItemImplCopyWith<_$ContentItemImpl> get copyWith => + __$$ContentItemImplCopyWithImpl<_$ContentItemImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ContentItemImplToJson(this); + } +} + +abstract class _ContentItem implements ContentItem { + const factory _ContentItem({ + required final int contentId, + final String? sourceUrl, + final String? status, + final String? createdAt, + final List places, + }) = _$ContentItemImpl; + + factory _ContentItem.fromJson(Map json) = + _$ContentItemImpl.fromJson; + + /// 콘텐츠 ID + @override + int get contentId; + + /// 원본 URL + @override + String? get sourceUrl; + + /// 콘텐츠 상태 + @override + String? get status; + + /// 생성일시 + @override + String? get createdAt; + + /// 콘텐츠에 포함된 장소 목록 + @override + List get places; + + /// Create a copy of ContentItem + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ContentItemImplCopyWith<_$ContentItemImpl> get copyWith => + throw _privateConstructorUsedError; +} + +ContentListResponse _$ContentListResponseFromJson(Map json) { + return _ContentListResponse.fromJson(json); +} + +/// @nodoc +mixin _$ContentListResponse { + /// 콘텐츠 아이템 목록 + List get content => throw _privateConstructorUsedError; + + /// 페이지네이션 정보 + CursorModel? get cursor => throw _privateConstructorUsedError; + + /// Serializes this ContentListResponse to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ContentListResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ContentListResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ContentListResponseCopyWith<$Res> { + factory $ContentListResponseCopyWith( + ContentListResponse value, + $Res Function(ContentListResponse) then, + ) = _$ContentListResponseCopyWithImpl<$Res, ContentListResponse>; + @useResult + $Res call({List content, CursorModel? cursor}); + + $CursorModelCopyWith<$Res>? get cursor; +} + +/// @nodoc +class _$ContentListResponseCopyWithImpl<$Res, $Val extends ContentListResponse> + implements $ContentListResponseCopyWith<$Res> { + _$ContentListResponseCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ContentListResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? content = null, Object? cursor = freezed}) { + return _then( + _value.copyWith( + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as List, + cursor: freezed == cursor + ? _value.cursor + : cursor // ignore: cast_nullable_to_non_nullable + as CursorModel?, + ) + as $Val, + ); + } + + /// Create a copy of ContentListResponse + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $CursorModelCopyWith<$Res>? get cursor { + if (_value.cursor == null) { + return null; + } + + return $CursorModelCopyWith<$Res>(_value.cursor!, (value) { + return _then(_value.copyWith(cursor: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$ContentListResponseImplCopyWith<$Res> + implements $ContentListResponseCopyWith<$Res> { + factory _$$ContentListResponseImplCopyWith( + _$ContentListResponseImpl value, + $Res Function(_$ContentListResponseImpl) then, + ) = __$$ContentListResponseImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({List content, CursorModel? cursor}); + + @override + $CursorModelCopyWith<$Res>? get cursor; +} + +/// @nodoc +class __$$ContentListResponseImplCopyWithImpl<$Res> + extends _$ContentListResponseCopyWithImpl<$Res, _$ContentListResponseImpl> + implements _$$ContentListResponseImplCopyWith<$Res> { + __$$ContentListResponseImplCopyWithImpl( + _$ContentListResponseImpl _value, + $Res Function(_$ContentListResponseImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ContentListResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? content = null, Object? cursor = freezed}) { + return _then( + _$ContentListResponseImpl( + content: null == content + ? _value._content + : content // ignore: cast_nullable_to_non_nullable + as List, + cursor: freezed == cursor + ? _value.cursor + : cursor // ignore: cast_nullable_to_non_nullable + as CursorModel?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ContentListResponseImpl implements _ContentListResponse { + const _$ContentListResponseImpl({ + final List content = const [], + this.cursor, + }) : _content = content; + + factory _$ContentListResponseImpl.fromJson(Map json) => + _$$ContentListResponseImplFromJson(json); + + /// 콘텐츠 아이템 목록 + final List _content; + + /// 콘텐츠 아이템 목록 + @override + @JsonKey() + List get content { + if (_content is EqualUnmodifiableListView) return _content; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_content); + } + + /// 페이지네이션 정보 + @override + final CursorModel? cursor; + + @override + String toString() { + return 'ContentListResponse(content: $content, cursor: $cursor)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ContentListResponseImpl && + const DeepCollectionEquality().equals(other._content, _content) && + (identical(other.cursor, cursor) || other.cursor == cursor)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_content), + cursor, + ); + + /// Create a copy of ContentListResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ContentListResponseImplCopyWith<_$ContentListResponseImpl> get copyWith => + __$$ContentListResponseImplCopyWithImpl<_$ContentListResponseImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$ContentListResponseImplToJson(this); + } +} + +abstract class _ContentListResponse implements ContentListResponse { + const factory _ContentListResponse({ + final List content, + final CursorModel? cursor, + }) = _$ContentListResponseImpl; + + factory _ContentListResponse.fromJson(Map json) = + _$ContentListResponseImpl.fromJson; + + /// 콘텐츠 아이템 목록 + @override + List get content; + + /// 페이지네이션 정보 + @override + CursorModel? get cursor; + + /// Create a copy of ContentListResponse + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ContentListResponseImplCopyWith<_$ContentListResponseImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/home/data/models/content_response.g.dart b/lib/features/home/data/models/content_response.g.dart new file mode 100644 index 0000000..d3287db --- /dev/null +++ b/lib/features/home/data/models/content_response.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'content_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ContentItemImpl _$$ContentItemImplFromJson(Map json) => + _$ContentItemImpl( + contentId: (json['contentId'] as num).toInt(), + sourceUrl: json['sourceUrl'] as String?, + status: json['status'] as String?, + createdAt: json['createdAt'] as String?, + places: + (json['places'] as List?) + ?.map((e) => PlaceModel.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$$ContentItemImplToJson(_$ContentItemImpl instance) => + { + 'contentId': instance.contentId, + 'sourceUrl': instance.sourceUrl, + 'status': instance.status, + 'createdAt': instance.createdAt, + 'places': instance.places, + }; + +_$ContentListResponseImpl _$$ContentListResponseImplFromJson( + Map json, +) => _$ContentListResponseImpl( + content: + (json['content'] as List?) + ?.map((e) => ContentItem.fromJson(e as Map)) + .toList() ?? + const [], + cursor: json['cursor'] == null + ? null + : CursorModel.fromJson(json['cursor'] as Map), +); + +Map _$$ContentListResponseImplToJson( + _$ContentListResponseImpl instance, +) => {'content': instance.content, 'cursor': instance.cursor}; diff --git a/lib/features/home/data/models/cursor_model.dart b/lib/features/home/data/models/cursor_model.dart new file mode 100644 index 0000000..afd0025 --- /dev/null +++ b/lib/features/home/data/models/cursor_model.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'cursor_model.freezed.dart'; +part 'cursor_model.g.dart'; + +/// 커서 기반 페이지네이션 모델 +@freezed +class CursorModel with _$CursorModel { + const factory CursorModel({ + /// 다음 페이지 존재 여부 + @Default(false) bool hasNext, + + /// 다음 커서 값 (다음 페이지 요청 시 사용) + int? nextCursor, + + /// 현재 페이지 아이템 수 + @Default(0) int size, + }) = _CursorModel; + + factory CursorModel.fromJson(Map json) => + _$CursorModelFromJson(json); +} diff --git a/lib/features/home/data/models/cursor_model.freezed.dart b/lib/features/home/data/models/cursor_model.freezed.dart new file mode 100644 index 0000000..3022ec5 --- /dev/null +++ b/lib/features/home/data/models/cursor_model.freezed.dart @@ -0,0 +1,229 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'cursor_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +CursorModel _$CursorModelFromJson(Map json) { + return _CursorModel.fromJson(json); +} + +/// @nodoc +mixin _$CursorModel { + /// 다음 페이지 존재 여부 + bool get hasNext => throw _privateConstructorUsedError; + + /// 다음 커서 값 (다음 페이지 요청 시 사용) + int? get nextCursor => throw _privateConstructorUsedError; + + /// 현재 페이지 아이템 수 + int get size => throw _privateConstructorUsedError; + + /// Serializes this CursorModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of CursorModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $CursorModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CursorModelCopyWith<$Res> { + factory $CursorModelCopyWith( + CursorModel value, + $Res Function(CursorModel) then, + ) = _$CursorModelCopyWithImpl<$Res, CursorModel>; + @useResult + $Res call({bool hasNext, int? nextCursor, int size}); +} + +/// @nodoc +class _$CursorModelCopyWithImpl<$Res, $Val extends CursorModel> + implements $CursorModelCopyWith<$Res> { + _$CursorModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CursorModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? hasNext = null, + Object? nextCursor = freezed, + Object? size = null, + }) { + return _then( + _value.copyWith( + hasNext: null == hasNext + ? _value.hasNext + : hasNext // ignore: cast_nullable_to_non_nullable + as bool, + nextCursor: freezed == nextCursor + ? _value.nextCursor + : nextCursor // ignore: cast_nullable_to_non_nullable + as int?, + size: null == size + ? _value.size + : size // ignore: cast_nullable_to_non_nullable + as int, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$CursorModelImplCopyWith<$Res> + implements $CursorModelCopyWith<$Res> { + factory _$$CursorModelImplCopyWith( + _$CursorModelImpl value, + $Res Function(_$CursorModelImpl) then, + ) = __$$CursorModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool hasNext, int? nextCursor, int size}); +} + +/// @nodoc +class __$$CursorModelImplCopyWithImpl<$Res> + extends _$CursorModelCopyWithImpl<$Res, _$CursorModelImpl> + implements _$$CursorModelImplCopyWith<$Res> { + __$$CursorModelImplCopyWithImpl( + _$CursorModelImpl _value, + $Res Function(_$CursorModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of CursorModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? hasNext = null, + Object? nextCursor = freezed, + Object? size = null, + }) { + return _then( + _$CursorModelImpl( + hasNext: null == hasNext + ? _value.hasNext + : hasNext // ignore: cast_nullable_to_non_nullable + as bool, + nextCursor: freezed == nextCursor + ? _value.nextCursor + : nextCursor // ignore: cast_nullable_to_non_nullable + as int?, + size: null == size + ? _value.size + : size // ignore: cast_nullable_to_non_nullable + as int, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$CursorModelImpl implements _CursorModel { + const _$CursorModelImpl({ + this.hasNext = false, + this.nextCursor, + this.size = 0, + }); + + factory _$CursorModelImpl.fromJson(Map json) => + _$$CursorModelImplFromJson(json); + + /// 다음 페이지 존재 여부 + @override + @JsonKey() + final bool hasNext; + + /// 다음 커서 값 (다음 페이지 요청 시 사용) + @override + final int? nextCursor; + + /// 현재 페이지 아이템 수 + @override + @JsonKey() + final int size; + + @override + String toString() { + return 'CursorModel(hasNext: $hasNext, nextCursor: $nextCursor, size: $size)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CursorModelImpl && + (identical(other.hasNext, hasNext) || other.hasNext == hasNext) && + (identical(other.nextCursor, nextCursor) || + other.nextCursor == nextCursor) && + (identical(other.size, size) || other.size == size)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hasNext, nextCursor, size); + + /// Create a copy of CursorModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CursorModelImplCopyWith<_$CursorModelImpl> get copyWith => + __$$CursorModelImplCopyWithImpl<_$CursorModelImpl>(this, _$identity); + + @override + Map toJson() { + return _$$CursorModelImplToJson(this); + } +} + +abstract class _CursorModel implements CursorModel { + const factory _CursorModel({ + final bool hasNext, + final int? nextCursor, + final int size, + }) = _$CursorModelImpl; + + factory _CursorModel.fromJson(Map json) = + _$CursorModelImpl.fromJson; + + /// 다음 페이지 존재 여부 + @override + bool get hasNext; + + /// 다음 커서 값 (다음 페이지 요청 시 사용) + @override + int? get nextCursor; + + /// 현재 페이지 아이템 수 + @override + int get size; + + /// Create a copy of CursorModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CursorModelImplCopyWith<_$CursorModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/home/data/models/cursor_model.g.dart b/lib/features/home/data/models/cursor_model.g.dart new file mode 100644 index 0000000..3d2efc8 --- /dev/null +++ b/lib/features/home/data/models/cursor_model.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cursor_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$CursorModelImpl _$$CursorModelImplFromJson(Map json) => + _$CursorModelImpl( + hasNext: json['hasNext'] as bool? ?? false, + nextCursor: (json['nextCursor'] as num?)?.toInt(), + size: (json['size'] as num?)?.toInt() ?? 0, + ); + +Map _$$CursorModelImplToJson(_$CursorModelImpl instance) => + { + 'hasNext': instance.hasNext, + 'nextCursor': instance.nextCursor, + 'size': instance.size, + }; diff --git a/lib/features/home/data/models/place_model.dart b/lib/features/home/data/models/place_model.dart new file mode 100644 index 0000000..ff2618c --- /dev/null +++ b/lib/features/home/data/models/place_model.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'place_model.freezed.dart'; +part 'place_model.g.dart'; + +/// 장소 모델 +@freezed +class PlaceModel with _$PlaceModel { + const factory PlaceModel({ + /// 장소 ID + required int placeId, + + /// 장소명 + required String placeName, + + /// 주소 + String? address, + + /// 위도 + double? latitude, + + /// 경도 + double? longitude, + + /// 카테고리 + String? category, + + /// 태그 목록 + @Default([]) List tags, + + /// 대표 이미지 URL + String? imageUrl, + + /// 콘텐츠 ID (상위 콘텐츠) + int? contentId, + }) = _PlaceModel; + + factory PlaceModel.fromJson(Map json) => + _$PlaceModelFromJson(json); +} diff --git a/lib/features/home/data/models/place_model.freezed.dart b/lib/features/home/data/models/place_model.freezed.dart new file mode 100644 index 0000000..92f65e2 --- /dev/null +++ b/lib/features/home/data/models/place_model.freezed.dart @@ -0,0 +1,415 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'place_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +PlaceModel _$PlaceModelFromJson(Map json) { + return _PlaceModel.fromJson(json); +} + +/// @nodoc +mixin _$PlaceModel { + /// 장소 ID + int get placeId => throw _privateConstructorUsedError; + + /// 장소명 + String get placeName => throw _privateConstructorUsedError; + + /// 주소 + String? get address => throw _privateConstructorUsedError; + + /// 위도 + double? get latitude => throw _privateConstructorUsedError; + + /// 경도 + double? get longitude => throw _privateConstructorUsedError; + + /// 카테고리 + String? get category => throw _privateConstructorUsedError; + + /// 태그 목록 + List get tags => throw _privateConstructorUsedError; + + /// 대표 이미지 URL + String? get imageUrl => throw _privateConstructorUsedError; + + /// 콘텐츠 ID (상위 콘텐츠) + int? get contentId => throw _privateConstructorUsedError; + + /// Serializes this PlaceModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of PlaceModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $PlaceModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaceModelCopyWith<$Res> { + factory $PlaceModelCopyWith( + PlaceModel value, + $Res Function(PlaceModel) then, + ) = _$PlaceModelCopyWithImpl<$Res, PlaceModel>; + @useResult + $Res call({ + int placeId, + String placeName, + String? address, + double? latitude, + double? longitude, + String? category, + List tags, + String? imageUrl, + int? contentId, + }); +} + +/// @nodoc +class _$PlaceModelCopyWithImpl<$Res, $Val extends PlaceModel> + implements $PlaceModelCopyWith<$Res> { + _$PlaceModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of PlaceModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? placeId = null, + Object? placeName = null, + Object? address = freezed, + Object? latitude = freezed, + Object? longitude = freezed, + Object? category = freezed, + Object? tags = null, + Object? imageUrl = freezed, + Object? contentId = freezed, + }) { + return _then( + _value.copyWith( + placeId: null == placeId + ? _value.placeId + : placeId // ignore: cast_nullable_to_non_nullable + as int, + placeName: null == placeName + ? _value.placeName + : placeName // ignore: cast_nullable_to_non_nullable + as String, + address: freezed == address + ? _value.address + : address // ignore: cast_nullable_to_non_nullable + as String?, + latitude: freezed == latitude + ? _value.latitude + : latitude // ignore: cast_nullable_to_non_nullable + as double?, + longitude: freezed == longitude + ? _value.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double?, + category: freezed == category + ? _value.category + : category // ignore: cast_nullable_to_non_nullable + as String?, + tags: null == tags + ? _value.tags + : tags // ignore: cast_nullable_to_non_nullable + as List, + imageUrl: freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, + contentId: freezed == contentId + ? _value.contentId + : contentId // ignore: cast_nullable_to_non_nullable + as int?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$PlaceModelImplCopyWith<$Res> + implements $PlaceModelCopyWith<$Res> { + factory _$$PlaceModelImplCopyWith( + _$PlaceModelImpl value, + $Res Function(_$PlaceModelImpl) then, + ) = __$$PlaceModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int placeId, + String placeName, + String? address, + double? latitude, + double? longitude, + String? category, + List tags, + String? imageUrl, + int? contentId, + }); +} + +/// @nodoc +class __$$PlaceModelImplCopyWithImpl<$Res> + extends _$PlaceModelCopyWithImpl<$Res, _$PlaceModelImpl> + implements _$$PlaceModelImplCopyWith<$Res> { + __$$PlaceModelImplCopyWithImpl( + _$PlaceModelImpl _value, + $Res Function(_$PlaceModelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of PlaceModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? placeId = null, + Object? placeName = null, + Object? address = freezed, + Object? latitude = freezed, + Object? longitude = freezed, + Object? category = freezed, + Object? tags = null, + Object? imageUrl = freezed, + Object? contentId = freezed, + }) { + return _then( + _$PlaceModelImpl( + placeId: null == placeId + ? _value.placeId + : placeId // ignore: cast_nullable_to_non_nullable + as int, + placeName: null == placeName + ? _value.placeName + : placeName // ignore: cast_nullable_to_non_nullable + as String, + address: freezed == address + ? _value.address + : address // ignore: cast_nullable_to_non_nullable + as String?, + latitude: freezed == latitude + ? _value.latitude + : latitude // ignore: cast_nullable_to_non_nullable + as double?, + longitude: freezed == longitude + ? _value.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double?, + category: freezed == category + ? _value.category + : category // ignore: cast_nullable_to_non_nullable + as String?, + tags: null == tags + ? _value._tags + : tags // ignore: cast_nullable_to_non_nullable + as List, + imageUrl: freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, + contentId: freezed == contentId + ? _value.contentId + : contentId // ignore: cast_nullable_to_non_nullable + as int?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaceModelImpl implements _PlaceModel { + const _$PlaceModelImpl({ + required this.placeId, + required this.placeName, + this.address, + this.latitude, + this.longitude, + this.category, + final List tags = const [], + this.imageUrl, + this.contentId, + }) : _tags = tags; + + factory _$PlaceModelImpl.fromJson(Map json) => + _$$PlaceModelImplFromJson(json); + + /// 장소 ID + @override + final int placeId; + + /// 장소명 + @override + final String placeName; + + /// 주소 + @override + final String? address; + + /// 위도 + @override + final double? latitude; + + /// 경도 + @override + final double? longitude; + + /// 카테고리 + @override + final String? category; + + /// 태그 목록 + final List _tags; + + /// 태그 목록 + @override + @JsonKey() + List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); + } + + /// 대표 이미지 URL + @override + final String? imageUrl; + + /// 콘텐츠 ID (상위 콘텐츠) + @override + final int? contentId; + + @override + String toString() { + return 'PlaceModel(placeId: $placeId, placeName: $placeName, address: $address, latitude: $latitude, longitude: $longitude, category: $category, tags: $tags, imageUrl: $imageUrl, contentId: $contentId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaceModelImpl && + (identical(other.placeId, placeId) || other.placeId == placeId) && + (identical(other.placeName, placeName) || + other.placeName == placeName) && + (identical(other.address, address) || other.address == address) && + (identical(other.latitude, latitude) || + other.latitude == latitude) && + (identical(other.longitude, longitude) || + other.longitude == longitude) && + (identical(other.category, category) || + other.category == category) && + const DeepCollectionEquality().equals(other._tags, _tags) && + (identical(other.imageUrl, imageUrl) || + other.imageUrl == imageUrl) && + (identical(other.contentId, contentId) || + other.contentId == contentId)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + placeId, + placeName, + address, + latitude, + longitude, + category, + const DeepCollectionEquality().hash(_tags), + imageUrl, + contentId, + ); + + /// Create a copy of PlaceModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PlaceModelImplCopyWith<_$PlaceModelImpl> get copyWith => + __$$PlaceModelImplCopyWithImpl<_$PlaceModelImpl>(this, _$identity); + + @override + Map toJson() { + return _$$PlaceModelImplToJson(this); + } +} + +abstract class _PlaceModel implements PlaceModel { + const factory _PlaceModel({ + required final int placeId, + required final String placeName, + final String? address, + final double? latitude, + final double? longitude, + final String? category, + final List tags, + final String? imageUrl, + final int? contentId, + }) = _$PlaceModelImpl; + + factory _PlaceModel.fromJson(Map json) = + _$PlaceModelImpl.fromJson; + + /// 장소 ID + @override + int get placeId; + + /// 장소명 + @override + String get placeName; + + /// 주소 + @override + String? get address; + + /// 위도 + @override + double? get latitude; + + /// 경도 + @override + double? get longitude; + + /// 카테고리 + @override + String? get category; + + /// 태그 목록 + @override + List get tags; + + /// 대표 이미지 URL + @override + String? get imageUrl; + + /// 콘텐츠 ID (상위 콘텐츠) + @override + int? get contentId; + + /// Create a copy of PlaceModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PlaceModelImplCopyWith<_$PlaceModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/home/data/models/place_model.g.dart b/lib/features/home/data/models/place_model.g.dart new file mode 100644 index 0000000..bf5ad60 --- /dev/null +++ b/lib/features/home/data/models/place_model.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'place_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$PlaceModelImpl _$$PlaceModelImplFromJson(Map json) => + _$PlaceModelImpl( + placeId: (json['placeId'] as num).toInt(), + placeName: json['placeName'] as String, + address: json['address'] as String?, + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + category: json['category'] as String?, + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + imageUrl: json['imageUrl'] as String?, + contentId: (json['contentId'] as num?)?.toInt(), + ); + +Map _$$PlaceModelImplToJson(_$PlaceModelImpl instance) => + { + 'placeId': instance.placeId, + 'placeName': instance.placeName, + 'address': instance.address, + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'category': instance.category, + 'tags': instance.tags, + 'imageUrl': instance.imageUrl, + 'contentId': instance.contentId, + }; diff --git a/lib/features/home/presentation/home_provider.dart b/lib/features/home/presentation/home_provider.dart new file mode 100644 index 0000000..e420334 --- /dev/null +++ b/lib/features/home/presentation/home_provider.dart @@ -0,0 +1,143 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/models/place_model.dart'; +import '../data/home_repository_impl.dart'; + +part 'home_provider.freezed.dart'; +part 'home_provider.g.dart'; + +/// 홈 화면 상태 +@freezed +class HomeState with _$HomeState { + const factory HomeState({ + /// 최신 장소 목록 + @Default([]) List recentPlaces, + + /// 인기 장소 목록 + @Default([]) List popularPlaces, + + /// 최신 장소 로딩 중 + @Default(false) bool isLoadingRecent, + + /// 인기 장소 로딩 중 + @Default(false) bool isLoadingPopular, + + /// 추가 로딩 중 (무한 스크롤) + @Default(false) bool isLoadingMore, + + /// 다음 페이지 커서 (최신 장소) + int? recentNextCursor, + + /// 다음 페이지 존재 여부 (최신 장소) + @Default(true) bool recentHasNext, + + /// 에러 메시지 + String? errorMessage, + + /// 초기 로딩 완료 여부 + @Default(false) bool isInitialized, + }) = _HomeState; +} + +/// 홈 화면 Notifier +@riverpod +class HomeNotifier extends _$HomeNotifier { + @override + HomeState build() { + Future.microtask(() => _initialize()); + return const HomeState(); + } + + Future _initialize() async { + await Future.wait([ + fetchRecentPlaces(), + fetchPopularPlaces(), + ]); + state = state.copyWith(isInitialized: true); + } + + /// 최신 장소 목록 조회 (초기 로드) + Future fetchRecentPlaces() async { + state = state.copyWith(isLoadingRecent: true, errorMessage: null); + + try { + final repository = ref.read(homeRepositoryProvider); + final response = await repository.getRecentContent(); + + final places = + response.content.expand((item) => item.places).toList(); + + state = state.copyWith( + recentPlaces: places, + isLoadingRecent: false, + recentNextCursor: response.cursor?.nextCursor, + recentHasNext: response.cursor?.hasNext ?? false, + ); + } catch (e) { + debugPrint('❌ HomeNotifier: Failed to fetch recent places: $e'); + state = state.copyWith( + isLoadingRecent: false, + errorMessage: '최신 장소를 불러올 수 없습니다', + ); + } + } + + /// 최신 장소 추가 로드 (무한 스크롤) + Future fetchMoreRecentPlaces() async { + if (!state.recentHasNext || state.isLoadingMore) return; + + state = state.copyWith(isLoadingMore: true); + + try { + final repository = ref.read(homeRepositoryProvider); + final response = await repository.getRecentContent( + cursor: state.recentNextCursor, + ); + + final newPlaces = + response.content.expand((item) => item.places).toList(); + + state = state.copyWith( + recentPlaces: [...state.recentPlaces, ...newPlaces], + isLoadingMore: false, + recentNextCursor: response.cursor?.nextCursor, + recentHasNext: response.cursor?.hasNext ?? false, + ); + } catch (e) { + debugPrint('❌ HomeNotifier: Failed to fetch more places: $e'); + state = state.copyWith(isLoadingMore: false); + } + } + + /// 인기 장소 목록 조회 + Future fetchPopularPlaces() async { + state = state.copyWith(isLoadingPopular: true, errorMessage: null); + + try { + final repository = ref.read(homeRepositoryProvider); + final response = await repository.getMemberContent(); + + final places = + response.content.expand((item) => item.places).toList(); + + state = state.copyWith( + popularPlaces: places, + isLoadingPopular: false, + ); + } catch (e) { + debugPrint('❌ HomeNotifier: Failed to fetch popular places: $e'); + state = state.copyWith( + isLoadingPopular: false, + errorMessage: '인기 장소를 불러올 수 없습니다', + ); + } + } + + /// 전체 새로고침 + Future refresh() async { + state = const HomeState(); + await _initialize(); + } +} diff --git a/lib/features/home/presentation/home_provider.freezed.dart b/lib/features/home/presentation/home_provider.freezed.dart new file mode 100644 index 0000000..8f95fc3 --- /dev/null +++ b/lib/features/home/presentation/home_provider.freezed.dart @@ -0,0 +1,431 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'home_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$HomeState { + /// 최신 장소 목록 + List get recentPlaces => throw _privateConstructorUsedError; + + /// 인기 장소 목록 + List get popularPlaces => throw _privateConstructorUsedError; + + /// 최신 장소 로딩 중 + bool get isLoadingRecent => throw _privateConstructorUsedError; + + /// 인기 장소 로딩 중 + bool get isLoadingPopular => throw _privateConstructorUsedError; + + /// 추가 로딩 중 (무한 스크롤) + bool get isLoadingMore => throw _privateConstructorUsedError; + + /// 다음 페이지 커서 (최신 장소) + int? get recentNextCursor => throw _privateConstructorUsedError; + + /// 다음 페이지 존재 여부 (최신 장소) + bool get recentHasNext => throw _privateConstructorUsedError; + + /// 에러 메시지 + String? get errorMessage => throw _privateConstructorUsedError; + + /// 초기 로딩 완료 여부 + bool get isInitialized => throw _privateConstructorUsedError; + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $HomeStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $HomeStateCopyWith<$Res> { + factory $HomeStateCopyWith(HomeState value, $Res Function(HomeState) then) = + _$HomeStateCopyWithImpl<$Res, HomeState>; + @useResult + $Res call({ + List recentPlaces, + List popularPlaces, + bool isLoadingRecent, + bool isLoadingPopular, + bool isLoadingMore, + int? recentNextCursor, + bool recentHasNext, + String? errorMessage, + bool isInitialized, + }); +} + +/// @nodoc +class _$HomeStateCopyWithImpl<$Res, $Val extends HomeState> + implements $HomeStateCopyWith<$Res> { + _$HomeStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? recentPlaces = null, + Object? popularPlaces = null, + Object? isLoadingRecent = null, + Object? isLoadingPopular = null, + Object? isLoadingMore = null, + Object? recentNextCursor = freezed, + Object? recentHasNext = null, + Object? errorMessage = freezed, + Object? isInitialized = null, + }) { + return _then( + _value.copyWith( + recentPlaces: null == recentPlaces + ? _value.recentPlaces + : recentPlaces // ignore: cast_nullable_to_non_nullable + as List, + popularPlaces: null == popularPlaces + ? _value.popularPlaces + : popularPlaces // ignore: cast_nullable_to_non_nullable + as List, + isLoadingRecent: null == isLoadingRecent + ? _value.isLoadingRecent + : isLoadingRecent // ignore: cast_nullable_to_non_nullable + as bool, + isLoadingPopular: null == isLoadingPopular + ? _value.isLoadingPopular + : isLoadingPopular // ignore: cast_nullable_to_non_nullable + as bool, + isLoadingMore: null == isLoadingMore + ? _value.isLoadingMore + : isLoadingMore // ignore: cast_nullable_to_non_nullable + as bool, + recentNextCursor: freezed == recentNextCursor + ? _value.recentNextCursor + : recentNextCursor // ignore: cast_nullable_to_non_nullable + as int?, + recentHasNext: null == recentHasNext + ? _value.recentHasNext + : recentHasNext // ignore: cast_nullable_to_non_nullable + as bool, + errorMessage: freezed == errorMessage + ? _value.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + isInitialized: null == isInitialized + ? _value.isInitialized + : isInitialized // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$HomeStateImplCopyWith<$Res> + implements $HomeStateCopyWith<$Res> { + factory _$$HomeStateImplCopyWith( + _$HomeStateImpl value, + $Res Function(_$HomeStateImpl) then, + ) = __$$HomeStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + List recentPlaces, + List popularPlaces, + bool isLoadingRecent, + bool isLoadingPopular, + bool isLoadingMore, + int? recentNextCursor, + bool recentHasNext, + String? errorMessage, + bool isInitialized, + }); +} + +/// @nodoc +class __$$HomeStateImplCopyWithImpl<$Res> + extends _$HomeStateCopyWithImpl<$Res, _$HomeStateImpl> + implements _$$HomeStateImplCopyWith<$Res> { + __$$HomeStateImplCopyWithImpl( + _$HomeStateImpl _value, + $Res Function(_$HomeStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? recentPlaces = null, + Object? popularPlaces = null, + Object? isLoadingRecent = null, + Object? isLoadingPopular = null, + Object? isLoadingMore = null, + Object? recentNextCursor = freezed, + Object? recentHasNext = null, + Object? errorMessage = freezed, + Object? isInitialized = null, + }) { + return _then( + _$HomeStateImpl( + recentPlaces: null == recentPlaces + ? _value._recentPlaces + : recentPlaces // ignore: cast_nullable_to_non_nullable + as List, + popularPlaces: null == popularPlaces + ? _value._popularPlaces + : popularPlaces // ignore: cast_nullable_to_non_nullable + as List, + isLoadingRecent: null == isLoadingRecent + ? _value.isLoadingRecent + : isLoadingRecent // ignore: cast_nullable_to_non_nullable + as bool, + isLoadingPopular: null == isLoadingPopular + ? _value.isLoadingPopular + : isLoadingPopular // ignore: cast_nullable_to_non_nullable + as bool, + isLoadingMore: null == isLoadingMore + ? _value.isLoadingMore + : isLoadingMore // ignore: cast_nullable_to_non_nullable + as bool, + recentNextCursor: freezed == recentNextCursor + ? _value.recentNextCursor + : recentNextCursor // ignore: cast_nullable_to_non_nullable + as int?, + recentHasNext: null == recentHasNext + ? _value.recentHasNext + : recentHasNext // ignore: cast_nullable_to_non_nullable + as bool, + errorMessage: freezed == errorMessage + ? _value.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + isInitialized: null == isInitialized + ? _value.isInitialized + : isInitialized // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc + +class _$HomeStateImpl with DiagnosticableTreeMixin implements _HomeState { + const _$HomeStateImpl({ + final List recentPlaces = const [], + final List popularPlaces = const [], + this.isLoadingRecent = false, + this.isLoadingPopular = false, + this.isLoadingMore = false, + this.recentNextCursor, + this.recentHasNext = true, + this.errorMessage, + this.isInitialized = false, + }) : _recentPlaces = recentPlaces, + _popularPlaces = popularPlaces; + + /// 최신 장소 목록 + final List _recentPlaces; + + /// 최신 장소 목록 + @override + @JsonKey() + List get recentPlaces { + if (_recentPlaces is EqualUnmodifiableListView) return _recentPlaces; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_recentPlaces); + } + + /// 인기 장소 목록 + final List _popularPlaces; + + /// 인기 장소 목록 + @override + @JsonKey() + List get popularPlaces { + if (_popularPlaces is EqualUnmodifiableListView) return _popularPlaces; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_popularPlaces); + } + + /// 최신 장소 로딩 중 + @override + @JsonKey() + final bool isLoadingRecent; + + /// 인기 장소 로딩 중 + @override + @JsonKey() + final bool isLoadingPopular; + + /// 추가 로딩 중 (무한 스크롤) + @override + @JsonKey() + final bool isLoadingMore; + + /// 다음 페이지 커서 (최신 장소) + @override + final int? recentNextCursor; + + /// 다음 페이지 존재 여부 (최신 장소) + @override + @JsonKey() + final bool recentHasNext; + + /// 에러 메시지 + @override + final String? errorMessage; + + /// 초기 로딩 완료 여부 + @override + @JsonKey() + final bool isInitialized; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'HomeState(recentPlaces: $recentPlaces, popularPlaces: $popularPlaces, isLoadingRecent: $isLoadingRecent, isLoadingPopular: $isLoadingPopular, isLoadingMore: $isLoadingMore, recentNextCursor: $recentNextCursor, recentHasNext: $recentHasNext, errorMessage: $errorMessage, isInitialized: $isInitialized)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'HomeState')) + ..add(DiagnosticsProperty('recentPlaces', recentPlaces)) + ..add(DiagnosticsProperty('popularPlaces', popularPlaces)) + ..add(DiagnosticsProperty('isLoadingRecent', isLoadingRecent)) + ..add(DiagnosticsProperty('isLoadingPopular', isLoadingPopular)) + ..add(DiagnosticsProperty('isLoadingMore', isLoadingMore)) + ..add(DiagnosticsProperty('recentNextCursor', recentNextCursor)) + ..add(DiagnosticsProperty('recentHasNext', recentHasNext)) + ..add(DiagnosticsProperty('errorMessage', errorMessage)) + ..add(DiagnosticsProperty('isInitialized', isInitialized)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$HomeStateImpl && + const DeepCollectionEquality().equals( + other._recentPlaces, + _recentPlaces, + ) && + const DeepCollectionEquality().equals( + other._popularPlaces, + _popularPlaces, + ) && + (identical(other.isLoadingRecent, isLoadingRecent) || + other.isLoadingRecent == isLoadingRecent) && + (identical(other.isLoadingPopular, isLoadingPopular) || + other.isLoadingPopular == isLoadingPopular) && + (identical(other.isLoadingMore, isLoadingMore) || + other.isLoadingMore == isLoadingMore) && + (identical(other.recentNextCursor, recentNextCursor) || + other.recentNextCursor == recentNextCursor) && + (identical(other.recentHasNext, recentHasNext) || + other.recentHasNext == recentHasNext) && + (identical(other.errorMessage, errorMessage) || + other.errorMessage == errorMessage) && + (identical(other.isInitialized, isInitialized) || + other.isInitialized == isInitialized)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_recentPlaces), + const DeepCollectionEquality().hash(_popularPlaces), + isLoadingRecent, + isLoadingPopular, + isLoadingMore, + recentNextCursor, + recentHasNext, + errorMessage, + isInitialized, + ); + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$HomeStateImplCopyWith<_$HomeStateImpl> get copyWith => + __$$HomeStateImplCopyWithImpl<_$HomeStateImpl>(this, _$identity); +} + +abstract class _HomeState implements HomeState { + const factory _HomeState({ + final List recentPlaces, + final List popularPlaces, + final bool isLoadingRecent, + final bool isLoadingPopular, + final bool isLoadingMore, + final int? recentNextCursor, + final bool recentHasNext, + final String? errorMessage, + final bool isInitialized, + }) = _$HomeStateImpl; + + /// 최신 장소 목록 + @override + List get recentPlaces; + + /// 인기 장소 목록 + @override + List get popularPlaces; + + /// 최신 장소 로딩 중 + @override + bool get isLoadingRecent; + + /// 인기 장소 로딩 중 + @override + bool get isLoadingPopular; + + /// 추가 로딩 중 (무한 스크롤) + @override + bool get isLoadingMore; + + /// 다음 페이지 커서 (최신 장소) + @override + int? get recentNextCursor; + + /// 다음 페이지 존재 여부 (최신 장소) + @override + bool get recentHasNext; + + /// 에러 메시지 + @override + String? get errorMessage; + + /// 초기 로딩 완료 여부 + @override + bool get isInitialized; + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$HomeStateImplCopyWith<_$HomeStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/home/presentation/home_provider.g.dart b/lib/features/home/presentation/home_provider.g.dart new file mode 100644 index 0000000..ca1ed4a --- /dev/null +++ b/lib/features/home/presentation/home_provider.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$homeNotifierHash() => r'b8dc0b493c3601ec697650b1450d1f7e12983150'; + +/// 홈 화면 Notifier +/// +/// Copied from [HomeNotifier]. +@ProviderFor(HomeNotifier) +final homeNotifierProvider = + AutoDisposeNotifierProvider.internal( + HomeNotifier.new, + name: r'homeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$homeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$HomeNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 94efcb6..b883b89 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,28 +1,77 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; -import '../../../../router/route_paths.dart'; -import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../../../common/constants/home_colors.dart'; +import '../../../../routing/route_paths.dart'; +import '../../../auth/presentation/auth_provider.dart'; +import '../home_provider.dart'; +import '../widgets/home_empty_state.dart'; +import '../widgets/home_error_state.dart'; +import '../widgets/home_loading_shimmer.dart'; +import '../widgets/place_card.dart'; -/// 홈 화면 -/// -/// 인증 완료 후 진입하는 메인 화면입니다. -/// TODO: 실제 MapSy 기능 구현 필요 -class HomePage extends ConsumerWidget { +/// 홈 화면 (씀 스타일: 탭 전환 + 미니멀 카드 리스트) +class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final authState = ref.watch(authNotifierProvider); + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + final _recentScrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _recentScrollController.addListener(_onRecentScroll); + } + + @override + void dispose() { + _tabController.dispose(); + _recentScrollController.removeListener(_onRecentScroll); + _recentScrollController.dispose(); + super.dispose(); + } + + void _onRecentScroll() { + if (_recentScrollController.position.pixels >= + _recentScrollController.position.maxScrollExtent - 200) { + ref.read(homeNotifierProvider.notifier).fetchMoreRecentPlaces(); + } + } + + @override + Widget build(BuildContext context) { + final homeState = ref.watch(homeNotifierProvider); return Scaffold( + backgroundColor: HomeColors.background, appBar: AppBar( - title: const Text('MapSy'), + backgroundColor: HomeColors.background, + elevation: 0, + scrolledUnderElevation: 0, + title: Text( + 'MapSy', + style: TextStyle( + color: HomeColors.textPrimary, + fontSize: 20.sp, + fontWeight: FontWeight.w600, + ), + ), actions: [ - // 로그아웃 버튼 IconButton( - icon: const Icon(Icons.logout), + icon: Icon( + Icons.logout, + color: HomeColors.iconPrimary, + size: 22.sp, + ), onPressed: () async { await ref.read(authNotifierProvider.notifier).signOut(); if (context.mounted) { @@ -32,36 +81,104 @@ class HomePage extends ConsumerWidget { tooltip: '로그아웃', ), ], + bottom: TabBar( + controller: _tabController, + labelColor: HomeColors.textPrimary, + unselectedLabelColor: HomeColors.iconSecondary, + labelStyle: TextStyle(fontSize: 15.sp, fontWeight: FontWeight.w600), + unselectedLabelStyle: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w400, + ), + indicatorColor: HomeColors.textPrimary, + indicatorWeight: 2, + dividerColor: HomeColors.divider, + tabs: const [ + Tab(text: '최신순'), + Tab(text: '인기순'), + ], + ), ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.map, size: 100, color: Colors.blue), - const SizedBox(height: 24), - const Text( - 'MapSy 홈 화면', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - authState.when( - data: (user) => Text( - '환영합니다, ${user?.displayName ?? user?.email ?? "사용자"}님!', - style: const TextStyle(fontSize: 16), - ), - loading: () => const CircularProgressIndicator(), - error: (error, stack) => Text( - '사용자 정보를 불러올 수 없습니다', - style: TextStyle(color: Colors.red[700]), + body: _buildBody(homeState), + ); + } + + Widget _buildBody(HomeState state) { + // 초기 로딩 + if (!state.isInitialized && + (state.isLoadingRecent || state.isLoadingPopular)) { + return const SingleChildScrollView(child: HomeLoadingShimmer()); + } + + // 에러 상태 (데이터 없이 에러) + if (state.errorMessage != null && + state.recentPlaces.isEmpty && + state.popularPlaces.isEmpty) { + return HomeErrorState( + message: state.errorMessage!, + onRetry: () => ref.read(homeNotifierProvider.notifier).refresh(), + ); + } + + return TabBarView( + controller: _tabController, + children: [_buildRecentTab(state), _buildPopularTab(state)], + ); + } + + /// 최신순 탭 + Widget _buildRecentTab(HomeState state) { + if (state.recentPlaces.isEmpty && !state.isLoadingRecent) { + return const HomeEmptyState(message: '아직 등록된 장소가 없습니다'); + } + + return RefreshIndicator( + onRefresh: () => ref.read(homeNotifierProvider.notifier).refresh(), + color: HomeColors.textPrimary, + backgroundColor: HomeColors.background, + child: ListView.builder( + controller: _recentScrollController, + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: state.recentPlaces.length + (state.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index < state.recentPlaces.length) { + return PlaceCard(place: state.recentPlaces[index]); + } + // 무한 스크롤 로딩 + return Padding( + padding: EdgeInsets.all(16.w), + child: Center( + child: SizedBox( + width: 20.w, + height: 20.w, + child: CircularProgressIndicator( + color: HomeColors.textPrimary, + strokeWidth: 2, + ), ), ), - const SizedBox(height: 32), - const Text( - 'TODO: 실제 기능 구현 필요', - style: TextStyle(fontSize: 14, color: Colors.grey), - ), - ], - ), + ); + }, + ), + ); + } + + /// 인기순 탭 + Widget _buildPopularTab(HomeState state) { + if (state.popularPlaces.isEmpty && !state.isLoadingPopular) { + return const HomeEmptyState(message: '아직 인기 장소가 없습니다'); + } + + return RefreshIndicator( + onRefresh: () => ref.read(homeNotifierProvider.notifier).refresh(), + color: HomeColors.textPrimary, + backgroundColor: HomeColors.background, + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: state.popularPlaces.length, + itemBuilder: (context, index) { + return PlaceCard(place: state.popularPlaces[index]); + }, ), ); } diff --git a/lib/features/home/presentation/widgets/home_empty_state.dart b/lib/features/home/presentation/widgets/home_empty_state.dart new file mode 100644 index 0000000..b519a1e --- /dev/null +++ b/lib/features/home/presentation/widgets/home_empty_state.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; + +/// 빈 상태 위젯 (씀 스타일: 미니멀) +class HomeEmptyState extends StatelessWidget { + final String message; + + const HomeEmptyState({super.key, this.message = '아직 등록된 장소가 없습니다'}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.explore_outlined, + size: 48.sp, + color: HomeColors.textDisabled, + ), + SizedBox(height: 16.h), + Text( + message, + style: TextStyle( + color: HomeColors.textSecondary, + fontSize: 14.sp, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/home_error_state.dart b/lib/features/home/presentation/widgets/home_error_state.dart new file mode 100644 index 0000000..5779468 --- /dev/null +++ b/lib/features/home/presentation/widgets/home_error_state.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; + +/// 에러 상태 위젯 (씀 스타일: 텍스트 중심, 아이콘 없음) +class HomeErrorState extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const HomeErrorState({ + super.key, + this.message = '불러오기에 실패했습니다', + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + style: TextStyle( + color: HomeColors.textSecondary, + fontSize: 14.sp, + ), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + SizedBox(height: 16.h), + GestureDetector( + onTap: onRetry, + child: Text( + '다시 시도', + style: TextStyle( + color: HomeColors.retryButton, + fontSize: 14.sp, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/home_loading_shimmer.dart b/lib/features/home/presentation/widgets/home_loading_shimmer.dart new file mode 100644 index 0000000..576f72e --- /dev/null +++ b/lib/features/home/presentation/widgets/home_loading_shimmer.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; + +/// 홈 화면 로딩 스켈레톤 (씀 스타일) +class HomeLoadingShimmer extends StatelessWidget { + const HomeLoadingShimmer({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(3, (_) => _buildCardShimmer()), + ); + } + + Widget _buildCardShimmer() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), + decoration: BoxDecoration( + color: HomeColors.shimmerBase, + border: Border.all(color: HomeColors.cardBorder, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 썸네일 영역 (3:2) + AspectRatio( + aspectRatio: 3 / 2, + child: Container(color: HomeColors.shimmerHighlight), + ), + // 텍스트 영역 + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16.h, + width: 160.w, + decoration: BoxDecoration( + color: HomeColors.shimmerHighlight, + borderRadius: BorderRadius.circular(2.r), + ), + ), + SizedBox(height: 8.h), + Container( + height: 12.h, + width: 120.w, + decoration: BoxDecoration( + color: HomeColors.shimmerHighlight, + borderRadius: BorderRadius.circular(2.r), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/place_card.dart b/lib/features/home/presentation/widgets/place_card.dart new file mode 100644 index 0000000..ac4119a --- /dev/null +++ b/lib/features/home/presentation/widgets/place_card.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../data/models/place_model.dart'; + +/// 장소 카드 (씀 스타일: 직각 보더, 넓은 여백) +class PlaceCard extends StatelessWidget { + final PlaceModel place; + final VoidCallback? onTap; + + const PlaceCard({super.key, required this.place, this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), + decoration: BoxDecoration( + color: HomeColors.cardBackground, + border: Border.all(color: HomeColors.cardBorder, width: 1), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 썸네일 (3:2) + AspectRatio(aspectRatio: 3 / 2, child: _buildThumbnail()), + // 정보 영역 + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 장소명 + Text( + place.placeName, + style: TextStyle( + color: HomeColors.textPrimary, + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (place.address != null) ...[ + SizedBox(height: 4.h), + // 주소 + Text( + place.address!, + style: TextStyle( + color: HomeColors.textSecondary, + fontSize: 13.sp, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (place.tags.isNotEmpty) ...[ + SizedBox(height: 8.h), + // 태그 + Wrap( + spacing: 6.w, + runSpacing: 4.h, + children: place.tags.take(3).map((tag) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 4.h, + ), + decoration: BoxDecoration( + color: HomeColors.tagBackground, + borderRadius: BorderRadius.circular(100.r), + ), + child: Text( + '#$tag', + style: TextStyle( + color: HomeColors.tagText, + fontSize: 12.sp, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildThumbnail() { + if (place.imageUrl != null && place.imageUrl!.isNotEmpty) { + return Image.network( + place.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPlaceholder(), + ); + } + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + color: HomeColors.surfaceLight, + child: Center( + child: Icon( + Icons.place_outlined, + color: HomeColors.iconSecondary, + size: 48.sp, + ), + ), + ); + } +} diff --git a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart b/lib/features/onboarding/data/onboarding_remote_datasource.dart similarity index 92% rename from lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart rename to lib/features/onboarding/data/onboarding_remote_datasource.dart index d36c22e..8e788b6 100644 --- a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart +++ b/lib/features/onboarding/data/onboarding_remote_datasource.dart @@ -3,12 +3,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/constants/api_endpoints.dart'; -import '../../../../core/network/api_client.dart'; -import '../models/terms_request.dart'; -import '../models/birth_date_request.dart'; -import '../models/gender_request.dart'; -import '../models/profile_request.dart'; +import '../../../common/constants/api_endpoints.dart'; +import '../../../common/services/api_client.dart'; +import 'models/terms_request.dart'; +import 'models/birth_date_request.dart'; +import 'models/gender_request.dart'; +import 'models/profile_request.dart'; part 'onboarding_remote_datasource.g.dart'; diff --git a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.g.dart b/lib/features/onboarding/data/onboarding_remote_datasource.g.dart similarity index 100% rename from lib/features/onboarding/data/datasources/onboarding_remote_datasource.g.dart rename to lib/features/onboarding/data/onboarding_remote_datasource.g.dart diff --git a/lib/features/onboarding/domain/repositories/onboarding_repository.dart b/lib/features/onboarding/data/onboarding_repository.dart similarity index 92% rename from lib/features/onboarding/domain/repositories/onboarding_repository.dart rename to lib/features/onboarding/data/onboarding_repository.dart index 6933091..dfa0ad0 100644 --- a/lib/features/onboarding/domain/repositories/onboarding_repository.dart +++ b/lib/features/onboarding/data/onboarding_repository.dart @@ -1,5 +1,5 @@ -import '../../data/models/gender_request.dart'; -import '../../data/models/profile_request.dart'; +import 'models/gender_request.dart'; +import 'models/profile_request.dart'; /// 온보딩 Repository 인터페이스 abstract class OnboardingRepository { diff --git a/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart b/lib/features/onboarding/data/onboarding_repository_impl.dart similarity index 92% rename from lib/features/onboarding/data/repositories/onboarding_repository_impl.dart rename to lib/features/onboarding/data/onboarding_repository_impl.dart index 7a4d9e7..eb74b93 100644 --- a/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart +++ b/lib/features/onboarding/data/onboarding_repository_impl.dart @@ -2,13 +2,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/network/token_storage.dart'; -import '../../domain/repositories/onboarding_repository.dart'; -import '../datasources/onboarding_remote_datasource.dart'; -import '../models/terms_request.dart'; -import '../models/birth_date_request.dart'; -import '../models/gender_request.dart'; -import '../models/profile_request.dart'; +import '../../../common/services/token_storage.dart'; +import 'onboarding_repository.dart'; +import 'onboarding_remote_datasource.dart'; +import 'models/terms_request.dart'; +import 'models/birth_date_request.dart'; +import 'models/gender_request.dart'; +import 'models/profile_request.dart'; part 'onboarding_repository_impl.g.dart'; diff --git a/lib/features/onboarding/data/repositories/onboarding_repository_impl.g.dart b/lib/features/onboarding/data/onboarding_repository_impl.g.dart similarity index 100% rename from lib/features/onboarding/data/repositories/onboarding_repository_impl.g.dart rename to lib/features/onboarding/data/onboarding_repository_impl.g.dart diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.dart b/lib/features/onboarding/presentation/onboarding_provider.dart similarity index 96% rename from lib/features/onboarding/presentation/providers/onboarding_provider.dart rename to lib/features/onboarding/presentation/onboarding_provider.dart index ea6204c..c49c6f2 100644 --- a/lib/features/onboarding/presentation/providers/onboarding_provider.dart +++ b/lib/features/onboarding/presentation/onboarding_provider.dart @@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/errors/app_exception.dart'; -import '../../../auth/domain/entities/onboarding_step.dart'; -import '../../data/models/gender_request.dart'; -import '../../data/repositories/onboarding_repository_impl.dart'; -import '../../domain/repositories/onboarding_repository.dart'; +import '../../../common/exceptions/app_exception.dart'; +import '../../auth/data/onboarding_step.dart'; +import '../data/models/gender_request.dart'; +import '../data/onboarding_repository_impl.dart'; +import '../data/onboarding_repository.dart'; // OnboardingStep re-export for convenience -export '../../../auth/domain/entities/onboarding_step.dart'; +export '../../auth/data/onboarding_step.dart'; part 'onboarding_provider.freezed.dart'; part 'onboarding_provider.g.dart'; diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.freezed.dart b/lib/features/onboarding/presentation/onboarding_provider.freezed.dart similarity index 100% rename from lib/features/onboarding/presentation/providers/onboarding_provider.freezed.dart rename to lib/features/onboarding/presentation/onboarding_provider.freezed.dart diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart b/lib/features/onboarding/presentation/onboarding_provider.g.dart similarity index 100% rename from lib/features/onboarding/presentation/providers/onboarding_provider.g.dart rename to lib/features/onboarding/presentation/onboarding_provider.g.dart diff --git a/lib/features/onboarding/presentation/pages/birth_date_step_page.dart b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart index a5731af..792f63b 100644 --- a/lib/features/onboarding/presentation/pages/birth_date_step_page.dart +++ b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart @@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/constants/app_colors.dart'; -import '../../../../router/route_paths.dart'; -import '../providers/onboarding_provider.dart'; +import '../../../../common/constants/app_colors.dart'; +import '../../../../routing/route_paths.dart'; +import '../onboarding_provider.dart'; import '../widgets/onboarding_button.dart'; import '../widgets/step_indicator.dart'; diff --git a/lib/features/onboarding/presentation/pages/gender_step_page.dart b/lib/features/onboarding/presentation/pages/gender_step_page.dart index 69275c6..483657c 100644 --- a/lib/features/onboarding/presentation/pages/gender_step_page.dart +++ b/lib/features/onboarding/presentation/pages/gender_step_page.dart @@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/constants/app_colors.dart'; -import '../../../../router/route_paths.dart'; +import '../../../../common/constants/app_colors.dart'; +import '../../../../routing/route_paths.dart'; import '../../data/models/gender_request.dart'; -import '../providers/onboarding_provider.dart'; +import '../onboarding_provider.dart'; import '../widgets/onboarding_button.dart'; import '../widgets/step_indicator.dart'; diff --git a/lib/features/onboarding/presentation/pages/nickname_step_page.dart b/lib/features/onboarding/presentation/pages/nickname_step_page.dart index ee10377..00b5eb0 100644 --- a/lib/features/onboarding/presentation/pages/nickname_step_page.dart +++ b/lib/features/onboarding/presentation/pages/nickname_step_page.dart @@ -3,9 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/constants/app_colors.dart'; -import '../../../../router/route_paths.dart'; -import '../providers/onboarding_provider.dart'; +import '../../../../common/constants/app_colors.dart'; +import '../../../../routing/route_paths.dart'; +import '../onboarding_provider.dart'; import '../widgets/onboarding_button.dart'; import '../widgets/step_indicator.dart'; diff --git a/lib/features/onboarding/presentation/pages/terms_step_page.dart b/lib/features/onboarding/presentation/pages/terms_step_page.dart index c75f704..76f6bd2 100644 --- a/lib/features/onboarding/presentation/pages/terms_step_page.dart +++ b/lib/features/onboarding/presentation/pages/terms_step_page.dart @@ -3,9 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/constants/app_colors.dart'; -import '../../../../router/route_paths.dart'; -import '../providers/onboarding_provider.dart'; +import '../../../../common/constants/app_colors.dart'; +import '../../../../routing/route_paths.dart'; +import '../onboarding_provider.dart'; import '../widgets/onboarding_button.dart'; import '../widgets/step_indicator.dart'; diff --git a/lib/features/onboarding/presentation/widgets/onboarding_button.dart b/lib/features/onboarding/presentation/widgets/onboarding_button.dart index c32a476..0234548 100644 --- a/lib/features/onboarding/presentation/widgets/onboarding_button.dart +++ b/lib/features/onboarding/presentation/widgets/onboarding_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../../../../core/constants/app_colors.dart'; +import '../../../../common/constants/app_colors.dart'; /// 온보딩 공통 버튼 class OnboardingButton extends StatelessWidget { diff --git a/lib/features/onboarding/presentation/widgets/step_indicator.dart b/lib/features/onboarding/presentation/widgets/step_indicator.dart index 7237bd2..2f787e3 100644 --- a/lib/features/onboarding/presentation/widgets/step_indicator.dart +++ b/lib/features/onboarding/presentation/widgets/step_indicator.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../../../../core/constants/app_colors.dart'; -import '../providers/onboarding_provider.dart'; +import '../../../../common/constants/app_colors.dart'; +import '../onboarding_provider.dart'; /// 온보딩 진행 표시기 class StepIndicator extends StatelessWidget { diff --git a/lib/main.dart b/lib/main.dart index f0b167c..36d1de3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,9 +7,9 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'firebase_options.dart'; -import 'core/services/fcm/firebase_messaging_service.dart'; -import 'core/services/fcm/local_notifications_service.dart'; -import 'router/app_router.dart'; +import 'common/services/firebase_messaging_service.dart'; +import 'common/services/local_notifications_service.dart'; +import 'routing/app_router.dart'; /// MapSy 앱 진입점 /// Firebase, Crashlytics, FCM 초기화 후 앱 실행 diff --git a/lib/router/app_router.dart b/lib/routing/app_router.dart similarity index 98% rename from lib/router/app_router.dart rename to lib/routing/app_router.dart index 36e3651..bdbc925 100644 --- a/lib/router/app_router.dart +++ b/lib/routing/app_router.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../core/constants/spacing_and_radius.dart'; -import '../core/constants/text_styles.dart'; +import '../common/constants/spacing_and_radius.dart'; +import '../common/constants/text_styles.dart'; import 'route_paths.dart'; // Auth Provider Import -import '../features/auth/presentation/providers/auth_provider.dart'; +import '../features/auth/presentation/auth_provider.dart'; // Page Imports import '../features/auth/presentation/pages/splash_page.dart'; diff --git a/lib/router/route_paths.dart b/lib/routing/route_paths.dart similarity index 100% rename from lib/router/route_paths.dart rename to lib/routing/route_paths.dart diff --git a/pubspec.yaml b/pubspec.yaml index 74ca091..c2a06cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mapsy description: "MapSy - Flutter Application" publish_to: "none" -version: 1.0.32+32 +version: 1.0.39+39 environment: sdk: ^3.9.2 dependencies: diff --git a/version.yml b/version.yml index 56fb8ca..9b03717 100644 --- a/version.yml +++ b/version.yml @@ -34,12 +34,12 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.0.32" -version_code: 33 # app build number +version: "1.0.39" +version_code: 40 # app build number project_type: "flutter" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-02-10 16:52:23" - last_updated_by: "EM-H20" + last_updated: "2026-02-23 00:55:07" + last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE" integration_date: "2026-01-19"