diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index fc3d7d8f..00000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,127 +0,0 @@ -# ─────────── 전역(공통) 설정 ─────────── -language: ko-KR -tone_instructions: "1. 리뷰 시에는 변경 사항의 문제점이나 한계점을 구체적으로 짚어주고, \n 2. 왜 해당 부분이 비효율적이거나 권장되지 않는지 명확한 근거와 함께 설명해주세요. \n 3. 대안을 제시할 땐, 선택지를 제안하면서 각각의 장단점도 논리적으로 설명해주세요. \n 4. 단순 지적보다는 개선 방향에 집중하고, 학습적인 관점에서 이해를 돕는 코멘트를 남겨주세요. \n 5. 지나치게 딱딱하지 않되, 논리의 흐름이 분명하게 전달되도록 해주세요." - -# ─────────── 리뷰(Reviews) 전반 ─────────── -reviews: - profile: chill - high_level_summary: true - high_level_summary_placeholder: "🤖 Code Rabbit PR 요약" - review_status: true - commit_status: true - - # 워크스루/자동화/부가 기능 - collapse_walkthrough: false - changed_files_summary: false - sequence_diagrams: false - assess_linked_issues: true - related_issues: false - related_prs: false - suggested_labels: false - auto_apply_labels: false - suggested_reviewers: false - auto_assign_reviewers: false - poem: false - - # 경로별 리뷰 지침 및 제외 폴더 - path_instructions: - - path: android/** - instructions: | - - 1. 코틀린 공식 스타일 가이드 및 팀 컨벤션을 우선적으로 반영하여, 가독성, 안전성(Null/예외처리), 테스트/유지보수 용이성, 안드로이드 특화 사항(라이프사이클, 리소스, 권한 등)에 대해 리뷰해주세요. - - 2. 최신 코틀린/안드로이드 트렌드, 주석 및 문서화, 팀 스타일 통일성도 함께 확인해 주세요. - - 3. 각 리뷰 포인트별로 문제점과 대안, 장단점을 논리적으로 제시하고, 필요한 경우 예시 코드도 추가해 주세요. - - 4. 리뷰가 너무 많아서 피로감을 줄 수 있으니, 꼭 필요한 부분에 집중해주고, 나머지는 캡션으로 설명해주세요. - - 5. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요. - - path: backend/** - instructions: | - - 1. 팀 및 공식 컨벤션, 가독성, 예외처리, 테스트/확장/유지보수성, 모듈화, API/DB/보안 설계 기준을 기반으로 리뷰해주세요. - - 2. 최신 트렌드, 불필요한 로직, 클린코드, 리팩토링, 서비스/도메인 설계, 공통 예외 처리, 확장성도 함께 확인해주세요. - - 3. 각 피드백은 문제점·대안·장단점을 짧고 논리적으로, 예시 코드가 있다면 간결히 포함해 주세요. - - 4. 팀 내 스타일 통일성도 확인해주세요. - - 5. 미작성한 테스트 코드 케이스가 있다면, 어떤 테스트가 필요한지 제안해주세요. (예: 컨트롤러는 인수 테스트, 나머지는 단위 테스트) - - 6. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요. - - path: frontend/** - instructions: | - - 우리는 백엔드 개발자 팀으로, 관리자 페이지 프론트엔드를 Vibe 코딩 방식으로 빠르게 구현했습니다. - - React에 대한 전문적인 이해도가 부족한 상태이므로, 다음과 같은 기준으로 리뷰해 주세요: - - 1. 코드 스타일이나 컴포넌트 구조 등 전반적인 구조에 대한 일반적인 피드백은 생략해 주세요. - - 2. 보안상 취약점이 될 수 있는 부분 (예: XSS, CSRF, 사용자 입력 검증 부족 등) 은 반드시 알려주세요. - - 3. 화면 상 명백하게 어색하거나 비정상적으로 동작할 수 있는 UI/UX 요소만 지적해 주세요. - - 4. 빠른 배포를 목적으로 하기 때문에, 논리상 큰 이상이 없는 부분은 코멘트하지 않으셔도 됩니다. - - 5. 실제 사용자에게 혼동을 줄 수 있는 부분(버튼 비노출, 접근 불가능 등)이 있다면 꼭 알려주세요. - - 6. 해당 PR에는 테스트 코드가 포함되지 않았으며, 테스트 커버리지나 테스트 방식에 대한 피드백은 생략해 주세요. - - 위 기준을 바탕으로 꼭 필요한 피드백 위주로 리뷰 부탁드립니다. - - # 리뷰 진행/캐시/자동화 - abort_on_close: true - disable_cache: false - - auto_review: - enabled: true - auto_incremental_review: true - base_branches: [ "android", "backend", "frontend" ] - - finishing_touches: - docstrings: - enabled: true - unit_tests: - enabled: true - -# ─────────── 채팅(Chat) 설정 ─────────── -chat: - auto_reply: true - -# ─────────── 지식 기반(Knowledge base) ─────────── -knowledge_base: - opt_out: false - - web_search: - enabled: true - - code_guidelines: - enabled: true - filePatterns: - - backend/code-style.md - - android/code-style.md - - learnings: - scope: auto - issues: - scope: local - pull_requests: - scope: local - -# ─────────── 코드 생성(Code generation) ─────────── -code_generation: - docstrings: - language: ko-KR - path_instructions: - - path: backend/** - instructions: | - - JavaDoc 공식 형식으로, 한글로 Docstring을 작성해주세요. - - 메서드 목적, 파라미터, 반환값, 예외 정보를 명확하게 기술해 주세요. - - 외부 API 등 공개 메서드는 상세히, 내부용은 핵심만 요약해 주세요. - - - path: android/** - instructions: | - - 모든 public 함수에 대해 KDoc 양식을 따라 한글로 간결하게 Docstring을 작성해주세요. - - 함수 목적, 파라미터, 반환값, 예외를 명확하게 기술해 주세요. - - 샘플 코드/사용 예시는 필요한 경우에만 포함해 주세요. - - unit_tests: - path_instructions: - - path: backend/** - instructions: | - - Controller는 인수테스트(API 엔드포인트 통합 테스트) 나머지 영역은 함수/클래스 단위의 단위 테스트 - - Given-When-Then 패턴을 적용 - -# ─────────── 코드 분석 도구(Tools) ─────────── -tools: - hadolint: - enabled: true - gitleaks: - enabled: true - sqlfluff: - enabled: true - oxc: - enabled: true diff --git a/.editorconfig b/.editorconfig index 35fccf76..79ae79e8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,4 @@ [*.{kt,kts}] ktlint_standard_annotation = disabled -ktlint_function_naming_ignore_when_annotated_with = Composable \ No newline at end of file +ktlint_function_naming_ignore_when_annotated_with = Composable +compose_allowed_composition_locals = LocalColor, LocalShapes, LocalSpacing, LocalTypography \ No newline at end of file diff --git a/.github/scripts/review-reminder.js b/.github/scripts/review-reminder.js new file mode 100644 index 00000000..2bb45174 --- /dev/null +++ b/.github/scripts/review-reminder.js @@ -0,0 +1,122 @@ +module.exports = async ({github, context, core}) => { + + // 최소 PR 생성 시간 + const LIMIT_HOURS = 12; + const LIMIT_MS = LIMIT_HOURS * 60 * 60 * 1000; + + const WEBHOOK_URL = process.env.SLACK_WEBHOOK_REVIEW; + + // GitHub, Slack 정보 + const USER_MAP = { + 'etama123': 'U0995MPSZ62', + 'oungsi2000': 'U098U2R57NK', + 'parkjiminnnn': 'U098U8SLXHD' + }; + + const repoName = context.repo.repo; + + if (!WEBHOOK_URL) { + core.setFailed("❌ Error: SLACK_WEBHOOK_REVIEW 환경변수가 설정되지 않았습니다."); + return; + } + + try { + // GitHub API 호출로 Open PR 목록 요청 + const {data: prs} = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', // 오픈 PR + sort: 'created', // 정렬 기준 + direction: 'asc' // 오름차순 + }); + + const now = new Date(); + const delayedPrs = []; // 알림 대상 + + for (const pr of prs) { + const createdDate = new Date(pr.created_at); + const diffTime = now - createdDate; // PR 생성 시간 차이 (ms) + + if (diffTime >= LIMIT_MS) { + + // 리뷰어 정보 가공 (없으면 '미지정' 처리) + const reviewers = pr.requested_reviewers.length > 0 + ? pr.requested_reviewers.map(r => { + const slackId = USER_MAP[r.login]; + return slackId ? `<@${slackId}>` : r.login; + }).join(', ') + : '(리뷰어 미지정)'; + + // 지난 시간 + const passedHours = Math.floor(diffTime / (1000 * 60 * 60)); + + const authorSlackId = USER_MAP[pr.user.login]; + const authorDisplay = authorSlackId ? `<@${authorSlackId}>` : pr.user.login; + + // 알림 전송 객체 생성 + delayedPrs.push({ + title: pr.title, + url: pr.html_url, + author: authorDisplay, + reviewers: reviewers, + hours: passedHours + }); + } + } + + // 지연 PR 없을 시 종료 + if (delayedPrs.length === 0) { + return; + } + + const message = { + text: `🚨 (${repoName}) 코드 리뷰 리마인더`, + blocks: [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": `🔥 (${repoName}) 코드 리뷰 리마인더`, + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": `현재 *${LIMIT_HOURS}시간* 이상 대기 중인 PR이 *${delayedPrs.length}건* 있습니다.` + } + }, + {"type": "divider"} + ] + }; + + // 지연 PR 하나씩 메시지 추가 + delayedPrs.forEach((pr, index) => { + message.blocks.push({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": `*${index + 1}. <${pr.url}|${pr.title}>*\n` + + `⏳ *${pr.hours}시간* 경과\n` + + `👤 작성자: ${pr.author}\n` + + `👀 리뷰어: ${pr.reviewers}` + } + }); + message.blocks.push({"type": "divider"}); + }); + + const response = await fetch(WEBHOOK_URL, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(message) + }); + + if (!response.ok) { + throw new Error(`Slack 전송 실패 Status: ${response.status}`); + } + + } catch (error) { + core.setFailed(`❌ 스크립트 실행 중 에러 발생: ${error.message}`); + } +}; diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index cddec7f9..658695cc 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -2,20 +2,17 @@ name: Android CI on: pull_request: - # 'main' 또는 'android' 브랜치로 PR이 병합될 때 트리거 branches: - main - develop - # 'android' 디렉토리 내의 파일이 변경되었을 때만 워크플로우를 실행 - path: - - '/**' jobs: Run-PR-Test: runs-on: ubuntu-latest - defaults: - run: - working-directory: android + + permissions: + contents: write + checks: write steps: - name: Repository Checkout @@ -34,14 +31,16 @@ jobs: ~/.gradle/caches ~/.gradle/wrapper ~/.android/build-cache - key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/build.gradle.kts', 'android/**/settings.gradle.kts', 'android/**/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/gradle.properties') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle.kts', '**/settings.gradle.kts', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml', '**/gradle.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Create local.properties with BASE_URL run: | - echo BASE_URL=\"${{ secrets.BASE_URL }}\" > local.properties + echo BASE_URL=\"${{ secrets.BASE_URL }}\" >> local.properties echo BASE_URL_DEV=\"${{ secrets.BASE_URL_DEV }}\" >> local.properties + echo IMAGE_BASE_URL=\"${{ secrets.IMAGE_BASE_URL }}\" >> local.properties + echo IMAGE_BASE_URL_DEV=\"${{ secrets.IMAGE_BASE_URL_DEV }}\" >> local.properties echo NAVER_MAP_CLIENT_ID=\"${{ secrets.NAVER_MAP_CLIENT_ID }}\" >> local.properties echo NAVER_MAP_STYLE_ID=\"${{ secrets.NAVER_MAP_STYLE_ID }}\" >> local.properties @@ -52,8 +51,7 @@ jobs: - name: Restore keystore file run: | - mkdir -p app - echo "$KEYSTORE_BASE64" | base64 --decode > ./app/festabook_appkey.jks + echo "$KEYSTORE_BASE64" | base64 --decode > app/festabook_appkey.jks echo "JKS_FILE_PATH=./app/festabook_appkey.jks" >> local.properties echo "STORE_PASSWORD=${{ secrets.STORE_PASSWORD }}" >> local.properties echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> local.properties @@ -62,20 +60,22 @@ jobs: env: KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} - - name: Clean Project - run: ./gradlew clean - - name: Run ktlint run: ./gradlew ktlintCheck - - - name: Run Unit Test - run: ./gradlew test + + - name: Run Debug Unit Test (for Develop PRs) + if: github.base_ref == 'develop' + run: ./gradlew testDebugUnitTest + + - name: Run Release Unit Test (for Main PRs) + if: github.base_ref == 'main' + run: ./gradlew testReleaseUnitTest - name: Publish Unit Test Results if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: - files: android/app/build/test-results/**/TEST-*.xml + files: app/build/test-results/**/TEST-*.xml check_name: '테스트 결과 🛠️' check_run_annotations: 'none' comment_mode: 'off' diff --git a/.github/workflows/android-release-drafter.yml b/.github/workflows/android-release-drafter.yml new file mode 100644 index 00000000..b0c9b596 --- /dev/null +++ b/.github/workflows/android-release-drafter.yml @@ -0,0 +1,121 @@ +name: Android CD + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + Upload-Release-Bundle: + runs-on: ubuntu-latest + outputs: + version_code: ${{ steps.get_version.outputs.version_code }} + + steps: + - name: Repository Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.android/build-cache + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/build.gradle.kts', 'android/**/settings.gradle.kts', 'android/**/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Create local.properties with BASE_URL + run: | + echo BASE_URL=\"${{ secrets.BASE_URL }}\" > local.properties + echo BASE_URL_DEV=\"${{ secrets.BASE_URL_DEV }}\" >> local.properties + echo NAVER_MAP_CLIENT_ID=\"${{ secrets.NAVER_MAP_CLIENT_ID }}\" >> local.properties + echo NAVER_MAP_STYLE_ID=\"${{ secrets.NAVER_MAP_STYLE_ID }}\" >> local.properties + + - name: Load Google Service file + env: + DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: echo "$DATA" > app/google-services.json + + - name: Restore keystore file + run: | + echo "$KEYSTORE_BASE64" | base64 --decode > app/festabook_appkey.jks + echo "JKS_FILE_PATH=./festabook_appkey.jks" >> local.properties + echo "STORE_PASSWORD=${{ secrets.STORE_PASSWORD }}" >> local.properties + echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> local.properties + echo "KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> local.properties + + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + + - name: Build Release AAB + run: ./gradlew app:bundleRelease + + #versionName의 숫자만 추출합니다 ex) versionName = "v1.2.1" -> 1.2.1 + - name: Extract Version Code + id: get_version + run: | + VERSION_NAME=$(grep -oP 'versionName = "\K[^"]+' app/build.gradle.kts | head -1 | sed 's/[v ]//g') + echo "version_code=$VERSION_NAME" >> $GITHUB_OUTPUT + echo "Extracted Version: $VERSION_NAME" + + - name: Upload Release Artifact + uses: actions/upload-artifact@v4 + with: + name: app-release-bundle-${{ steps.get_version.outputs.version_code }} + path: app/build/outputs/bundle/release/app-release.aab + retention-days: 7 + + #번들이 업로드가 완료된 후 버전 태그를 작성합니다 + Release-Drafter: + runs-on: ubuntu-latest + needs: Upload-Release-Bundle + + steps: + - name: Repository Checkout + uses: actions/checkout@v4 + + #추출한 버전을 drafter에 적용합니다 + - name: Update Draft Release + uses: release-drafter/release-drafter@v6 + with: + version: ${{ needs.Upload-Release-Bundle.outputs.version_code }} + config-name: template/release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # hotfix 머지 시, main의 변경사항을 develop에 자동 반영합니다 + # 번들이 업로드가 완료된 후 실행됩니다 + Reflect-Changes-Into-Develop: + runs-on: ubuntu-latest + needs: Upload-Release-Bundle + + steps: + - name: Develop Branch Checkout + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git User + run: | + git config user.name "GitHub Actions Bot" + git config user.email "actions@github.com" + + - name: Merge Main into Develop + run: | + git fetch origin main + git merge origin/main + git push origin develop \ No newline at end of file diff --git a/.github/workflows/common-slack-notify-opened.yml b/.github/workflows/common-slack-notify-opened.yml index 85f9178f..0994d2f5 100644 --- a/.github/workflows/common-slack-notify-opened.yml +++ b/.github/workflows/common-slack-notify-opened.yml @@ -2,9 +2,9 @@ name: Slack Notification (Opened/Ready) on: pull_request: - types: [opened, ready_for_review] + types: [ opened, ready_for_review ] -permissions: {} +permissions: { } concurrency: group: pr-${{ github.event.pull_request.number }}-slack-opened-ready @@ -28,6 +28,8 @@ jobs: PR_AUTHOR: ${{ github.event.pull_request.user.login }} REVIEWERS_JSON: ${{ toJson(github.event.pull_request.requested_reviewers) }} run: | + REPO_NAME="${GITHUB_REPOSITORY#*/}" + declare -A GITHUB_TO_SLACK GITHUB_TO_SLACK["soeun2537"]="U09A0LM0CRW" GITHUB_TO_SLACK["taek2222"]="U099ARRH3D3" @@ -56,7 +58,20 @@ jobs: echo "리뷰어가 없으므로 알림 생략" exit 0 fi - - curl -X POST -H 'Content-type: application/json' \ - --data "{\"text\": \"🔥 *리뷰 요청 알림*\n*PR 제목:* ${PR_TITLE} (#${PR_NUMBER})\n*작성자:* ${SLACK_AUTHOR_MENTION}\n*리뷰어:* ${REVIEWER_MENTIONS}\n*링크:* ${PR_URL}\"}" \ - $SLACK_WEBHOOK_URL + + PAYLOAD=$(jq -n \ + --arg repo "$REPO_NAME" \ + --arg title "$PR_TITLE" \ + --arg number "$PR_NUMBER" \ + --arg author "$SLACK_AUTHOR_MENTION" \ + --arg reviewers "$REVIEWER_MENTIONS" \ + --arg url "$PR_URL" \ + '{ + text: "🔥 (\($repo)) 리뷰 요청 알림", + blocks: [ + {type: "header", text: {type: "plain_text", text: "🔥 (\($repo)) 리뷰 요청 알림", emoji: true}}, + {type: "section", text: {type: "mrkdwn", text: "*PR 제목:* \($title) (#\($number))\n*작성자:* \($author)\n*리뷰어:* \($reviewers)\n*링크:* \($url)"}} + ] + }') + + curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" $SLACK_WEBHOOK_URL diff --git a/.github/workflows/common-slack-notify-rerequested.yml b/.github/workflows/common-slack-notify-rerequested.yml index dcf76883..4b58c605 100644 --- a/.github/workflows/common-slack-notify-rerequested.yml +++ b/.github/workflows/common-slack-notify-rerequested.yml @@ -2,7 +2,7 @@ name: Slack Notification (Review Requested) on: pull_request: - types: [review_requested] + types: [ review_requested ] permissions: contents: read @@ -32,6 +32,8 @@ jobs: PR_REVIEWER: ${{ github.event.requested_reviewer.login }} REPOSITORY: ${{ github.repository }} run: | + REPO_NAME="${GITHUB_REPOSITORY#*/}" + declare -A GITHUB_TO_SLACK GITHUB_TO_SLACK["soeun2537"]="U09A0LM0CRW" GITHUB_TO_SLACK["taek2222"]="U099ARRH3D3" @@ -59,7 +61,7 @@ jobs: echo "매핑 없는 사용자(${PR_REVIEWER})는 생략" exit 0 fi - + if [[ -n "${GITHUB_TO_SLACK[$PR_AUTHOR]}" ]]; then SLACK_AUTHOR_MENTION="<@${GITHUB_TO_SLACK[$PR_AUTHOR]}>" else @@ -67,7 +69,20 @@ jobs: fi SLACK_REVIEWER_MENTION="<@$slack_id>" - - curl -X POST -H 'Content-type: application/json' \ - --data "{\"text\": \"🏸 *재리뷰 요청 알림*\n*PR 제목:* ${PR_TITLE} (#${PR_NUMBER})\n*작성자:* ${SLACK_AUTHOR_MENTION}\n*리뷰어:* ${SLACK_REVIEWER_MENTION}\n*링크:* ${PR_URL}\"}" \ - $SLACK_WEBHOOK_URL + + PAYLOAD=$(jq -n \ + --arg repo "$REPO_NAME" \ + --arg title "$PR_TITLE" \ + --arg number "$PR_NUMBER" \ + --arg author "$SLACK_AUTHOR_MENTION" \ + --arg reviewer "$SLACK_REVIEWER_MENTION" \ + --arg url "$PR_URL" \ + '{ + text: "🏸 (\($repo)) 재리뷰 요청 알림", + blocks: [ + {type: "header", text: {type: "plain_text", text: "🏸 (\($repo)) 재리뷰 요청 알림", emoji: true}}, + {type: "section", text: {type: "mrkdwn", text: "*PR 제목:* \($title) (#\($number))\n*작성자:* \($author)\n*리뷰어:* \($reviewer)\n*링크:* \($url)"}} + ] + }') + + curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" $SLACK_WEBHOOK_URL diff --git a/.github/workflows/common-slack-notify-submitted.yml b/.github/workflows/common-slack-notify-submitted.yml index 8e86657a..0f817889 100644 --- a/.github/workflows/common-slack-notify-submitted.yml +++ b/.github/workflows/common-slack-notify-submitted.yml @@ -2,9 +2,9 @@ name: Slack Notification (Submitted) on: pull_request_review: - types: [submitted] + types: [ submitted ] -permissions: {} +permissions: { } concurrency: group: pr-${{ github.event.pull_request.number }}-slack-submitted @@ -15,6 +15,9 @@ jobs: notify: runs-on: ubuntu-latest steps: + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + - name: Notify Review Submitted env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -29,6 +32,8 @@ jobs: echo "단순 코멘트 리뷰는 알림 생략" exit 0 fi + + REPO_NAME="${GITHUB_REPOSITORY#*/}" declare -A GITHUB_TO_SLACK GITHUB_TO_SLACK["soeun2537"]="U09A0LM0CRW" @@ -54,7 +59,21 @@ jobs: approved) ICON="✅" ;; changes_requested) ICON="🔴" ;; esac - - curl -X POST -H 'Content-type: application/json' \ - --data "{\"text\": \"${ICON} *리뷰 완료 알림*\n*PR 제목:* ${PR_TITLE} (#${PR_NUMBER})\n*작성자:* ${SLACK_AUTHOR_MENTION}\n*상태:* ${REVIEW_STATE}\n*링크:* ${PR_URL}\"}" \ - $SLACK_WEBHOOK_URL + + PAYLOAD=$(jq -n \ + --arg icon "$ICON" \ + --arg repo "$REPO_NAME" \ + --arg title "$PR_TITLE" \ + --arg number "$PR_NUMBER" \ + --arg author "$SLACK_AUTHOR_MENTION" \ + --arg state "$REVIEW_STATE" \ + --arg url "$PR_URL" \ + '{ + text: "\($icon) (\($repo)) 리뷰 완료 알림", + blocks: [ + {type: "header", text: {type: "plain_text", text: "\($icon) (\($repo)) 리뷰 완료 알림", emoji: true}}, + {type: "section", text: {type: "mrkdwn", text: "*PR 제목:* \($title) (#\($number))\n*작성자:* \($author)\n*상태:* \($state)\n*링크:* \($url)"}} + ] + }') + + curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" $SLACK_WEBHOOK_URL diff --git a/.github/workflows/common-slack-review-reminder.yml b/.github/workflows/common-slack-review-reminder.yml new file mode 100644 index 00000000..226dd785 --- /dev/null +++ b/.github/workflows/common-slack-review-reminder.yml @@ -0,0 +1,25 @@ +name: Slack Notification (Review Reminder) + +on: + schedule: + # 매일 한국 시간 오후 2시 (KST 14:00 == UTC 05:00) + - cron: '0 5 * * *' + workflow_dispatch: + +jobs: + remind: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Run Reminder Script + uses: actions/github-script@v7 + env: + SLACK_WEBHOOK_REVIEW: ${{ secrets.SLACK_WEBHOOK_REVIEW }} + with: + script: | + // .github/scripts/review-reminder.js 파일을 불러와서 실행 + const script = require('./.github/scripts/review-reminder.js'); + await script({ github, context, core }); diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 6e2d19df..00000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Release Drafter - -on: - push: - branches: - - release/* - - main - -permissions: - contents: write - pull-requests: write - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Update Draft Release - if: startsWith(github.ref, 'refs/heads/release/') - uses: release-drafter/release-drafter@v6 - with: - config-name: template/release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish Release - if: github.ref == 'refs/heads/main' - id: drafter - uses: release-drafter/release-drafter@v6 - with: - config-name: template/release-drafter.yml - publish: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dac82a61..ee5143ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.serialization) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ktlint) id("kotlin-kapt") id("kotlin-parcelize") id("com.google.gms.google-services") @@ -68,8 +69,8 @@ android { applicationId = "com.daedan.festabook" minSdk = 28 targetSdk = 36 - versionCode = 10_201 - versionName = "v 1.2.1" + versionCode = 20_000 + versionName = "v 2.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField( @@ -99,7 +100,11 @@ android { val baseUrl = checkNotNull(localProperties["BASE_URL_DEV"] as? String) { - "BASE_URL is missing or not a String in local.properties" + "BASE_URL_DEV is missing or not a String in local.properties" + } + val imageBaseUrl = + checkNotNull(localProperties["IMAGE_BASE_URL_DEV"] as? String) { + "IMAGE_BASE_URL_DEV is missing or not a String in local.properties" } buildConfigField( @@ -107,6 +112,11 @@ android { "FESTABOOK_URL", baseUrl, ) + buildConfigField( + "String", + "FESTABOOK_IMAGE_URL", + imageBaseUrl, + ) } release { @@ -123,12 +133,21 @@ android { checkNotNull(localProperties["BASE_URL"] as? String) { "BASE_URL is missing or not a String in local.properties" } + val imageBaseUrl = + checkNotNull(localProperties["IMAGE_BASE_URL"] as? String) { + "IMAGE_BASE_URL is missing or not a String in local.properties" + } buildConfigField( "String", "FESTABOOK_URL", baseUrl, ) + buildConfigField( + "String", + "FESTABOOK_IMAGE_URL", + imageBaseUrl, + ) } } compileOptions { @@ -147,7 +166,10 @@ android { } dependencies { + ktlintRuleset(libs.ktlint) + implementation(libs.kotlinx.datetime) implementation(libs.map.sdk) + implementation(libs.androidx.navigation.compose) implementation(libs.play.services.location) implementation(libs.androidx.core.ktx) implementation(libs.androidx.fragment.ktx) @@ -181,6 +203,9 @@ dependencies { implementation(libs.ui.tooling) implementation(libs.androidx.material3) implementation(libs.photoview.dialog) + implementation(libs.landscapist.coil3) + implementation(libs.landscapist.placeholder) + implementation(libs.landscapist.zoomable) testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.androidx.core.testing) @@ -189,4 +214,13 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) debugImplementation(libs.logging.interceptor) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.platform.launcher) + testRuntimeOnly(libs.junit.vintage.engine) +} + +tasks.withType().configureEach { + useJUnitPlatform() } diff --git a/app/src/androidTest/java/com/daedan/festabook/.gitkeep b/app/src/androidTest/java/com/daedan/festabook/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt deleted file mode 100644 index f3f80a96..00000000 --- a/app/src/androidTest/java/com/daedan/festabook/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.daedan.festabook - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.daedan.festabook", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 490c19f9..c64ffd54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,12 +23,7 @@ - - - - - + android:theme="@style/Theme.Festabook.Splash" /> + tools:ignore="DiscouragedApi, LockedOrientationActivity"> + + + + + - + tools:ignore="DiscouragedApi, LockedOrientationActivity" /> - + tools:ignore="DiscouragedApi, LockedOrientationActivity" /> ().create(this) } @@ -34,6 +43,12 @@ class FestaBookApp : Application() { @Inject private lateinit var fcmDataSource: FcmDataSource + private val defaultImage: Image? by lazy { + ContextCompat + .getDrawable(this, R.drawable.img_fallback) + ?.asImage() + } + override fun onCreate() { super.onCreate() setGlobalExceptionHandler() @@ -51,6 +66,14 @@ class FestaBookApp : Application() { Timber.w("FestabookApp: onLowMemory 호출됨") } + override fun newImageLoader(context: PlatformContext): ImageLoader = + ImageLoader + .Builder(context) + .crossfade(true) + .fallback(defaultImage) + .error(defaultImage) + .build() + private fun sendUnsentReports() { Firebase.crashlytics.sendUnsentReports() } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt index bfa7e409..bd01077c 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/DeviceLocalDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class DeviceLocalDataSourceImpl @Inject constructor( +@Inject +class DeviceLocalDataSourceImpl( private val prefs: SharedPreferences, ) : DeviceLocalDataSource { override fun saveUuid(uuid: String) { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt index f552787c..90bd7ff8 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/FcmDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FcmDataSourceImpl @Inject constructor( +@Inject +class FcmDataSourceImpl constructor( private val prefs: SharedPreferences, ) : FcmDataSource { override fun saveFcmToken(token: String) { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt index ddeca35e..3718ba63 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalLocalDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalLocalDataSourceImpl @Inject constructor( +@Inject +class FestivalLocalDataSourceImpl( private val prefs: SharedPreferences, ) : FestivalLocalDataSource { override fun saveFestivalId(festivalId: Long) { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt index 0b63778b..52642426 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/local/FestivalNotificationLocalDataSourceImpl.kt @@ -7,7 +7,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalNotificationLocalDataSourceImpl @Inject constructor( +@Inject +class FestivalNotificationLocalDataSourceImpl( private val prefs: SharedPreferences, ) : FestivalNotificationLocalDataSource { override fun saveFestivalNotificationId( diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt index b6d6e183..9a291465 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/device/DeviceDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class DeviceDataSourceImpl @Inject constructor( +@Inject +class DeviceDataSourceImpl( private val deviceService: DeviceService, ) : DeviceDataSource { override suspend fun registerDevice( diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt index 0f494024..7d1152ff 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/faq/FAQDataSourceImpl.kt @@ -8,7 +8,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FAQDataSourceImpl @Inject constructor( +@Inject +class FAQDataSourceImpl( private val faqService: FAQService, ) : FAQDataSource { override suspend fun fetchAllFAQs(): ApiResult> = ApiResult.toApiResult { faqService.fetchAllFAQs() } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt index fba1fd20..d120dd9d 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalDataSourceImpl @Inject constructor( +@Inject +class FestivalDataSourceImpl( private val festivalService: FestivalService, ) : FestivalDataSource { override suspend fun fetchFestival(): ApiResult = @@ -20,7 +21,7 @@ class FestivalDataSourceImpl @Inject constructor( override suspend fun findUniversitiesByName(universityName: String): ApiResult> = ApiResult.toApiResult { festivalService.findUniversitiesByName( - universityName = universityName, + keyword = universityName, ) } } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt index dde36796..afae53b8 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSource.kt @@ -2,6 +2,7 @@ package com.daedan.festabook.data.datasource.remote.festival import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse interface FestivalNotificationDataSource { suspend fun saveFestivalNotification( @@ -10,4 +11,6 @@ interface FestivalNotificationDataSource { ): ApiResult suspend fun deleteFestivalNotification(festivalNotificationId: Long): ApiResult + + suspend fun getFestivalNotification(deviceId: Long): ApiResult> } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt index cc85a095..d8e93e1b 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/festival/FestivalNotificationDataSourceImpl.kt @@ -3,13 +3,15 @@ package com.daedan.festabook.data.datasource.remote.festival import com.daedan.festabook.data.datasource.remote.ApiResult import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import com.daedan.festabook.data.service.FestivalNotificationService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FestivalNotificationDataSourceImpl @Inject constructor( +@Inject +class FestivalNotificationDataSourceImpl( private val festivalNotificationService: FestivalNotificationService, ) : FestivalNotificationDataSource { override suspend fun saveFestivalNotification( @@ -27,4 +29,9 @@ class FestivalNotificationDataSourceImpl @Inject constructor( ApiResult.toApiResult { festivalNotificationService.deleteFestivalNotification(festivalNotificationId) } + + override suspend fun getFestivalNotification(deviceId: Long): ApiResult> = + ApiResult.toApiResult { + festivalNotificationService.getFestivalNotification(deviceId) + } } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt index 6491d8bd..e06b5fe6 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lineup/LineupDataSourceImpl.kt @@ -8,7 +8,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class LineupDataSourceImpl @Inject constructor( +@Inject +class LineupDataSourceImpl( private val festivalLineupService: FestivalLineupService, ) : LineupDataSource { override suspend fun fetchLineup(): ApiResult> = diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt index 3cebfbe1..0d7c149b 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/lostitem/LostItemDataSourceImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class LostItemDataSourceImpl @Inject constructor( +@Inject +class LostItemDataSourceImpl( private val lostItemService: LostItemService, private val festivalService: FestivalService, ) : LostItemDataSource { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt index 185443a1..f6ec0e6b 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/notice/NoticeDataSourceImpl.kt @@ -8,7 +8,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class NoticeDataSourceImpl @Inject constructor( +@Inject +class NoticeDataSourceImpl( private val noticeService: NoticeService, ) : NoticeDataSource { override suspend fun fetchNotices(): ApiResult = ApiResult.toApiResult { noticeService.getNotices() } diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt index 8096e967..a181beb0 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/place/PlaceDataSourceImpl.kt @@ -13,7 +13,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class PlaceDataSourceImpl @Inject constructor( +@Inject +class PlaceDataSourceImpl( private val placeService: PlaceService, private val festivalService: FestivalService, ) : PlaceDataSource { diff --git a/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt b/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt index d7201396..65cdb0fa 100644 --- a/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/datasource/remote/schedule/ScheduleDataSourceImpl.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class ScheduleDataSourceImpl @Inject constructor( +@Inject +class ScheduleDataSourceImpl( private val scheduleService: ScheduleService, ) : ScheduleDataSource { override suspend fun fetchScheduleEventsById(eventDateId: Long): ApiResult> = diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/UniversityResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/UniversityResponse.kt index 1405f943..9c314092 100644 --- a/app/src/main/java/com/daedan/festabook/data/model/response/UniversityResponse.kt +++ b/app/src/main/java/com/daedan/festabook/data/model/response/UniversityResponse.kt @@ -8,8 +8,8 @@ import kotlinx.serialization.Serializable data class UniversityResponse( @SerialName("festivalId") val festivalId: Long, - @SerialName("universityName") - val universityName: String, + @SerialName("organizationName") + val organizationName: String, @SerialName("festivalName") val festivalName: String, @SerialName("startDate") @@ -21,7 +21,7 @@ data class UniversityResponse( fun UniversityResponse.toDomain() = University( festivalId = festivalId, - universityName = universityName, + universityName = organizationName, festivalName = festivalName, startDate = startDate, endDate = endDate, diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt index 781881a8..836523a9 100644 --- a/app/src/main/java/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt +++ b/app/src/main/java/com/daedan/festabook/data/model/response/festival/FestivalResponse.kt @@ -11,8 +11,8 @@ import kotlinx.serialization.Serializable data class FestivalResponse( @SerialName("festivalId") val id: Long, - @SerialName("universityName") - val universityName: String, + @SerialName("organizationName") + val organizationName: String, @SerialName("festivalImages") val festivalImages: List, @SerialName("festivalName") @@ -36,7 +36,7 @@ data class FestivalResponse( fun FestivalResponse.toDomain() = Organization( id = id, - universityName = universityName, + universityName = organizationName, festival = Festival( festivalImages = festivalImages.map { it.toDomain() }, diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt new file mode 100644 index 00000000..19769514 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/data/model/response/festival/RegisteredFestivalNotificationResponse.kt @@ -0,0 +1,16 @@ +package com.daedan.festabook.data.model.response.festival + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisteredFestivalNotificationResponse( + @SerialName("festivalNotificationId") + val festivalNotificationId: Long, + @SerialName("festivalId") + val festivalId: Long, + @SerialName("organizationName") + val organizationName: String, + @SerialName("festivalName") + val festivalName: String, +) diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt index a91f293d..3b655f96 100644 --- a/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt +++ b/app/src/main/java/com/daedan/festabook/data/model/response/notice/NoticeResponse.kt @@ -1,9 +1,9 @@ package com.daedan.festabook.data.model.response.notice import com.daedan.festabook.domain.model.Notice +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Serializable data class NoticeResponse( diff --git a/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt b/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt index 55142c0f..9747a311 100644 --- a/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt +++ b/app/src/main/java/com/daedan/festabook/data/model/response/place/PlaceDetailResponse.kt @@ -5,9 +5,9 @@ import com.daedan.festabook.domain.model.Notice import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.domain.model.PlaceDetailImage +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter diff --git a/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt index 015fd979..603e9139 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/DeviceRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class DeviceRepositoryImpl @Inject constructor( +@Inject +class DeviceRepositoryImpl( private val deviceDataSource: DeviceDataSource, private val deviceLocalDataSource: DeviceLocalDataSource, private val fcmDataSource: FcmDataSource, diff --git a/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt index 5a503bb1..322e86d5 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/ExploreRepositoryImpl.kt @@ -12,7 +12,8 @@ import dev.zacsweers.metro.Inject import timber.log.Timber @ContributesBinding(AppScope::class) -class ExploreRepositoryImpl @Inject constructor( +@Inject +class ExploreRepositoryImpl( private val festivalDataSource: FestivalDataSource, private val festivalLocalDataSource: FestivalLocalDataSource, ) : ExploreRepository { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt index 907afea1..070aff6d 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FAQRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class FAQRepositoryImpl @Inject constructor( +@Inject +class FAQRepositoryImpl( private val faqDataSource: FAQDataSource, ) : FAQRepository { override suspend fun getAllFAQ(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt index 5dc1c36a..d5a8adff 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FestivalNotificationRepositoryImpl.kt @@ -9,53 +9,83 @@ import com.daedan.festabook.domain.repository.FestivalNotificationRepository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject -import timber.log.Timber @ContributesBinding(AppScope::class) -class FestivalNotificationRepositoryImpl @Inject constructor( +@Inject +class FestivalNotificationRepositoryImpl( private val festivalNotificationDataSource: FestivalNotificationDataSource, private val deviceLocalDataSource: DeviceLocalDataSource, private val festivalNotificationLocalDataSource: FestivalNotificationLocalDataSource, private val festivalLocalDataSource: FestivalLocalDataSource, ) : FestivalNotificationRepository { override suspend fun saveFestivalNotification(): Result { - val deviceId = deviceLocalDataSource.getDeviceId() - if (deviceId == null) { - Timber.e("${::FestivalNotificationRepositoryImpl.name}: DeviceId가 없습니다.") - return Result.failure(IllegalStateException()) - } - val festivalId = festivalLocalDataSource.getFestivalId() + val deviceId = + deviceLocalDataSource.getDeviceId() ?: return Result.failure( + IllegalArgumentException(NO_DEVICE_ID_EXCEPTION), + ) + val festivalId = + festivalLocalDataSource.getFestivalId() ?: return Result.failure( + IllegalArgumentException(NO_FESTIVAL_ID_EXCEPTION), + ) - val result = - festivalId?.let { - festivalNotificationDataSource - .saveFestivalNotification( - festivalId = it, - deviceId = deviceId, - ).toResult() - } - ?: throw IllegalArgumentException("${::FestivalNotificationRepositoryImpl.javaClass.simpleName}festivalId가 null 입니다.") - return result - .mapCatching { + return festivalNotificationDataSource + .saveFestivalNotification( + festivalId = festivalId, + deviceId = deviceId, + ).toResult() + .mapCatching { response -> festivalNotificationLocalDataSource.saveFestivalNotificationId( festivalId, - it.festivalNotificationId, + response.festivalNotificationId, ) } } override suspend fun deleteFestivalNotification(): Result { val festivalId = - festivalLocalDataSource.getFestivalId() ?: return Result.failure( - IllegalStateException(), - ) + festivalLocalDataSource.getFestivalId() + ?: return Result.failure(IllegalStateException(NO_FESTIVAL_ID_EXCEPTION)) val festivalNotificationId = festivalNotificationLocalDataSource.getFestivalNotificationId(festivalId) - val response = - festivalNotificationDataSource.deleteFestivalNotification(festivalNotificationId) - festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + return festivalNotificationDataSource + .deleteFestivalNotification(festivalNotificationId) + .toResult() + .mapCatching { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + } + } + + override suspend fun syncFestivalNotificationIsAllow(): Result { + val deviceId = + deviceLocalDataSource.getDeviceId() ?: return Result.failure( + IllegalArgumentException(NO_DEVICE_ID_EXCEPTION), + ) + val festivalId = + festivalLocalDataSource.getFestivalId() ?: return Result.failure( + IllegalArgumentException(NO_FESTIVAL_ID_EXCEPTION), + ) - return response.toResult() + return festivalNotificationDataSource + .getFestivalNotification(deviceId) + .toResult() + .mapCatching { response -> + val notificationId = + response.find { it.festivalId == festivalId }?.festivalNotificationId + val isAllowed = notificationId != null + festivalNotificationLocalDataSource.saveFestivalNotificationIsAllowed( + festivalId, + isAllowed, + ) + if (isAllowed) { + festivalNotificationLocalDataSource.saveFestivalNotificationId( + festivalId, + notificationId, + ) + } else { + festivalNotificationLocalDataSource.deleteFestivalNotificationId(festivalId) + } + isAllowed + } } override fun getFestivalNotificationIsAllow(): Boolean { @@ -71,4 +101,11 @@ class FestivalNotificationRepositoryImpl @Inject constructor( ) } } + + companion object { + private val NO_FESTIVAL_ID_EXCEPTION = + "${::FestivalNotificationRepositoryImpl.name}: FestivalId가 없습니다." + private val NO_DEVICE_ID_EXCEPTION = + "${::FestivalNotificationRepositoryImpl.name}: DeviceId가 없습니다." + } } diff --git a/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt index b0875789..b1cc30f3 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/FestivalRepositoryImpl.kt @@ -15,7 +15,8 @@ import dev.zacsweers.metro.Inject import java.time.LocalDate @ContributesBinding(AppScope::class) -class FestivalRepositoryImpl @Inject constructor( +@Inject +class FestivalRepositoryImpl( private val festivalDataSource: FestivalDataSource, private val festivalLocalDataSource: FestivalLocalDataSource, private val lineupDataSource: LineupDataSource, diff --git a/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt index c080a513..10a3356f 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/LostItemRepositoryImpl.kt @@ -13,7 +13,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @ContributesBinding(AppScope::class) -class LostItemRepositoryImpl @Inject constructor( +@Inject +class LostItemRepositoryImpl( private val lostItemDataSource: LostItemDataSource, ) : LostItemRepository { override suspend fun getPendingLostItems(): Result> = diff --git a/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt index c673e912..c2ef585d 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/NoticeRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class NoticeRepositoryImpl @Inject constructor( +@Inject +class NoticeRepositoryImpl( private val noticeDataSource: NoticeDataSource, ) : NoticeRepository { override suspend fun fetchNotices(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt index cac14b30..eb0fda18 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/PlaceDetailRepositoryImpl.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class PlaceDetailRepositoryImpl @Inject constructor( +@Inject +class PlaceDetailRepositoryImpl( private val placeDataSource: PlaceDataSource, ) : PlaceDetailRepository { override suspend fun getPlaceDetail(placeId: Long): Result { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt index eedca070..ac2631f7 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/PlaceListRepositoryImpl.kt @@ -14,7 +14,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class PlaceListRepositoryImpl @Inject constructor( +@Inject +class PlaceListRepositoryImpl( private val placeDataSource: PlaceDataSource, ) : PlaceListRepository { override suspend fun getTimeTags(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt b/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt index 4bb9c244..eb8a2ca7 100644 --- a/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt +++ b/app/src/main/java/com/daedan/festabook/data/repository/ScheduleRepositoryImpl.kt @@ -11,7 +11,8 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @ContributesBinding(AppScope::class) -class ScheduleRepositoryImpl @Inject constructor( +@Inject +class ScheduleRepositoryImpl( private val scheduleDataSource: ScheduleDataSource, ) : ScheduleRepository { override suspend fun fetchAllScheduleDates(): Result> { diff --git a/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt b/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt index e17ac8e4..09688a4f 100644 --- a/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt +++ b/app/src/main/java/com/daedan/festabook/data/service/FestivalNotificationService.kt @@ -2,9 +2,11 @@ package com.daedan.festabook.data.service import com.daedan.festabook.data.model.request.FestivalNotificationRequest import com.daedan.festabook.data.model.response.festival.FestivalNotificationResponse +import com.daedan.festabook.data.model.response.festival.RegisteredFestivalNotificationResponse import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE +import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path @@ -19,4 +21,9 @@ interface FestivalNotificationService { suspend fun deleteFestivalNotification( @Path("festivalNotificationId") id: Long, ): Response + + @GET("festivals/notifications/{deviceId}") + suspend fun getFestivalNotification( + @Path("deviceId") id: Long, + ): Response> } diff --git a/app/src/main/java/com/daedan/festabook/data/service/FestivalService.kt b/app/src/main/java/com/daedan/festabook/data/service/FestivalService.kt index 5c9c3f1c..5adeb553 100644 --- a/app/src/main/java/com/daedan/festabook/data/service/FestivalService.kt +++ b/app/src/main/java/com/daedan/festabook/data/service/FestivalService.kt @@ -15,9 +15,9 @@ interface FestivalService { @GET("festivals/geography") suspend fun fetchOrganizationGeography(): Response - @GET("festivals/universities") + @GET("/organizations/festivals/search") suspend fun findUniversitiesByName( - @Query("universityName") universityName: String, + @Query("keyword") keyword: String, ): Response> @GET("festivals/lost-item-guide") diff --git a/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt b/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt index acbbe8af..1bbbdc9d 100644 --- a/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt +++ b/app/src/main/java/com/daedan/festabook/data/service/api/FestaBookAuthInterceptor.kt @@ -9,7 +9,8 @@ import okhttp3.Response import timber.log.Timber @ContributesBinding(AppScope::class) -class FestaBookAuthInterceptor @Inject constructor( +@Inject +class FestaBookAuthInterceptor( private val festivalLocalDataSource: FestivalLocalDataSource, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { diff --git a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt index 80fc6739..8a29e5d8 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -6,10 +6,10 @@ import androidx.fragment.app.Fragment import com.daedan.festabook.FestaBookApp import com.daedan.festabook.di.viewmodel.MetroViewModelFactory import com.daedan.festabook.logging.DefaultFirebaseLogger -import com.daedan.festabook.presentation.main.MainActivity +import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior -import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.splash.AppVersionManager import com.daedan.festabook.presentation.splash.SplashActivity import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateManagerFactory @@ -28,25 +28,24 @@ interface FestaBookAppGraph { fun inject(app: FestaBookApp) - fun inject(activity: MainActivity) - fun inject(activity: SplashActivity) fun inject(activity: PlaceDetailActivity) - fun inject(placeListBottomSheetBehavior: PlaceListBottomSheetBehavior<*>) - // splashActivity @Provides - fun provideAppUpdateManager(application: Application): AppUpdateManager = - AppUpdateManagerFactory.create(application) + fun provideAppUpdateManager(application: Application): AppUpdateManager = AppUpdateManagerFactory.create(application) // logger val defaultFirebaseLogger: DefaultFirebaseLogger val metroViewModelFactory: MetroViewModelFactory - val scheduleViewModelFactory: ScheduleViewModel.Factory + val notificationPermissionManagerFactory: NotificationPermissionManager.Factory + + val appVersionManagerFactory: AppVersionManager.Factory + + val placeDetailViewModelFactory: PlaceDetailViewModel.Factory } val Context.appGraph get() = (applicationContext as FestaBookApp).festaBookGraph diff --git a/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt b/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt index d0a3bed6..8e6d51b2 100644 --- a/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt +++ b/app/src/main/java/com/daedan/festabook/di/fragment/MetroFragmentFactory.kt @@ -9,7 +9,8 @@ import dev.zacsweers.metro.Provider import kotlin.reflect.KClass @ContributesBinding(AppScope::class) -class MetroFragmentFactory @Inject constructor( +@Inject +class MetroFragmentFactory( private val creators: Map, Provider>, ) : FragmentFactory() { override fun instantiate( diff --git a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt index 4687ae9b..8df85db6 100644 --- a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt +++ b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.di.mapManager -import com.daedan.festabook.presentation.placeMap.MapClickListener -import com.daedan.festabook.presentation.placeMap.MapClickListenerImpl import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListenerImpl import com.daedan.festabook.presentation.placeMap.mapManager.internal.OverlayImageManager import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.iconResources diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt new file mode 100644 index 00000000..a9517516 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.placeMapHandler + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class CachedPlaceByTimeTag diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt new file mode 100644 index 00000000..26f64913 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.placeMapHandler + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class CachedPlaces diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt new file mode 100644 index 00000000..f55ac9de --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.di.placeMapHandler + +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.handler.FilterEventHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlEventHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.SelectEventHandler +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.GraphExtension +import dev.zacsweers.metro.Provides + +@GraphExtension(PlaceMapViewModelScope::class) +interface PlaceMapHandlerGraph { + val filterEventHandler: FilterEventHandler + val selectEventHandler: SelectEventHandler + val mapControlEventHandler: MapControlEventHandler + + @ContributesTo(AppScope::class) + @GraphExtension.Factory + interface Factory { + fun create( + @Provides context: EventHandlerContext, + ): PlaceMapHandlerGraph + } +} diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt new file mode 100644 index 00000000..cbf9f4b6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.di.placeMapHandler + +abstract class PlaceMapViewModelScope private constructor() diff --git a/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt b/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt index 8f380ea3..a353511d 100644 --- a/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt +++ b/app/src/main/java/com/daedan/festabook/di/viewmodel/MetroViewModelFactory.kt @@ -10,7 +10,8 @@ import dev.zacsweers.metro.Provider import kotlin.reflect.KClass @ContributesBinding(AppScope::class) -class MetroViewModelFactory @Inject constructor( +@Inject +class MetroViewModelFactory( private val creators: Map, Provider>, ) : ViewModelProvider.Factory { @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") diff --git a/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt b/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt index 0f091167..b9b60668 100644 --- a/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt +++ b/app/src/main/java/com/daedan/festabook/domain/model/Notice.kt @@ -1,6 +1,6 @@ package com.daedan.festabook.domain.model -import java.time.LocalDateTime +import kotlinx.datetime.LocalDateTime data class Notice( val id: Long, diff --git a/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt b/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt index 6dbc68a2..19a83f4e 100644 --- a/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt +++ b/app/src/main/java/com/daedan/festabook/domain/model/PlaceCategory.kt @@ -11,7 +11,7 @@ enum class PlaceCategory { PRIMARY, STAGE, PHOTO_BOOTH, - EXTRA + EXTRA, ; companion object { diff --git a/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt b/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt index 4d885acd..70487c9c 100644 --- a/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt +++ b/app/src/main/java/com/daedan/festabook/domain/repository/FestivalNotificationRepository.kt @@ -5,6 +5,8 @@ interface FestivalNotificationRepository { suspend fun deleteFestivalNotification(): Result + suspend fun syncFestivalNotificationIsAllow(): Result + fun getFestivalNotificationIsAllow(): Boolean fun setFestivalNotificationIsAllow(isAllowed: Boolean) diff --git a/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt b/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt index 1319e6c7..a80e8407 100644 --- a/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt +++ b/app/src/main/java/com/daedan/festabook/logging/DefaultFirebaseLogger.kt @@ -16,7 +16,8 @@ import kotlinx.coroutines.tasks.await import java.time.LocalDateTime @SingleIn(AppScope::class) -class DefaultFirebaseLogger @Inject constructor( +@Inject +class DefaultFirebaseLogger( private val firebaseAnalytics: FirebaseAnalytics, private val festivalLocalDataSource: FestivalLocalDataSource, private val festivalNotificationLocalDataSource: FestivalNotificationLocalDataSource, diff --git a/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt b/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt index 0c8b5781..58370b89 100644 --- a/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt +++ b/app/src/main/java/com/daedan/festabook/logging/FirebaseAnalyticsTree.kt @@ -10,7 +10,8 @@ import timber.log.Timber import java.util.Locale @ContributesBinding(AppScope::class) -class FirebaseAnalyticsTree @Inject constructor( +@Inject +class FirebaseAnalyticsTree( private val analytics: FirebaseAnalytics, ) : Timber.Tree() { override fun log( diff --git a/app/src/main/java/com/daedan/festabook/presentation/FestabookScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/FestabookScreen.kt new file mode 100644 index 00000000..1d24f4fa --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/FestabookScreen.kt @@ -0,0 +1,58 @@ +package com.daedan.festabook.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import com.daedan.festabook.presentation.explore.navigation.exploreNavGraph +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.navigation.mainNavGraph +import com.daedan.festabook.presentation.main.rememberFestabookNavigator +import com.daedan.festabook.presentation.platform.rememberAppGraph +import com.daedan.festabook.presentation.platform.rememberAppVersionManager +import com.daedan.festabook.presentation.platform.rememberLocationSource +import com.daedan.festabook.presentation.splash.SplashViewModel +import com.daedan.festabook.presentation.splash.navigation.splashNavGraph + +@Composable +fun FestabookScreen( + onAppFinish: () -> Unit, + modifier: Modifier = Modifier, + splashViewModel: SplashViewModel = viewModel(), +) { + val appGraph = rememberAppGraph() + val locationSource = rememberLocationSource() + val festabookNavigator = rememberFestabookNavigator() + + val appVersionManager = + rememberAppVersionManager( + factory = appGraph.appVersionManagerFactory, + onUpdateSuccess = { splashViewModel.handleVersionCheckResult(Result.success(false)) }, + onUpdateFailure = { splashViewModel.handleVersionCheckResult(Result.failure(Exception("Update failed"))) }, + ) + + NavHost( + modifier = modifier, + startDestination = festabookNavigator.startRoute, + navController = festabookNavigator.navController, + ) { + splashNavGraph( + appGraph = appGraph, + appVersionManager = appVersionManager, + onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, + onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, + onFinishApp = onAppFinish, + ) + exploreNavGraph( + appGraph = appGraph, + onBackClick = { festabookNavigator.popBackStack() }, + onNavigateToMain = { festabookNavigator.navigate(FestabookRoute.Main) }, + ) + mainNavGraph( + appGraph = appGraph, + onAppFinish = onAppFinish, + locationSource = locationSource, + festabookNavigator = festabookNavigator, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/NotificationPermissionManager.kt b/app/src/main/java/com/daedan/festabook/presentation/NotificationPermissionManager.kt index 40b5759f..503794a5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/NotificationPermissionManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/NotificationPermissionManager.kt @@ -14,14 +14,16 @@ import timber.log.Timber @AssistedInject class NotificationPermissionManager( - @Assisted private val requester: NotificationPermissionRequester, + @Assisted private val launchPermission: (String) -> Unit, + @Assisted private val shouldShowRationale: (String) -> Boolean, @Assisted("granted") private val onPermissionGranted: () -> Unit = {}, @Assisted("denied") private val onPermissionDenied: () -> Unit = {}, ) { @AssistedFactory interface Factory { fun create( - requester: NotificationPermissionRequester, + launchPermission: (String) -> Unit, + shouldShowRationale: (String) -> Boolean, @Assisted("granted") onPermissionGranted: () -> Unit = {}, @Assisted("denied") onPermissionDenied: () -> Unit = {}, ): NotificationPermissionManager @@ -39,7 +41,7 @@ class NotificationPermissionManager( onPermissionGranted() } - requester.shouldShowPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { + shouldShowRationale(Manifest.permission.POST_NOTIFICATIONS) -> { // 이전에 거부했지만 "다시 묻지 않음"을 선택하지 않은 경우 // 권한이 필요한 이유를 설명하는 UI(예: AlertDialog)를 표시 Timber.d("Show rationale for notification permission") @@ -50,7 +52,7 @@ class NotificationPermissionManager( // 권한이 없으며, 이전에 "다시 묻지 않음"을 선택했거나 첫 요청인 경우 // 바로 권한 요청 다이얼로그 표시 Timber.d("Requesting notification permission for the first time or after 'don't ask again'") - requester.permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + launchPermission(Manifest.permission.POST_NOTIFICATIONS) } } } else { @@ -65,7 +67,7 @@ class NotificationPermissionManager( .setMessage(R.string.notification_permission_message) .setPositiveButton(R.string.confirm) { dialog, _ -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requester.permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + launchPermission(Manifest.permission.POST_NOTIFICATIONS) } dialog.dismiss() }.setNegativeButton(R.string.cancel) { dialog, _ -> diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt index eac13b87..9dcf8677 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/AnimationUtil.kt @@ -26,4 +26,4 @@ fun ViewGroup.showBottomAnimation() { .setDuration(300) // 0.5초 동안 .setInterpolator(DecelerateInterpolator()) // 점점 느려지게 .start() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt index 8d7c3f96..38fd1c1d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt @@ -4,33 +4,23 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.os.Build -import android.os.Bundle import android.os.Parcelable import android.util.TypedValue import android.view.View import android.view.ViewGroup -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.os.BundleCompat.getSerializable +import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.daedan.festabook.R import com.daedan.festabook.data.util.ApiResultException -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetFollowBehavior -import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import java.io.Serializable -inline fun Bundle.getObject(key: String): T? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) as? T - } - -inline fun Intent.getSerializableCompat(key: String):T? = +inline fun Intent.getSerializableCompat(key: String): T? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { getSerializableExtra(key, T::class.java) } else { + @Suppress("DEPRECATION") getSerializableExtra(key) as? T } @@ -38,6 +28,7 @@ inline fun Intent.getObject(key: String): T? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { getParcelableExtra(key, T::class.java) } else { + @Suppress("DEPRECATION") getParcelableExtra(key) as? T } @@ -56,6 +47,7 @@ fun View.getSystemBarHeightCompat() = WindowInsetsCompat.Type.systemBars(), ).bottom } else { + @Suppress("DEPRECATION") rootWindowInsets.systemWindowInsetBottom } @@ -71,7 +63,7 @@ fun Activity.showErrorSnackBar(msg: String) { Snackbar.LENGTH_SHORT, ) snackBar.setAnchorView( - findViewById(R.id.bab_menu), + findViewById(R.id.cv_main), ) snackBar .setAction( @@ -124,7 +116,7 @@ fun Activity.showSnackBar(msg: String) { Snackbar.LENGTH_SHORT, ) snackBar.setAnchorView( - findViewById(R.id.bab_menu), + findViewById(R.id.cv_main), ) snackBar .setAction( @@ -134,8 +126,3 @@ fun Activity.showSnackBar(msg: String) { }.setActionTextColor(getColor(R.color.blue400)) snackBar.show() } - -fun View.placeListBottomSheetFollowBehavior(): PlaceListBottomSheetFollowBehavior? { - val params = layoutParams as? CoordinatorLayout.LayoutParams - return params?.behavior as? PlaceListBottomSheetFollowBehavior -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt index 9f5efba3..40e8634b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/ImageUtil.kt @@ -30,12 +30,12 @@ fun ImageView.loadImage( } } -fun String?.convertImageUrl() = if (this != null && this.startsWith("/images/")) { - BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this -} else { - this -} - +fun String?.convertImageUrl() = + if (this != null && this.startsWith("/images/")) { + BuildConfig.FESTABOOK_IMAGE_URL + this + } else { + this + } fun vectorToBitmap( context: Context, diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt new file mode 100644 index 00000000..f5e67fb1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + onEvent: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt index bf71e751..4972b425 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/PermissionUtil.kt @@ -1,6 +1,5 @@ package com.daedan.festabook.presentation.common -import android.Manifest import android.content.Context import android.content.Intent import android.net.Uri @@ -21,7 +20,7 @@ fun showNotificationDeniedSnackbar( view, text, Snackbar.LENGTH_LONG, - ).setAnchorView(view.rootView.findViewById(R.id.bab_menu)) + ).setAnchorView(view.rootView.findViewById(R.id.cv_main)) .setAction(context.getString(R.string.move_to_setting_text)) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt index e1262009..cacbebad 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CardBackground.kt @@ -4,38 +4,42 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes @Composable fun Modifier.cardBackground( backgroundColor: Color = FestabookColor.gray100, borderStroke: Dp = 1.dp, borderColor: Color = FestabookColor.gray200, - roundedCornerShape: Dp = 16.dp, + shape: Shape = festabookShapes.radius3, ): Modifier = background( color = backgroundColor, - shape = RoundedCornerShape(roundedCornerShape), + shape = shape, ).border( width = borderStroke, color = borderColor, - shape = RoundedCornerShape(roundedCornerShape), + shape = shape, ) @Composable @Preview(showBackground = true) private fun CardBackgroundPreview() { - Box( - modifier = - Modifier - .cardBackground() - .size(120.dp), - ) + FestabookTheme { + Box( + modifier = + Modifier + .cardBackground() + .size(120.dp), + ) + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt index 880ba912..35677f34 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -5,13 +5,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest -import coil3.request.crossfade -import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.convertImageUrl @Composable fun CoilImage( @@ -24,23 +22,20 @@ fun CoilImage( AsyncImage( model = ImageRequest - .Builder(LocalContext.current) + .Builder(LocalPlatformContext.current) .apply(builder) - .data(url) - .crossfade(true) + .data(url.convertImageUrl()) .build(), contentDescription = contentDescription, contentScale = contentScale, placeholder = ColorPainter(Color.LightGray), - fallback = painterResource(R.drawable.img_fallback), - error = painterResource(R.drawable.img_fallback), modifier = modifier, ) } @Composable @Preview -fun CoilImagePreview() { +private fun CoilImagePreview() { CoilImage( url = "", contentDescription = "", diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/ErrorStateScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/ErrorStateScreen.kt new file mode 100644 index 00000000..8c83fff1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/ErrorStateScreen.kt @@ -0,0 +1,46 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ErrorStateScreen(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.ic_fail_load), + contentDescription = stringResource(R.string.content_description_iv_fail_load), + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) + Text( + text = stringResource(R.string.error_fail_to_load_info), + style = FestabookTypography.bodyLarge, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ErrorStateScreenPreview() { + ErrorStateScreen() +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt new file mode 100644 index 00000000..43c03dbe --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookImage.kt @@ -0,0 +1,164 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.request.ImageRequest +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.convertImageUrl +import com.daedan.festabook.presentation.theme.FestabookColor +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil3.CoilImage +import com.skydoves.landscapist.components.rememberImageComponent +import com.skydoves.landscapist.placeholder.shimmer.Shimmer +import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin +import com.skydoves.landscapist.zoomable.ZoomablePlugin +import com.skydoves.landscapist.zoomable.rememberZoomableState + +@Composable +fun FestabookImage( + imageUrl: String?, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + isZoomable: Boolean = false, + enablePopUp: Boolean = false, + builder: ImageRequest.Builder.() -> Unit = {}, +) { + val context = LocalContext.current + val zoomableState = rememberZoomableState() + val convertedUrl = imageUrl.convertImageUrl() + + var isPopUpOpen by remember { mutableStateOf(false) } + + Box( + modifier = + modifier.then( + if (enablePopUp) { + Modifier.clickable { isPopUpOpen = true } + } else { + Modifier + }, + ), + ) { + CoilImage( + imageRequest = { + ImageRequest + .Builder(context) + .data(convertedUrl) + .apply(builder) + .build() + }, + modifier = Modifier.fillMaxSize(), + imageOptions = + ImageOptions( + contentScale = contentScale, + alignment = Alignment.Center, + contentDescription = contentDescription, + ), + component = + rememberImageComponent { + +ShimmerPlugin( + Shimmer.Flash( + baseColor = FestabookColor.gray100.copy(alpha = 0.5f), + highlightColor = FestabookColor.gray200.copy(alpha = 0.3f), + ), + ) + if (isZoomable) { + +ZoomablePlugin(state = zoomableState) + } + }, + failure = { + Image( + painter = painterResource(id = R.drawable.img_fallback), + contentDescription = "fallback_image", + modifier = Modifier.align(Alignment.Center), + contentScale = contentScale, + ) + }, + ) + } + if (isPopUpOpen && enablePopUp) { + FestabookImageZoomPopup( + imageUrl = imageUrl, + onDismiss = { isPopUpOpen = false }, + ) + } +} + +@Composable +private fun FestabookImageZoomPopup( + imageUrl: String?, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(FestabookColor.black.copy(alpha = 0.8f)), + ) { + FestabookImage( + imageUrl = imageUrl, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + isZoomable = true, + enablePopUp = false, + ) + + IconButton( + onClick = onDismiss, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close the popup", + tint = FestabookColor.white, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FestabookImageTestPreview() { + FestabookImage( + imageUrl = "", + ) +} + +@Preview(showBackground = true) +@Composable +private fun DiaplogPreview() { + FestabookImageZoomPopup( + imageUrl = "", + ) { } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt new file mode 100644 index 00000000..dd80e51e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt @@ -0,0 +1,33 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.daedan.festabook.presentation.theme.FestabookColor + +@Composable +fun FestabookSwitch( + enabled: Boolean, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Switch( + enabled = enabled, + modifier = modifier.wrapContentSize(), + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors().copy( + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent, + disabledCheckedTrackColor = FestabookColor.black, + disabledUncheckedTrackColor = FestabookColor.gray200, + checkedTrackColor = FestabookColor.black, + uncheckedTrackColor = FestabookColor.gray200, + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt new file mode 100644 index 00000000..cd09357f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookTopAppBar.kt @@ -0,0 +1,37 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun FestabookTopAppBar( + title: String, + modifier: Modifier = Modifier, + style: TextStyle = FestabookTypography.displayLarge, +) { + Text( + text = title, + style = style, + modifier = + modifier + .padding( + top = festabookSpacing.paddingTitleHorizontal, + bottom = festabookSpacing.paddingBody4, + start = festabookSpacing.paddingScreenGutter, + end = festabookSpacing.paddingScreenGutter, + ).fillMaxWidth(), + ) +} + +@Composable +@Preview(showBackground = true) +private fun FestabookTopAppBarPreview() { + FestabookTopAppBar(title = "FestaBook") +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt index 741d403a..a1305503 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/LoadingStateScreen.kt @@ -12,11 +12,15 @@ import com.airbnb.lottie.compose.rememberLottieComposition import com.daedan.festabook.R @Composable -fun LoadingStateScreen(modifier: Modifier = Modifier) { +fun LoadingStateScreen( + modifier: Modifier = Modifier, + isPlaying: Boolean = true, +) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading)) val progress by animateLottieCompositionAsState( composition = composition, iterations = LottieConstants.IterateForever, + isPlaying = isPlaying, ) LottieAnimation( composition = composition, diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt index 18bc0b80..71a743c7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/PullToRefreshContainer.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -22,7 +23,7 @@ import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.theme.FestabookColor -const val PULL_OFFSET_LIMIT = 180F +private const val PULL_OFFSET_LIMIT = 180F @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,7 +32,7 @@ fun PullToRefreshContainer( onRefresh: () -> Unit, modifier: Modifier = Modifier, pullOffsetLimit: Float = PULL_OFFSET_LIMIT, - content: @Composable (PullToRefreshState) -> Unit, + content: @Composable (Modifier) -> Unit, ) { val pullToRefreshState = rememberPullToRefreshState() val threshold = (pullOffsetLimit / 2).dp @@ -52,7 +53,11 @@ fun PullToRefreshContainer( }, modifier = modifier.fillMaxSize(), ) { - content(pullToRefreshState) + content( + Modifier.graphicsLayer { + translationY = pullToRefreshState.distanceFraction * pullOffsetLimit + }, + ) } } @@ -63,8 +68,8 @@ private fun PullToRefreshIndicator( isRefreshing: Boolean, onRefresh: () -> Unit, pullOffsetLimit: Float, - modifier: Modifier = Modifier, threshold: Dp, + modifier: Modifier = Modifier, ) { val indicatorSize = (pullOffsetLimit / 5).dp val centerOffset = -(threshold / 2 - indicatorSize / 2) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/SnackBar.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/SnackBar.kt new file mode 100644 index 00000000..244aa363 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/SnackBar.kt @@ -0,0 +1,107 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.daedan.festabook.R +import com.daedan.festabook.data.util.ApiResultException +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +@Composable +fun FestabookSnackbar( + data: SnackbarData, + modifier: Modifier = Modifier, +) { + Snackbar( + modifier = modifier, + snackbarData = data, + actionColor = FestabookColor.accentBlue, + ) +} + +class SnackbarManager( + val hostState: SnackbarHostState, + val scope: CoroutineScope, + private val actionLabel: String, + private val errorMessages: Map, String>, + private val defaultErrorMessage: String, + private val permissionDeniedMessage: String, + private val moveToSettingLabel: String, +) { + fun show(message: String) { + hostState.currentSnackbarData?.dismiss() + scope.launch { + hostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, + actionLabel = actionLabel, + ) + } + } + + fun showError(throwable: Throwable) { + val message = errorMessages[throwable::class] ?: defaultErrorMessage + show(message) + } + + fun showPermissionDeniedSnackbar(onOpenSettings: () -> Unit) { + hostState.currentSnackbarData?.dismiss() + scope.launch { + val result = + hostState.showSnackbar( + message = permissionDeniedMessage, + actionLabel = moveToSettingLabel, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + onOpenSettings() + } + } + } +} + +@Composable +fun rememberAppSnackbarManager( + snackbarHostState: SnackbarHostState, + scope: CoroutineScope = rememberCoroutineScope(), +): SnackbarManager { + val clientErrorMessage = stringResource(R.string.error_client_exception) + val serverErrorMessage = stringResource(R.string.error_server_exception) + val networkErrorMessage = stringResource(R.string.error_network_exception) + val unknownErrorMessage = stringResource(R.string.error_unknown_exception) + val actionLabel = stringResource(R.string.fail_snackbar_confirm) + val permissionDeniedMessage = stringResource(R.string.notification_permission_denied_message) + val moveToSettingLabel = stringResource(R.string.move_to_setting_text) + + val errorMessages = + remember { + mapOf( + ApiResultException.ClientException::class to clientErrorMessage, + ApiResultException.ServerException::class to serverErrorMessage, + ApiResultException.NetworkException::class to networkErrorMessage, + ApiResultException.UnknownException::class to unknownErrorMessage, + ) + } + + return remember(snackbarHostState, scope) { + SnackbarManager( + snackbarHostState, + scope, + actionLabel, + errorMessages, + unknownErrorMessage, + permissionDeniedMessage, + moveToSettingLabel, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt new file mode 100644 index 00000000..3d1a81ff --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/URLText.kt @@ -0,0 +1,113 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import com.daedan.festabook.presentation.theme.FestabookColor + +@Composable +fun URLText( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + val uriHandler = LocalUriHandler.current + var layoutResult by remember { mutableStateOf(null) } + val linkedText = + buildAnnotatedString { + append(text) + WEB_REGEX.findAll(text).forEach { result -> + addStyle( + style = + SpanStyle( + color = FestabookColor.gray500, + textDecoration = TextDecoration.Underline, + ), + start = result.range.first, + end = result.range.last + 1, + ) + addStringAnnotation( + tag = "URL", + annotation = result.value, + start = result.range.first, + end = result.range.last + 1, + ) + } + } + Text( + text = linkedText, + modifier = + modifier.pointerInput(Unit) { + detectTapGestures { + layoutResult?.let { result -> + val position = result.getOffsetForPosition(it) + linkedText + .getStringAnnotations("URL", position, position) + .firstOrNull() + ?.let { annotation -> + uriHandler.openUri(annotation.item) + } ?: onClick() + } + } + }, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + onTextLayout = { + layoutResult = it + onTextLayout(it) + }, + style = style, + ) +} + +private val WEB_REGEX = + """(https?|ftp|file)://[a-zA-Z0-9+&@#/%?=~_|!:,.;]+(? - val systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - if (imeInsets.bottom > systemInsets.bottom) { - view.setPadding( - view.paddingLeft, - view.paddingTop, - view.paddingRight, - imeInsets.bottom - systemInsets.bottom, + setContent { + FestabookTheme { + ExploreScreen( + viewModel = viewModel, + onNavigateToMain = { + navigateToMainActivity() + }, + onBackClick = { finish() }, ) - } else { - view.setPadding( - view.paddingLeft, - view.paddingTop, - view.paddingRight, - 0, - ) - } - insets - } - - setContentView(binding.root) - - viewModel.checkFestivalId() - - setupBinding() - setupRecyclerView() - setupObservers() - - binding.logger.log( - ExploreViewLogData( - baseLogData = binding.logger.getBaseLogData(), - hasFestivalId = viewModel.hasFestivalId.value ?: false, - ), - ) - } - - private fun setupBinding() { - binding.etSearchText.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - binding.tilSearchInputLayout.boxStrokeColor = getColor(R.color.blue400) - } else { - binding.tilSearchInputLayout.boxStrokeColor = getColor(R.color.gray400) - } - } - - binding.etSearchText.doOnTextChanged { text, _, _, _ -> - viewModel.onTextInputChanged(text?.toString().orEmpty()) - binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM - - if (text.isNullOrEmpty()) { - // 검색 아이콘 - binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_search) - binding.tilSearchInputLayout.setEndIconOnClickListener { - handleSearchAction() - } - binding.tilSearchInputLayout.endIconContentDescription = "검색" - } else { - // X 아이콘 - binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_close) - binding.tilSearchInputLayout.setEndIconOnClickListener { - binding.etSearchText.text?.clear() - } - binding.tilSearchInputLayout.endIconContentDescription = "입력 내용 지우기" - } - } - - // 키보드 엔터(검색) 리스너 - binding.etSearchText.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_SEARCH) { - handleSearchAction() - return@setOnEditorActionListener true } - return@setOnEditorActionListener false } - - binding.tilSearchInputLayout.setEndIconOnClickListener { - handleSearchAction() - } - - binding.btnBackToMain.setOnClickListener { - finish() - } - } - - private fun handleSearchAction() { - hideKeyboard() - } - - private fun setupRecyclerView() { - binding.rvSearchResults.adapter = searchResultAdapter - } - - private fun setupObservers() { - viewModel.searchState.observe(this) { state -> - when (state) { - is SearchUiState.Idle -> { - binding.tilSearchInputLayout.isErrorEnabled = false - binding.tilSearchInputLayout.error = null - binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM - binding.tilSearchInputLayout.setEndIconDrawable(R.drawable.ic_search) - searchResultAdapter.submitList(emptyList()) - } - - is SearchUiState.Loading -> Unit - is SearchUiState.Success -> { - // 검색 결과가 없을 때 - if (state.universitiesFound.isEmpty()) { - binding.tilSearchInputLayout.isErrorEnabled = true - binding.tilSearchInputLayout.error = - getString(R.string.explore_no_search_result_text) - binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_NONE - } else { - // 검색 결과가 있을 때 - binding.tilSearchInputLayout.isErrorEnabled = false - searchResultAdapter.submitList(state.universitiesFound) - } - - binding.logger.log( - ExploreSearchResultLogData( - baseLogData = binding.logger.getBaseLogData(), - query = - binding.etSearchText.text - ?.toString() - .orEmpty(), - resultCount = state.universitiesFound.size, - ), - ) - } - - is SearchUiState.Error -> { - binding.tilSearchInputLayout.isErrorEnabled = true - binding.tilSearchInputLayout.error = - getString(R.string.explore_error_text, state.throwable.message) - binding.tilSearchInputLayout.endIconMode = TextInputLayout.END_ICON_NONE - } - } - } - - viewModel.navigateToMain.observe(this) { university -> - university?.let { - navigateToMainActivity() - } - } - viewModel.hasFestivalId.observe(this) { hasId -> - binding.layoutExploreToolbar.visibility = if (hasId) View.VISIBLE else View.GONE - binding.ivLogoTitle.visibility = if (hasId) View.GONE else View.VISIBLE - } - } - - private fun hideKeyboard() { - val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(binding.etSearchText.windowToken, 0) } private fun navigateToMainActivity() { diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt new file mode 100644 index 00000000..27f0eb8b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreSideEffect.kt @@ -0,0 +1,9 @@ +package com.daedan.festabook.presentation.explore + +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel + +sealed interface ExploreSideEffect { + data class NavigateToMain( + val searchResult: SearchResultUiModel, + ) : ExploreSideEffect +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreUiState.kt new file mode 100644 index 00000000..31254fed --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreUiState.kt @@ -0,0 +1,7 @@ +package com.daedan.festabook.presentation.explore + +data class ExploreUiState( + val query: String = "", + val searchState: SearchUiState = SearchUiState.Idle, + val hasFestivalId: Boolean = false, +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt index 3b0df115..39dd8b93 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/ExploreViewModel.kt @@ -1,102 +1,94 @@ package com.daedan.festabook.presentation.explore -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.ExploreRepository -import com.daedan.festabook.presentation.common.SingleLiveData import com.daedan.festabook.presentation.explore.model.SearchResultUiModel import com.daedan.festabook.presentation.explore.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber +@OptIn(FlowPreview::class) @ContributesIntoMap(AppScope::class) @ViewModelKey(ExploreViewModel::class) -class ExploreViewModel @Inject constructor( +@Inject +class ExploreViewModel( private val exploreRepository: ExploreRepository, ) : ViewModel() { - private val searchQuery = MutableStateFlow("") + private val _uiState = MutableStateFlow(ExploreUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _searchState = MutableLiveData() - val searchState: LiveData = _searchState - - private val _navigateToMain = SingleLiveData() - val navigateToMain: LiveData = _navigateToMain - - private val _hasFestivalId = MutableLiveData(false) - val hasFestivalId: LiveData = _hasFestivalId - - private var selectedUniversity: SearchResultUiModel? = null + private val _sideEffect = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val sideEffect = _sideEffect.asSharedFlow() init { checkFestivalId() + observeSearchQuery() + } + + private fun checkFestivalId() { + val festivalId = exploreRepository.getFestivalId() + Timber.d("festival ID : $festivalId") + if (festivalId != null) { + _uiState.update { it.copy(hasFestivalId = true) } + } + } + private fun observeSearchQuery() { viewModelScope.launch { - searchQuery - .debounce(300L) + _uiState + .map { it.query } .distinctUntilChanged() + .debounce(300L) .collectLatest { query -> -// 현재는 검색어가 없을 시, 전체 리스트를 보여주기 위해 아래의 코드를 주석처리해두었음. -// if (query.isEmpty()) { -// _searchState.value = SearchUiState.Idle -// return@collectLatest -// } + if (query.isBlank()) { + _uiState.update { it.copy(searchState = SearchUiState.Idle) } + return@collectLatest + } - _searchState.value = SearchUiState.Loading + _uiState.update { it.copy(searchState = SearchUiState.Loading) } - val result = exploreRepository.search(query) - result + exploreRepository + .search(query) .onSuccess { universitiesFound -> Timber.d("검색 성공 - received: $universitiesFound") - _searchState.value = - SearchUiState.Success(universitiesFound = universitiesFound.map { it.toUiModel() }) - }.onFailure { - Timber.d(it, "검색 실패") - _searchState.value = SearchUiState.Error(it) + val uiModels = universitiesFound.map { it.toUiModel() } + _uiState.update { + it.copy(searchState = SearchUiState.Success(universitiesFound = uiModels)) + } + }.onFailure { throwable -> + Timber.d(throwable, "검색 실패") + _uiState.update { + it.copy(searchState = SearchUiState.Error(throwable)) + } } } } } - fun checkFestivalId() { - val festivalId = exploreRepository.getFestivalId() - Timber.d("festival ID : $festivalId") - if (festivalId != null) { - _hasFestivalId.value = true - } - } - fun onUniversitySelected(university: SearchResultUiModel) { - selectedUniversity = university - _searchState.value = - SearchUiState.Success( - universitiesFound = listOf(university), -// selectedUniversity = university, - ) - navigateToMainScreen() + exploreRepository.saveFestivalId(university.festivalId) + viewModelScope.launch { + _sideEffect.emit(ExploreSideEffect.NavigateToMain(university)) + } } fun onTextInputChanged(query: String) { - searchQuery.value = query - } - - private fun navigateToMainScreen() { - val selectedUniversity = selectedUniversity - - if (selectedUniversity != null) { - Timber.d("festivalId 로 화면 이동 - ${selectedUniversity.festivalId}") - _navigateToMain.setValue(selectedUniversity) - exploreRepository.saveFestivalId(selectedUniversity.festivalId) - } + _uiState.update { it.copy(query = query) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/SearchUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/SearchUiState.kt index 4605d373..86955e40 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/SearchUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/SearchUiState.kt @@ -3,6 +3,10 @@ package com.daedan.festabook.presentation.explore import com.daedan.festabook.presentation.explore.model.SearchResultUiModel sealed interface SearchUiState { + val isEmptyResult: Boolean get() = false + val isFailure: Boolean get() = this is Error + val shouldShowErrorUi: Boolean get() = isFailure || isEmptyResult + data object Idle : SearchUiState data object Loading : SearchUiState @@ -10,7 +14,10 @@ sealed interface SearchUiState { data class Success( val universitiesFound: List = emptyList(), // val selectedUniversity: University? = null, - ) : SearchUiState + ) : SearchUiState { + override val isEmptyResult: Boolean + get() = universitiesFound.isEmpty() + } data class Error( val throwable: Throwable, diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/OnUniversityClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/OnUniversityClickListener.kt deleted file mode 100644 index 3e99fea8..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/OnUniversityClickListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.daedan.festabook.presentation.explore.adapter - -import com.daedan.festabook.presentation.explore.model.SearchResultUiModel - -fun interface OnUniversityClickListener { - fun onUniversityClick(university: SearchResultUiModel) -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/SearchResultAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/SearchResultAdapter.kt deleted file mode 100644 index 9dd8a98b..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/SearchResultAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.daedan.festabook.presentation.explore.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.explore.model.SearchResultUiModel - -class SearchResultAdapter( - private val onUniversityClickListener: OnUniversityClickListener, -) : ListAdapter(DIFF_CALLBACK) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): SearchResultViewHolder = SearchResultViewHolder.from(parent, onUniversityClickListener) - - override fun onBindViewHolder( - holder: SearchResultViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: SearchResultUiModel, - newItem: SearchResultUiModel, - ): Boolean = oldItem.festivalId == newItem.festivalId - - override fun areContentsTheSame( - oldItem: SearchResultUiModel, - newItem: SearchResultUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/SearchResultViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/SearchResultViewHolder.kt deleted file mode 100644 index ffee8b16..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/explore/adapter/SearchResultViewHolder.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.daedan.festabook.presentation.explore.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.databinding.ItemSearchResultBinding -import com.daedan.festabook.presentation.explore.model.SearchResultUiModel -import timber.log.Timber - -class SearchResultViewHolder private constructor( - private val binding: ItemSearchResultBinding, - private val onUniversityClickListener: OnUniversityClickListener, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(searchResult: SearchResultUiModel) { - binding.root.setOnClickListener { - Timber.d("아이템 클릭 - ${searchResult.universityName}") - onUniversityClickListener.onUniversityClick(searchResult) - } - binding.searchResult = searchResult - } - - companion object { - fun from( - parent: ViewGroup, - onUniversityClickListener: OnUniversityClickListener, - ): SearchResultViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = ItemSearchResultBinding.inflate(inflater, parent, false) - return SearchResultViewHolder(binding, onUniversityClickListener) - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt new file mode 100644 index 00000000..af1a97a6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreBackHeader.kt @@ -0,0 +1,50 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookTheme + +@Composable +fun ExploreBackHeader( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .height(56.dp), + contentAlignment = Alignment.CenterStart, + ) { + IconButton( + onClick = onBackClick, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.explore_back), + tint = Color.Unspecified, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreBackHeaderPreview() { + FestabookTheme { + ExploreBackHeader(onBackClick = {}) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt new file mode 100644 index 00000000..d8ea3581 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreResultItem.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun ExploreResultItem( + university: SearchResultUiModel, + onItemClick: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .clickable { onItemClick(university) } + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = university.universityName, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray800, + ) + Text( + text = university.festivalName, + style = FestabookTypography.bodySmall, + color = FestabookColor.gray600, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreResultItemPreview() { + FestabookTheme { + ExploreResultItem( + university = SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), + onItemClick = {}, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt new file mode 100644 index 00000000..be48983e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreScreen.kt @@ -0,0 +1,229 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.explore.ExploreSideEffect +import com.daedan.festabook.presentation.explore.ExploreViewModel +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme + +@Composable +fun ExploreScreen( + onNavigateToMain: (SearchResultUiModel) -> Unit, + onBackClick: () -> Unit, + viewModel: ExploreViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val keyboardController = LocalSoftwareKeyboardController.current + + val latestNavigateToMain by rememberUpdatedState(onNavigateToMain) + val latestKeyboardController by rememberUpdatedState(keyboardController) + + LaunchedEffect(viewModel) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is ExploreSideEffect.NavigateToMain -> { + latestKeyboardController?.hide() + latestNavigateToMain(effect.searchResult) + } + } + } + } + + if (uiState.hasFestivalId) { + ExploreSearchScreen( + query = uiState.query, + searchState = uiState.searchState, + onQueryChange = viewModel::onTextInputChanged, + onUniversitySelect = viewModel::onUniversitySelected, + onBackClick = onBackClick, + ) + } else { + ExploreLandingScreen( + query = uiState.query, + searchState = uiState.searchState, + onQueryChange = viewModel::onTextInputChanged, + onUniversitySelect = viewModel::onUniversitySelected, + ) + } +} + +@Composable +fun ExploreSearchScreen( + query: String, + searchState: SearchUiState, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = Color.White, + topBar = { + ExploreBackHeader( + onBackClick = onBackClick, + modifier = Modifier.statusBarsPadding(), + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(20.dp), + ) { + ExploreSearchContent( + query = query, + searchState = searchState, + onQueryChange = onQueryChange, + onUniversitySelect = onUniversitySelect, + ) + } + } +} + +@Composable +fun ExploreLandingScreen( + query: String, + searchState: SearchUiState, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val isError = searchState.shouldShowErrorUi + + val isSearchMode = query.isNotBlank() + + Scaffold( + modifier = modifier, + containerColor = Color.White, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .imePadding(), + ) { + AnimatedContent( + targetState = isSearchMode, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(tween(200)), + initialContentExit = fadeOut(tween(200)), + ) + }, + ) { searching -> + if (!searching) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(0.3f)) + + Image( + painter = painterResource(id = R.drawable.logo_title), + contentDescription = stringResource(id = R.string.explore_festabook_logo), + modifier = Modifier.height(24.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Box(modifier = Modifier.padding(horizontal = 20.dp)) { + ExploreSearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { keyboardController?.hide() }, + isError = isError, + ) + } + + Spacer(modifier = Modifier.weight(0.7f)) + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(20.dp)) + + Image( + painter = painterResource(id = R.drawable.logo_title), + contentDescription = "FestaBook Logo", + modifier = Modifier.height(24.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Box(modifier = Modifier.padding(horizontal = 20.dp)) { + ExploreSearchContent( + query = query, + searchState = searchState, + onQueryChange = onQueryChange, + onUniversitySelect = onUniversitySelect, + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchScreenPreview() { + FestabookTheme { + ExploreSearchScreen( + query = "서울", + searchState = SearchUiState.Idle, + onQueryChange = {}, + onUniversitySelect = {}, + onBackClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreLandingScreenPreview() { + FestabookTheme { + ExploreLandingScreen( + query = "ㅇㄹㅇ", + searchState = SearchUiState.Idle, + onQueryChange = {}, + onUniversitySelect = {}, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt new file mode 100644 index 00000000..f16c02c5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchBar.kt @@ -0,0 +1,140 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes + +@Composable +fun ExploreSearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + isError: Boolean, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = + modifier + .fillMaxWidth(), + placeholder = { + Text( + text = stringResource(id = R.string.explore_search_hint_text), + style = FestabookTypography.titleMedium, + color = FestabookColor.gray400, + ) + }, + supportingText = + if (isError) { + { + Text( + stringResource(R.string.explore_no_search_result_text), + color = FestabookColor.error, + ) + } + } else { + null + }, + singleLine = true, + textStyle = + FestabookTypography.titleMedium.copy( + color = FestabookColor.gray800, + ), + shape = festabookShapes.radiusFull, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = FestabookColor.gray800, + unfocusedBorderColor = FestabookColor.gray400, + errorBorderColor = FestabookColor.error, + cursorColor = FestabookColor.gray800, + errorCursorColor = FestabookColor.error, + disabledBorderColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = "Clear text", + tint = Color.Unspecified, + ) + } + } else { + IconButton(onClick = { onSearch(query) }) { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = "Search", + tint = Color.Unspecified, + ) + } + } + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Search, + showKeyboardOnFocus = true, + ), + keyboardActions = + KeyboardActions( + onSearch = { + onSearch(query) + keyboardController?.hide() + }, + ), + isError = isError, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchBarPreview() { + FestabookTheme { + ExploreSearchBar( + query = "", + onQueryChange = {}, + isError = false, + modifier = Modifier.padding(16.dp), + onSearch = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchBarErrorPreview() { + FestabookTheme { + ExploreSearchBar( + query = "서울시립대학교", + onQueryChange = {}, + isError = true, + modifier = Modifier.padding(16.dp), + onSearch = {}, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt new file mode 100644 index 00000000..d96f8314 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchContent.kt @@ -0,0 +1,66 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme + +@Composable +fun ExploreSearchContent( + query: String, + searchState: SearchUiState, + onQueryChange: (String) -> Unit, + onUniversitySelect: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val isError = searchState.shouldShowErrorUi + + Column( + modifier = + modifier + .fillMaxSize(), + ) { + Box(modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)) { + ExploreSearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { keyboardController?.hide() }, + isError = isError, + ) + } + + ExploreSearchResultList( + searchState = searchState, + onUniversitySelect = onUniversitySelect, + modifier = Modifier.weight(1f), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchContentPreview() { + FestabookTheme { + ExploreSearchContent( + query = "서울", + searchState = + SearchUiState.Success( + listOf( + SearchResultUiModel(1, "서울시립대학교", "2024 대동제"), + SearchResultUiModel(2, "서울대학교", "2024 봄축제"), + ), + ), + onQueryChange = {}, + onUniversitySelect = {}, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt new file mode 100644 index 00000000..693ae771 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/component/ExploreSearchResultList.kt @@ -0,0 +1,88 @@ +package com.daedan.festabook.presentation.explore.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.explore.SearchUiState +import com.daedan.festabook.presentation.explore.model.SearchResultUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme + +@Composable +fun ExploreSearchResultList( + searchState: SearchUiState, + onUniversitySelect: (SearchResultUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + when (searchState) { + is SearchUiState.Loading -> { + item { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.padding(top = 20.dp), + color = FestabookColor.accentBlue, + ) + } + } + } + + is SearchUiState.Success -> { + items(searchState.universitiesFound) { university -> + ExploreResultItem( + university = university, + onItemClick = onUniversitySelect, + ) + } + } + + is SearchUiState.Error -> {} + + is SearchUiState.Idle -> {} + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchResultListLoadingPreview() { + FestabookTheme { + ExploreSearchResultList( + searchState = SearchUiState.Loading, + onUniversitySelect = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreSearchResultListSuccessPreview() { + val fakeUniversities = + listOf( + SearchResultUiModel(1, "서울대학교", "대동제"), + SearchResultUiModel(2, "서울시립대학교", "대동제"), + ) + FestabookTheme { + ExploreSearchResultList( + searchState = + SearchUiState.Success( + universitiesFound = fakeUniversities, + ), + onUniversitySelect = {}, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt new file mode 100644 index 00000000..12509313 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/explore/navigation/ExploreNavigation.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.explore.navigation + +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.di.FestaBookAppGraph +import com.daedan.festabook.presentation.explore.component.ExploreScreen +import com.daedan.festabook.presentation.main.FestabookRoute + +fun NavGraphBuilder.exploreNavGraph( + appGraph: FestaBookAppGraph, + onBackClick: () -> Unit, + onNavigateToMain: () -> Unit, +) { + composable { + ExploreScreen( + viewModel = viewModel(factory = appGraph.metroViewModelFactory), + onBackClick = onBackClick, + onNavigateToMain = { onNavigateToMain() }, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt deleted file mode 100644 index 2b0b8349..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.daedan.festabook.presentation.home - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.PagerSnapHelper -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentHomeBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.home.ExploreClickLogData -import com.daedan.festabook.logging.model.home.HomeViewLogData -import com.daedan.festabook.logging.model.home.ScheduleClickLogData -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.formatFestivalPeriod -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.explore.ExploreActivity -import com.daedan.festabook.presentation.home.adapter.CenterItemMotionEnlarger -import com.daedan.festabook.presentation.home.adapter.FestivalUiState -import com.daedan.festabook.presentation.home.adapter.LineUpItemOfDayAdapter -import com.daedan.festabook.presentation.home.adapter.PosterAdapter -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding -import timber.log.Timber - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(HomeFragment::class) -class HomeFragment @Inject constructor( - private val centerItemMotionEnlarger: RecyclerView.OnScrollListener, -) : BaseFragment() { - override val layoutId: Int = R.layout.fragment_home - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: HomeViewModel by viewModels({ requireActivity() }) - - private val posterAdapter: PosterAdapter by lazy { - PosterAdapter() - } - - private val lineupOfDayAdapter: LineUpItemOfDayAdapter by lazy { - LineUpItemOfDayAdapter() - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - setupObservers() - setupAdapters() - setupNavigateToScheduleButton() - setupNavigateToExploreButton() - } - - private fun setupNavigateToExploreButton() { - binding.layoutTitleWithIcon.setOnClickListener { - binding.logger.log(ExploreClickLogData(binding.logger.getBaseLogData())) - - startActivity(ExploreActivity.newIntent(requireContext())) - } - } - - private fun setupNavigateToScheduleButton() { - binding.btnNavigateToSchedule.setOnClickListener { - binding.logger.log( - ScheduleClickLogData( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - - viewModel.navigateToScheduleClick() - } - } - - private fun setupObservers() { - viewModel.festivalUiState.observe(viewLifecycleOwner) { festivalUiState -> - when (festivalUiState) { - is FestivalUiState.Loading -> {} - is FestivalUiState.Success -> handleSuccessState(festivalUiState) - is FestivalUiState.Error -> { - showErrorSnackBar(festivalUiState.throwable) - Timber.w( - festivalUiState.throwable, - "HomeFragment: ${festivalUiState.throwable.message}", - ) - } - } - } - viewModel.lineupUiState.observe(viewLifecycleOwner) { lineupUiState -> - when (lineupUiState) { - is LineupUiState.Loading -> {} - is LineupUiState.Success -> { - lineupOfDayAdapter.submitList(lineupUiState.lineups.getLineupItems()) - } - - is LineupUiState.Error -> { - showErrorSnackBar(lineupUiState.throwable) - Timber.w( - lineupUiState.throwable, - "HomeFragment: ${lineupUiState.throwable.message}", - ) - } - } - } - } - - private fun setupAdapters() { - binding.rvHomePoster.adapter = posterAdapter - binding.rvHomeLineup.adapter = lineupOfDayAdapter - attachSnapHelper() - addScrollEffectListener() - } - - private fun handleSuccessState(festivalUiState: FestivalUiState.Success) { - binding.tvHomeOrganizationTitle.text = - festivalUiState.organization.universityName - binding.tvHomeFestivalTitle.text = - festivalUiState.organization.festival.festivalName - binding.tvHomeFestivalDate.text = - formatFestivalPeriod( - festivalUiState.organization.festival.startDate, - festivalUiState.organization.festival.endDate, - ) - - val posterUrls = - festivalUiState.organization.festival.festivalImages - .sortedBy { it.sequence } - .map { it.imageUrl } - - if (posterUrls.isNotEmpty()) { - posterAdapter.submitList(posterUrls) { - scrollToInitialPosition(posterUrls.size) - } - } - binding.logger.log( - HomeViewLogData( - baseLogData = binding.logger.getBaseLogData(), - universityName = festivalUiState.organization.universityName, - festivalId = festivalUiState.organization.id, - ), - ) - } - - private fun attachSnapHelper() { - PagerSnapHelper().attachToRecyclerView(binding.rvHomePoster) - } - - private fun scrollToInitialPosition(size: Int) { - val safeMaxValue = Int.MAX_VALUE / INFINITE_SCROLL_SAFETY_FACTOR - val initialPosition = safeMaxValue - (safeMaxValue % size) - - val layoutManager = binding.rvHomePoster.layoutManager as? LinearLayoutManager ?: return - - val itemWidth = resources.getDimensionPixelSize(R.dimen.poster_item_width) - val offset = (binding.rvHomePoster.width / 2) - (itemWidth / 2) - - layoutManager.scrollToPositionWithOffset(initialPosition, offset) - - binding.rvHomePoster.post { - (centerItemMotionEnlarger as CenterItemMotionEnlarger).expandCenterItem(binding.rvHomePoster) - } - } - - private fun addScrollEffectListener() { - binding.rvHomePoster.addOnScrollListener(centerItemMotionEnlarger) - } - - override fun onDestroyView() { - binding.rvHomePoster.clearOnScrollListeners() - super.onDestroyView() - } - - companion object { - private const val INFINITE_SCROLL_SAFETY_FACTOR = 4 - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt index 85fb947f..99ebae04 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/HomeViewModel.kt @@ -1,32 +1,36 @@ package com.daedan.festabook.presentation.home -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.common.SingleLiveData import com.daedan.festabook.presentation.home.adapter.FestivalUiState import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @ViewModelKey(HomeViewModel::class) -class HomeViewModel @Inject constructor( +@Inject +class HomeViewModel( private val festivalRepository: FestivalRepository, ) : ViewModel() { - private val _festivalUiState = MutableLiveData() - val festivalUiState: LiveData get() = _festivalUiState + private val _festivalUiState = MutableStateFlow(FestivalUiState.Loading) + val festivalUiState: StateFlow = _festivalUiState.asStateFlow() - private val _lineupUiState = MutableLiveData() - val lineupUiState: LiveData get() = _lineupUiState + private val _lineupUiState = MutableStateFlow(LineupUiState.Loading) + val lineupUiState: StateFlow = _lineupUiState.asStateFlow() - private val _navigateToScheduleEvent: SingleLiveData = SingleLiveData() - val navigateToScheduleEvent: LiveData get() = _navigateToScheduleEvent + private val _navigateToScheduleEvent = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigateToScheduleEvent: SharedFlow = _navigateToScheduleEvent.asSharedFlow() init { loadFestival() @@ -48,7 +52,7 @@ class HomeViewModel @Inject constructor( } fun navigateToScheduleClick() { - _navigateToScheduleEvent.setValue(Unit) + _navigateToScheduleEvent.tryEmit(Unit) } private fun loadLineup() { @@ -58,10 +62,8 @@ class HomeViewModel @Inject constructor( val result = festivalRepository.getLineUpGroupByDate() result .onSuccess { lineups -> - _lineupUiState.value = - LineupUiState.Success( - lineups.toUiModel(), - ) + val lineupItems = lineups.toUiModel().getLineupItems() + _lineupUiState.value = LineupUiState.Success(lineupItems) }.onFailure { _lineupUiState.value = LineupUiState.Error(it) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt index f2b84299..dcfc03e8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/LineupUiState.kt @@ -4,7 +4,7 @@ sealed interface LineupUiState { data object Loading : LineupUiState data class Success( - val lineups: LineUpItemGroupUiModel, + val lineups: List, ) : LineupUiState data class Error( diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt index a4118b66..6d78fae5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/home/adapter/PosterItemViewHolder.kt @@ -19,19 +19,19 @@ import io.getstream.photoview.dialog.PhotoViewDialog class PosterItemViewHolder( private val binding: ItemHomePosterBinding, ) : RecyclerView.ViewHolder(binding.root) { - fun bind(url: String) { - val imageDialog = PhotoViewDialog.Builder( - context = binding.root.context, - images = listOf(url), - ) { imageView, url -> - imageView.load(url.convertImageUrl()) { - crossfade(true) - } - } - .withHiddenStatusBar(false) - .withTransitionFrom(binding.ivHomePoster) - .build() + val imageDialog = + PhotoViewDialog + .Builder( + context = binding.root.context, + images = listOf(url), + ) { imageView, url -> + imageView.load(url.convertImageUrl()) { + crossfade(true) + } + }.withHiddenStatusBar(false) + .withTransitionFrom(binding.ivHomePoster) + .build() binding.ivHomePoster.loadImage(url) { transformations(RoundedCornersTransformation(20f)) diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt new file mode 100644 index 00000000..0c3cc779 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeArtistItem.kt @@ -0,0 +1,71 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeArtistItem( + artistName: String, + artistImageUrl: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.width(68.dp), + ) { + CoilImage( + url = artistImageUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(HomeArtistItem.ArtistImage) + .border(1.dp, FestabookColor.gray300, HomeArtistItem.ArtistImage), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = artistName, + style = FestabookTypography.labelLarge, + color = FestabookColor.gray700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private object HomeArtistItem { + val ArtistImage = + RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 5, + ) +} + +@Preview +@Composable +private fun HomeArtistItemPreview() { + HomeArtistItem( + artistName = "실리카겔", + artistImageUrl = "sample", + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt new file mode 100644 index 00000000..37e15d6d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeFestivalInfo.kt @@ -0,0 +1,48 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeFestivalInfo( + festivalName: String, + festivalDate: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp), + ) { + Text( + text = festivalName, + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = festivalDate, + style = FestabookTypography.bodyLarge, + color = FestabookColor.gray500, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeFestivalInfoPreview() { + HomeFestivalInfo( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + festivalDate = "2025년 10월 15일 - 10월 17일", + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt new file mode 100644 index 00000000..1d9450de --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeHeader.kt @@ -0,0 +1,71 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeHeader( + universityName: String, + onExpandClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier.clickable { onExpandClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = universityName, + style = + FestabookTypography.displayLarge.copy( + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeight = 34.sp, + ), + color = FestabookColor.black, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_dropdown), + tint = FestabookColor.black, + contentDescription = stringResource(R.string.home_navigate_to_explore_desc), + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeHeaderPreview() { + HomeHeader( + universityName = "가천대학교", + onExpandClick = {}, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt new file mode 100644 index 00000000..11b4d092 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupHeader.kt @@ -0,0 +1,75 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography + +@Composable +fun HomeLineupHeader( + onScheduleClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_lineup_title), + style = FestabookTypography.displayMedium, + color = FestabookColor.black, + ) + + Row( + modifier = + Modifier + .clickable( + onClick = onScheduleClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_navigate_to_schedule_text), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray400, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_forward_right), + contentDescription = stringResource(R.string.home_navigate_to_schedule_desc), + tint = FestabookColor.gray400, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupHeaderPreview() { + HomeLineupHeader( + onScheduleClick = {}, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt new file mode 100644 index 00000000..9efdb59d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeLineupItem.kt @@ -0,0 +1,152 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.home.LineUpItemOfDayUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTypography +import java.time.LocalDate +import java.time.LocalDateTime + +@Composable +fun HomeLineupItem( + uiModel: LineUpItemOfDayUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + // 날짜 + 배지 영역 + Column( + modifier = Modifier.padding(horizontal = 16.dp).width(IntrinsicSize.Max), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${uiModel.date.monthValue}.${uiModel.date.dayOfMonth}", + style = FestabookTypography.titleLarge, + color = FestabookColor.black, + ) + + if (uiModel.isDDay) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(FestabookColor.black) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = stringResource(id = R.string.home_is_d_day), + style = FestabookTypography.labelSmall, + color = FestabookColor.white, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray700, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 아티스트 가로 리스트 + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(uiModel.lineupItems) { item -> + HomeArtistItem( + artistName = item.name, + artistImageUrl = item.imageUrl, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeLineupItemPreview() { + HomeLineupItem( + uiModel = + LineUpItemOfDayUiModel( + id = 1L, + date = LocalDate.now(), + isDDay = true, + lineupItems = + listOf( + LineupItemUiModel( + id = 1, + name = "실리카겔", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 2, + name = "한로로", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 3, + name = "실리카겔", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 4, + name = "한로로", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 5, + name = "실리카겔", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + LineupItemUiModel( + id = 6, + name = "한로로", + imageUrl = "sample", + performanceAt = LocalDateTime.now(), + ), + ), + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt new file mode 100644 index 00000000..4e9bda6f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomePosterList.kt @@ -0,0 +1,113 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.daedan.festabook.presentation.common.component.FestabookImage +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.festabookShapes +import kotlin.math.absoluteValue + +@Composable +fun HomePosterList( + posterUrls: List, + modifier: Modifier = Modifier, +) { + if (posterUrls.isEmpty()) return + + // 무한 스크롤을 위한 큰 수 설정 + val initialPage = (Int.MAX_VALUE / 2) - ((Int.MAX_VALUE / 2) % posterUrls.size) + val pagerState = + rememberPagerState( + initialPage = initialPage, + pageCount = { Int.MAX_VALUE }, + ) + + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val itemWidth = 300.dp + // 화면 중앙에 아이템이 오도록 패딩 계산 + val horizontalPadding = (screenWidth - itemWidth) / 2 + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(itemWidth), + contentPadding = PaddingValues(horizontal = horizontalPadding), + pageSpacing = 12.dp, + modifier = + modifier + .fillMaxWidth() + .height(400.dp), // item_home_poster 높이 + verticalAlignment = Alignment.CenterVertically, + ) { page -> + val actualIndex = page % posterUrls.size + val imageUrl = posterUrls[actualIndex] + + // 스크롤 위치에 따른 Scale 계산 + val pageOffset = + ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue + + // 중앙(0)이면 1.0f, 멀어질수록 작아짐 (최소 0.9f) + val scale = + lerp( + start = 1.0f, + stop = 0.9f, + fraction = pageOffset.coerceIn(0f, 1f), + ) + + // 투명도 조절 (중앙은 1.0, 멀어지면 약간 투명하게) + val alpha = + lerp( + start = 1.0f, + stop = 0.6f, + fraction = pageOffset.coerceIn(0f, 1f), + ) + + Box( + modifier = + Modifier + .width(itemWidth) + .height(400.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + }.cardBackground(shape = festabookShapes.radius2) + .clip(festabookShapes.radius2), + ) { + FestabookImage( + imageUrl = imageUrl, + modifier = Modifier.fillMaxSize(), + enablePopUp = true, + ) + } + } +} + +@Preview +@Composable +private fun HomePosterListPreview() { + HomePosterList( + posterUrls = + listOf( + "sample", + "sample", + "sample", + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt new file mode 100644 index 00000000..5130ce54 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/component/HomeScreen.kt @@ -0,0 +1,254 @@ +package com.daedan.festabook.presentation.home.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.domain.model.Poster +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.formatFestivalPeriod +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.LineUpItemGroupUiModel +import com.daedan.festabook.presentation.home.LineupItemUiModel +import com.daedan.festabook.presentation.home.LineupUiState +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.theme.FestabookColor +import java.time.LocalDate +import java.time.LocalDateTime + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onNavigateToExplore: () -> Unit, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val festivalUiState by viewModel.festivalUiState.collectAsStateWithLifecycle() + val lineupUiState by viewModel.lineupUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + notificationPermissionManager.requestNotificationPermission(context) + } + + ObserveAsEvents(flow = settingViewModel.success) { + onShowSnackBar(context.getString(R.string.setting_notice_enabled)) + } + + ObserveAsEvents(flow = settingViewModel.error) { + currentOnShowErrorSnackbar(it) + } + + LaunchedEffect(festivalUiState) { + when (val state = festivalUiState) { + is FestivalUiState.Error -> { + currentOnShowErrorSnackbar(state.throwable) + } + + else -> { + Unit + } + } + } + + when (val state = festivalUiState) { + is FestivalUiState.Loading -> { + LoadingStateScreen(modifier = modifier) + } + + is FestivalUiState.Error -> { + Box(modifier = modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.error_fail_to_load_info), + modifier = Modifier.align(Alignment.Center), + ) + } + } + + is FestivalUiState.Success -> { + FestivalOverview( + festivalUiState = state, + lineupUiState = lineupUiState, + onNavigateToExplore = onNavigateToExplore, + onNavigateToSchedule = viewModel::navigateToScheduleClick, + modifier = modifier, + ) + } + } +} + +@Composable +private fun FestivalOverview( + festivalUiState: FestivalUiState.Success, + lineupUiState: LineupUiState, + onNavigateToExplore: () -> Unit, + onNavigateToSchedule: () -> Unit, + modifier: Modifier = Modifier, +) { + val universityName = festivalUiState.organization.universityName + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.White, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize(), + ) { + HomeHeader( + universityName = universityName, + onExpandClick = onNavigateToExplore, + modifier = Modifier.padding(top = 40.dp, bottom = 12.dp), + ) + + LazyColumn( + modifier = + Modifier + .fillMaxSize(), + ) { + // 포스터 리스트 + item { + val posterUrls = + festivalUiState.organization.festival.festivalImages + .sortedBy { it.sequence } + .map { it.imageUrl } + + HomePosterList( + posterUrls = posterUrls, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + + // 축제 정보 + item { + val festival = festivalUiState.organization.festival + HomeFestivalInfo( + festivalName = festival.festivalName, + festivalDate = + formatFestivalPeriod( + festival.startDate, + festival.endDate, + ), + modifier = Modifier.padding(top = 16.dp), + ) + } + + // 구분선 + item { + HorizontalDivider( + thickness = 4.dp, + color = FestabookColor.gray200, + modifier = + Modifier + .padding(top = 16.dp), + ) + } + + // 라인업 헤더 + item { + HomeLineupHeader( + onScheduleClick = onNavigateToSchedule, + ) + } + + // 라인업 리스트 + when (lineupUiState) { + is LineupUiState.Success -> { + items( + items = lineupUiState.lineups, + key = { it.id }, + ) { lineupItem -> + HomeLineupItem(uiModel = lineupItem) + } + } + + is LineupUiState.Loading -> { + // 로딩 시 동작 논의 후 추가 + } + + is LineupUiState.Error -> { + // 에러 표시 + } + } + + // 하단 여백 추가 + item { + Spacer(modifier = Modifier.padding(bottom = 60.dp)) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FestivalOverviewPreview() { + val sampleFestival = + Organization( + id = 1, + universityName = "가천대학교", + festival = + Festival( + festivalName = "2025 가천 Water Festival\n: AQUA WAVE", + startDate = LocalDate.now(), + endDate = LocalDate.now().plusDays(2), + festivalImages = + listOf( + Poster(1, "sample", 1), + Poster(2, "sample", 2), + ), + ), + ) + + val sampleLineups = + LineUpItemGroupUiModel( + group = + mapOf( + LocalDate.now() to + listOf( + LineupItemUiModel(1, "sample", "실리카겔", LocalDateTime.now()), + LineupItemUiModel(2, "sample", "아이유", LocalDateTime.now()), + ), + LocalDate.now().plusDays(1) to + listOf( + LineupItemUiModel(3, "sample", "뉴진스", LocalDateTime.now()), + ), + ), + ) + + FestivalOverview( + festivalUiState = FestivalUiState.Success(sampleFestival), + lineupUiState = LineupUiState.Success(sampleLineups.getLineupItems()), + onNavigateToExplore = {}, + onNavigateToSchedule = {}, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt new file mode 100644 index 00000000..f600d9ab --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/home/navigation/HomeNavigation.kt @@ -0,0 +1,42 @@ +package com.daedan.festabook.presentation.home.navigation + +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.component.HomeScreen +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.main.MainViewModel +import com.daedan.festabook.presentation.main.component.FirstVisitDialog +import com.daedan.festabook.presentation.setting.SettingViewModel + +fun NavGraphBuilder.homeNavGraph( + viewModel: HomeViewModel, + mainViewModel: MainViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackbar: (String) -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + onSubscriptionConfirm: () -> Unit, + onNavigateToExplore: () -> Unit, +) { + composable { + val isFirstVisit by mainViewModel.isFirstVisit.collectAsStateWithLifecycle() + if (isFirstVisit) { + FirstVisitDialog( + onConfirm = { onSubscriptionConfirm() }, + onDecline = { mainViewModel.declineAlert() }, + ) + } + HomeScreen( + viewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, + onNavigateToExplore = onNavigateToExplore, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = onShowSnackbar, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookMainTab.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookMainTab.kt new file mode 100644 index 00000000..77ab8b65 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookMainTab.kt @@ -0,0 +1,59 @@ +package com.daedan.festabook.presentation.main + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor + +enum class FestabookMainTab( + @param:DrawableRes val iconResId: Int, + @param:StringRes val labelResId: Int, + val route: MainTabRoute, +) { + HOME( + iconResId = R.drawable.ic_home, + labelResId = R.string.menu_home_title, + route = MainTabRoute.Home, + ), + SCHEDULE( + iconResId = R.drawable.ic_schedule, + labelResId = R.string.menu_schedule_title, + route = MainTabRoute.Schedule, + ), + PLACE_MAP( + iconResId = R.drawable.ic_map, + labelResId = R.string.menu_map_title, + route = MainTabRoute.PlaceMap, + ), + NEWS( + iconResId = R.drawable.ic_news, + labelResId = R.string.menu_news_title, + route = MainTabRoute.News, + ), + SETTING( + iconResId = R.drawable.ic_setting, + labelResId = R.string.menu_setting_title, + route = MainTabRoute.Setting, + ), + ; + + companion object Defaults { + @Composable + fun find(predicate: @Composable (FestabookRoute) -> Boolean) = + entries.find { + predicate(it.route) + } + + val selectedColor + @Composable + @ReadOnlyComposable + get() = FestabookColor.black + + val unselectedColor + @Composable + @ReadOnlyComposable + get() = FestabookColor.gray400 + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt new file mode 100644 index 00000000..9dc83e96 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookNavigator.kt @@ -0,0 +1,81 @@ +package com.daedan.festabook.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions + +class FestabookNavigator( + val navController: NavHostController, + val startRoute: FestabookRoute = FestabookRoute.Splash, +) { + private val currentDestination + @Composable + get() = + navController + .visibleEntries + .collectAsState() + .value + .lastOrNull { it.destination.route != null } + ?.destination + val currentTab + @Composable + get() = + FestabookMainTab.find { + currentDestination?.hasRoute(it::class) ?: false + } + + val shouldShowBottomBar + @Composable + get() = + FestabookMainTab.find { + currentDestination?.hasRoute(it::class) ?: false + } != null + + fun navigateToMainTab(tab: FestabookMainTab) { + navController.navigate( + tab.route, + navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }, + ) + } + + fun navigate( + route: FestabookRoute, + navOptions: NavOptions? = null, + ) { + navController.navigate( + route, + navOptions, + ) + } + + fun popBackStack() { + navController.popBackStack() + } + + fun popBackStack( + route: FestabookRoute, + inclusive: Boolean = false, + ) { + navController.popBackStack(route, inclusive) + } +} + +@Composable +fun rememberFestabookNavigator(startRoute: FestabookRoute = FestabookRoute.Splash): FestabookNavigator { + val navController = rememberNavController() + return remember { + FestabookNavigator(navController = navController, startRoute = startRoute) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt new file mode 100644 index 00000000..1ced5839 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/FestabookRoute.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook.presentation.main + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import kotlinx.serialization.Serializable + +@Serializable +sealed interface FestabookRoute { + @Serializable + data object Splash : FestabookRoute + + @Serializable + data class PlaceDetail( + val placeUiModel: PlaceUiModel? = null, + val placeDetailUiModel: PlaceDetailUiModel? = null, + ) : FestabookRoute + + @Serializable + data object Explore : FestabookRoute + + @Serializable + data object Main : FestabookRoute +} + +@Serializable +sealed interface MainTabRoute : FestabookRoute { + @Serializable + data object Home : MainTabRoute + + @Serializable + data object Schedule : MainTabRoute + + @Serializable + data object PlaceMap : MainTabRoute + + @Serializable + data object News : MainTabRoute + + @Serializable + data object Setting : MainTabRoute +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt index 8df33ef2..6db17c33 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainActivity.kt @@ -4,109 +4,31 @@ import android.Manifest import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.marginBottom -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentFactory -import androidx.fragment.app.add -import androidx.fragment.app.commit -import androidx.fragment.app.commitNow import androidx.lifecycle.ViewModelProvider import com.daedan.festabook.R -import com.daedan.festabook.databinding.ActivityMainBinding import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.NotificationPermissionManager -import com.daedan.festabook.presentation.NotificationPermissionRequester -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener +import com.daedan.festabook.presentation.FestabookScreen import com.daedan.festabook.presentation.common.isGranted import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar -import com.daedan.festabook.presentation.common.showSnackBar -import com.daedan.festabook.presentation.common.showToast -import com.daedan.festabook.presentation.home.HomeFragment -import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.news.NewsFragment -import com.daedan.festabook.presentation.placeMap.PlaceMapFragment -import com.daedan.festabook.presentation.schedule.ScheduleFragment -import com.daedan.festabook.presentation.setting.SettingFragment -import com.daedan.festabook.presentation.setting.SettingViewModel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dev.zacsweers.metro.Inject -import timber.log.Timber +import com.daedan.festabook.presentation.theme.FestabookTheme -class MainActivity : - AppCompatActivity(), - NotificationPermissionRequester { - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - - @Inject - private lateinit var fragmentFactory: FragmentFactory - - @Inject - private lateinit var notificationPermissionManagerFactory: NotificationPermissionManager.Factory - private val binding: ActivityMainBinding by lazy { - ActivityMainBinding.inflate(layoutInflater) - } - - private val mainViewModel: MainViewModel by viewModels() - private val homeViewModel: HomeViewModel by viewModels() - private val settingViewModel: SettingViewModel by viewModels() - - private val notificationPermissionManager by lazy { - notificationPermissionManagerFactory.create( - requester = this, - onPermissionGranted = { onPermissionGranted() }, - onPermissionDenied = { onPermissionDenied() }, - ) - } - - override val permissionLauncher: ActivityResultLauncher = - registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted: Boolean -> - if (isGranted) { - Timber.d("Notification permission granted") - onPermissionGranted() - } else { - Timber.d("Notification permission denied") - showNotificationDeniedSnackbar(window.decorView.rootView, this) - onPermissionDenied() - } - } - - override fun onPermissionGranted() { - settingViewModel.saveNotificationId() - } - - override fun onPermissionDenied() = Unit +class MainActivity : AppCompatActivity() { + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = appGraph.metroViewModelFactory override fun onCreate(savedInstanceState: Bundle?) { - appGraph.inject(this) - setupFragmentFactory() super.onCreate(savedInstanceState) enableEdgeToEdge() - setupBinding() - mainViewModel.registerDeviceAndFcmToken() - setupHomeFragment(savedInstanceState) - setUpBottomNavigation() - setupObservers() - onMenuItemClick() - onMenuItemReClick() - onBackPress() - handleNavigation(intent) - } - - private fun setupFragmentFactory() { - supportFragmentManager.fragmentFactory = fragmentFactory + setContent { + FestabookTheme { + FestabookScreen( + onAppFinish = ::finish, + ) + } + } } override fun onRequestPermissionsResult( @@ -116,186 +38,29 @@ class MainActivity : ) { grantResults.forEachIndexed { index, result -> val text = permissions[index] - when(text) { + when (text) { Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION -> { + Manifest.permission.ACCESS_COARSE_LOCATION, + -> { if (!result.isGranted()) { + // 이 부분은 레거시지만 NaverMap이 자동으로 권한 설정을 하기 때문에 + // 마이그래이션 하려면 MainActivity에 전역 State를 뚫어야 할 것 같습니다 showNotificationDeniedSnackbar( - binding.root, + window.decorView.rootView, this, - getString(R.string.map_request_location_permission_message) + getString(R.string.map_request_location_permission_message), ) } } } - } super.onRequestPermissionsResult(requestCode, permissions, grantResults) } - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleNavigation(intent) - } - - private fun handleNavigation(intent: Intent) { - val canNavigateToNewsScreen = - intent.getBooleanExtra(KEY_CAN_NAVIGATE_TO_NEWS, false) - val noticeIdToExpand = intent.getLongExtra(KEY_NOTICE_ID_TO_EXPAND, INITIALIZED_ID) - if (noticeIdToExpand != INITIALIZED_ID) mainViewModel.expandNoticeItem(noticeIdToExpand) - - if (canNavigateToNewsScreen) { - binding.bnvMenu.selectedItemId = R.id.item_menu_news - } - } - - private fun setupObservers() { - mainViewModel.backPressEvent.observe(this) { event -> - event.getContentIfNotHandled()?.let { isDoublePress -> - if (isDoublePress) finish() else showToast(getString(R.string.back_press_exit_message)) - } - } - homeViewModel.navigateToScheduleEvent.observe(this) { - binding.bnvMenu.selectedItemId = R.id.item_menu_schedule - } - - mainViewModel.isFirstVisit.observe(this) { isFirstVisit -> - if (isFirstVisit) { - showAlarmDialog() - } - } - settingViewModel.success.observe(this) { - showSnackBar(getString(R.string.setting_notice_enabled)) - } - } - - private fun setupBinding() { - setContentView(binding.root) - ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - } - - private fun setUpBottomNavigation() { - binding.fabMap.post { - binding.fcvFragmentContainer.updatePadding( - bottom = binding.babMenu.height + binding.babMenu.marginBottom, - ) - } - binding.babMenu.setOnApplyWindowInsetsListener(null) - binding.babMenu.setPadding(0, 0, 0, 0) - binding.bnvMenu.setOnApplyWindowInsetsListener(null) - binding.bnvMenu.setPadding(0, 0, 0, 0) - } - - private fun setupHomeFragment(savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - supportFragmentManager.commitNow { - add(R.id.fcv_fragment_container) - } - } - } - - private fun onBackPress() { - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - mainViewModel.onBackPressed() - } - }, - ) - } - - private fun onMenuItemClick() { - binding.bnvMenu.setOnItemSelectedListener { icon -> - when (icon.itemId) { - R.id.item_menu_home -> switchFragment(HomeFragment::class.java, TAG_HOME_FRAGMENT) - R.id.item_menu_schedule -> - switchFragment( - ScheduleFragment::class.java, - TAG_SCHEDULE_FRAGMENT, - ) - - R.id.item_menu_news -> switchFragment(NewsFragment::class.java, TAG_NEWS_FRAGMENT) - R.id.item_menu_setting -> - switchFragment( - SettingFragment::class.java, - TAG_SETTING_FRAGMENT, - ) - } - true - } - binding.fabMap.setOnClickListener { - binding.bnvMenu.selectedItemId = R.id.item_menu_map - val fragment = supportFragmentManager.findFragmentByTag(TAG_PLACE_MAP_FRAGMENT) - if (fragment is OnMenuItemReClickListener && !fragment.isHidden) fragment.onMenuItemReClick() - switchFragment(PlaceMapFragment::class.java, TAG_PLACE_MAP_FRAGMENT) - } - } - - private fun onMenuItemReClick() { - binding.bnvMenu.setOnItemReselectedListener { icon -> - when (icon.itemId) { - R.id.item_menu_home -> Unit - R.id.item_menu_schedule -> { - val fragment = supportFragmentManager.findFragmentByTag(TAG_SCHEDULE_FRAGMENT) - if (fragment is OnMenuItemReClickListener) fragment.onMenuItemReClick() - } - - R.id.item_menu_news -> Unit - R.id.item_menu_setting -> Unit - } - } - } - - private fun switchFragment( - fragment: Class, - tag: String, - ) { - supportFragmentManager.commit { - supportFragmentManager.fragments.forEach { fragment -> hide(fragment) } - - val existing = supportFragmentManager.findFragmentByTag(tag) - if (existing != null) { - show(existing) - } else { - add(R.id.fcv_fragment_container, fragment, null, tag) - } - setReorderingAllowed(true) - } - } - - private fun showAlarmDialog() { - val dialog = - MaterialAlertDialogBuilder(this, R.style.MainAlarmDialogTheme) - .setView(R.layout.view_main_alert_dialog) - .setPositiveButton(R.string.main_alarm_dialog_confirm_button) { _, _ -> - notificationPermissionManager.requestNotificationPermission(this) - }.setNegativeButton(R.string.main_alarm_dialog_cancel_button) { dialog, _ -> - dialog.dismiss() - }.create() - dialog.show() - } - companion object { - const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" - const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" - private const val TAG_HOME_FRAGMENT = "homeFragment" - private const val TAG_SCHEDULE_FRAGMENT = "scheduleFragment" - private const val TAG_PLACE_MAP_FRAGMENT = "placeMapFragment" - private const val TAG_NEWS_FRAGMENT = "newsFragment" - private const val TAG_SETTING_FRAGMENT = "settingFragment" - private const val INITIALIZED_ID = -1L - fun newIntent(context: Context) = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt index 4f453bb9..1311a8d8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/main/MainViewModel.kt @@ -1,38 +1,46 @@ package com.daedan.festabook.presentation.main -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.DeviceRepository import com.daedan.festabook.domain.repository.FestivalRepository -import com.daedan.festabook.presentation.common.Event import com.google.firebase.messaging.FirebaseMessaging import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber @ContributesIntoMap(AppScope::class) @ViewModelKey(MainViewModel::class) -class MainViewModel @Inject constructor( +@Inject +class MainViewModel( private val deviceRepository: DeviceRepository, festivalRepository: FestivalRepository, ) : ViewModel() { - private val _backPressEvent: MutableLiveData> = MutableLiveData() - val backPressEvent: LiveData> get() = _backPressEvent + private val _backPressEvent: MutableSharedFlow = + MutableSharedFlow( + extraBufferCapacity = 1, + ) + val backPressEvent: SharedFlow = _backPressEvent.asSharedFlow() - private val _noticeIdToExpand: MutableLiveData = MutableLiveData() - val noticeIdToExpand: LiveData = _noticeIdToExpand + private val _navigateNewsEvent: MutableSharedFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + ) + val navigateNewsEvent = _navigateNewsEvent.asSharedFlow() private val _isFirstVisit = - MutableLiveData( - festivalRepository.getIsFirstVisit().getOrDefault(true), - ) - val isFirstVisit: LiveData get() = _isFirstVisit + MutableStateFlow(festivalRepository.getIsFirstVisit().getOrDefault(true)) + val isFirstVisit: StateFlow = _isFirstVisit.asStateFlow() private var lastBackPressedTime: Long = 0 @@ -42,7 +50,10 @@ class MainViewModel @Inject constructor( Timber.d("registerDeviceAndFcmToken() UUID: $uuid, FCM: $fcmToken") when { - uuid.isBlank() -> Timber.w("❌ UUID 생성 전") + uuid.isBlank() -> { + Timber.w("❌ UUID 생성 전") + } + !fcmToken.isNullOrBlank() -> { Timber.d("✅ 기존 값으로 디바이스 등록 실행") registerDevice(uuid, fcmToken) @@ -63,13 +74,21 @@ class MainViewModel @Inject constructor( } } + fun navigateToNews() { + _navigateNewsEvent.tryEmit(Unit) + } + + fun declineAlert() { + _isFirstVisit.value = false + } + fun onBackPressed() { val currentTime = System.currentTimeMillis() if (currentTime - lastBackPressedTime < BACK_PRESS_INTERVAL) { - _backPressEvent.value = Event(true) + _backPressEvent.tryEmit(true) } else { lastBackPressedTime = currentTime - _backPressEvent.value = Event(false) + _backPressEvent.tryEmit(false) } } @@ -90,10 +109,6 @@ class MainViewModel @Inject constructor( } } - fun expandNoticeItem(announcementId: Long) { - _noticeIdToExpand.value = announcementId - } - companion object { private const val BACK_PRESS_INTERVAL: Long = 2000L } diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt b/app/src/main/java/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt new file mode 100644 index 00000000..657401fb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/FestabookBottomNavigationBar.kt @@ -0,0 +1,168 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.main.FestabookMainTab +import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.selectedColor +import com.daedan.festabook.presentation.main.FestabookMainTab.Defaults.unselectedColor +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun FestabookBottomNavigationBar( + currentTab: FestabookMainTab?, + onTabSelect: (FestabookMainTab) -> Unit, + modifier: Modifier = Modifier, + onTabReSelect: (FestabookMainTab) -> Unit = {}, +) { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = + modifier + .shadow( + elevation = 10.dp, + clip = false, + ).background(color = FestabookColor.white) + .navigationBarsPadding(), + ) { + Row( + modifier = + Modifier + .background(color = FestabookColor.white) + .fillMaxWidth() + .height(70.dp), + ) { + FestabookMainTab.entries.forEach { item -> + when (item) { + FestabookMainTab.PLACE_MAP -> { + Spacer(modifier = Modifier.weight(1f)) + } + + else -> { + FestabookNavigationItem( + tab = item, + selected = item == currentTab, + onClick = { + if (it == currentTab) onTabReSelect(it) + onTabSelect(it) + }, + ) + } + } + } + } + PlaceMapNavigationItem(onClick = { + if (it == currentTab) onTabReSelect(it) + onTabSelect(it) + }) + } +} + +@Composable +private fun RowScope.FestabookNavigationItem( + onClick: (FestabookMainTab) -> Unit, + tab: FestabookMainTab, + selected: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .weight(1f) + .fillMaxHeight() + .background(color = FestabookColor.white) + .selectable( + selected = selected, + onClick = { onClick(tab) }, + interactionSource = remember { MutableInteractionSource() }, + indication = null, // 리플(Ripple) 완벽 제거 + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = tab.iconResId), + contentDescription = stringResource(tab.labelResId), + tint = if (selected) selectedColor else unselectedColor, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody1)) + Text( + text = stringResource(tab.labelResId), + style = FestabookTypography.labelMedium, + color = if (selected) selectedColor else unselectedColor, + ) + } + } +} + +@Composable +private fun PlaceMapNavigationItem( + onClick: (FestabookMainTab) -> Unit, + modifier: Modifier = Modifier, +) { + Image( + modifier = + modifier + .offset(y = -festabookSpacing.paddingBody4) + .clickable( + onClick = { + onClick(FestabookMainTab.PLACE_MAP) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + painter = painterResource(id = R.drawable.btn_fab_manu), + contentDescription = stringResource(FestabookMainTab.PLACE_MAP.labelResId), + ) +} + +@Preview(showBackground = true) +@Composable +private fun FestabookBottomNavigationBarPreview() { + FestabookTheme { + var currentTabState by remember { mutableStateOf(FestabookMainTab.HOME) } + Scaffold( + bottomBar = { + FestabookBottomNavigationBar( + currentTab = currentTabState, + onTabSelect = { currentTabState = it }, + ) + }, + ) { it } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt b/app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt new file mode 100644 index 00000000..cb6cbffe --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/FirstVisitDialog.kt @@ -0,0 +1,175 @@ +package com.daedan.festabook.presentation.main.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun FirstVisitDialog( + onConfirm: () -> Unit, + onDecline: () -> Unit = {}, +) { + var isVisible by remember { mutableStateOf(true) } + if (isVisible) { + FirstVisitInfoDialog( + title = stringResource(id = R.string.main_alarm_dialog_title), + message = stringResource(id = R.string.main_alarm_dialog_message), + confirmButtonText = stringResource(id = R.string.main_alarm_dialog_confirm_button), + declineButtonText = stringResource(id = R.string.main_alarm_dialog_cancel_button), + iconResId = R.drawable.ic_alarm, + confirmButtonColor = FestabookColor.accentBlue, + declineButtonColor = FestabookColor.gray400, + onConfirm = { + onConfirm() + isVisible = false + }, + onDecline = onDecline, + ) + } +} + +@Composable +private fun FirstVisitInfoDialog( + title: String, + message: String, + confirmButtonText: String, + declineButtonText: String, + confirmButtonColor: Color, + declineButtonColor: Color, + onConfirm: () -> Unit, + onDecline: () -> Unit, + @DrawableRes iconResId: Int? = null, +) { + Dialog( + onDismissRequest = { onDecline() }, + properties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Column( + modifier = + Modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radius4, + ).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + iconResId?.let { + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = title, + style = FestabookTypography.displaySmall, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = message, + style = FestabookTypography.bodyMedium, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = onDecline, + modifier = + Modifier + .wrapContentWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = FestabookColor.white, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + border = BorderStroke(width = 1.dp, declineButtonColor), + contentPadding = PaddingValues(festabookSpacing.paddingBody1), + ) { + Text( + color = declineButtonColor, + text = declineButtonText, + ) + } + Spacer(Modifier.padding(festabookSpacing.paddingBody1)) + Button( + onClick = onConfirm, + modifier = + Modifier + .wrapContentWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = confirmButtonColor, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + contentPadding = PaddingValues(festabookSpacing.paddingBody3), + ) { + Text(text = confirmButtonText) + } + } + } + } +} + +@Preview +@Composable +private fun UpdateDialogPreview() { + FestabookTheme { + FirstVisitDialog( + onConfirm = {}, + onDecline = {}, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt new file mode 100644 index 00000000..f61b9063 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/component/MainScreen.kt @@ -0,0 +1,246 @@ +package com.daedan.festabook.presentation.main.component + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.navigation.compose.NavHost +import com.daedan.festabook.R +import com.daedan.festabook.di.FestaBookAppGraph +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.FestabookSnackbar +import com.daedan.festabook.presentation.common.component.SnackbarManager +import com.daedan.festabook.presentation.common.component.rememberAppSnackbarManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.navigation.homeNavGraph +import com.daedan.festabook.presentation.main.FestabookMainTab +import com.daedan.festabook.presentation.main.FestabookNavigator +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.main.MainViewModel +import com.daedan.festabook.presentation.main.rememberFestabookNavigator +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.navigation.newsNavGraph +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapRoute +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.navigation.placeMapNavGraph +import com.daedan.festabook.presentation.platform.DeepLinkKeys +import com.daedan.festabook.presentation.platform.RememberDeepLinkHandler +import com.daedan.festabook.presentation.platform.rememberNotificationPermissionManager +import com.daedan.festabook.presentation.platform.rememberOpenAppSettings +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.navigation.scheduleNavGraph +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.navigation.settingNavGraph +import com.naver.maps.map.util.FusedLocationSource + +@Composable +@Suppress("ktlint:compose:vm-forwarding-check") +fun MainScreen( + appGraph: FestaBookAppGraph, + locationSource: FusedLocationSource, + onAppFinish: () -> Unit, + festabookNavigator: FestabookNavigator, + mainViewModel: MainViewModel, + homeViewModel: HomeViewModel, + scheduleViewModel: ScheduleViewModel, + placeMapViewModel: PlaceMapViewModel, + newsViewModel: NewsViewModel, + settingViewModel: SettingViewModel, + modifier: Modifier = Modifier, +) { + val mainNavigator = rememberFestabookNavigator(MainTabRoute.Home) + val snackbarHostState = remember { SnackbarHostState() } + val snackbarManager = rememberAppSnackbarManager(snackbarHostState) + val backPressExitMessage = stringResource(R.string.back_press_exit_message) + val openAppSettings = rememberOpenAppSettings() + + val notificationPermissionManager = + rememberNotificationPermissionManager( + factory = appGraph.notificationPermissionManagerFactory, + onPermissionGrant = { settingViewModel.saveNotificationId() }, + onPermissionDeny = { snackbarManager.showPermissionDeniedSnackbar(openAppSettings) }, + ) + + ObserveAsEvents(flow = mainViewModel.navigateNewsEvent) { + mainNavigator.navigateToMainTab(FestabookMainTab.NEWS) + } + ObserveAsEvents(flow = mainViewModel.backPressEvent) { isDoublePress -> + if (isDoublePress) { + onAppFinish() + } else { + snackbarManager.show(backPressExitMessage) + } + } + ObserveAsEvents(flow = homeViewModel.navigateToScheduleEvent) { + mainNavigator.navigateToMainTab(FestabookMainTab.SCHEDULE) + } + + LaunchedEffect(Unit) { + mainViewModel.registerDeviceAndFcmToken() + } + + BackHandler { + mainViewModel.onBackPressed() + } + + RememberDeepLinkHandler { intent -> + handleNavigation(intent, newsViewModel, mainViewModel) + } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + FestabookSnackbar(data) + } + }, + bottomBar = { + if (mainNavigator.shouldShowBottomBar) { + FestabookBottomNavigationBar( + currentTab = mainNavigator.currentTab, + onTabSelect = { mainNavigator.navigateToMainTab(it) }, + onTabReSelect = { tab -> + when (tab) { + FestabookMainTab.SCHEDULE -> { + scheduleViewModel.loadSchedules() + } + + FestabookMainTab.PLACE_MAP -> { + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } + + else -> { + Unit + } + } + }, + ) + } + }, + modifier = modifier, + ) { innerPadding -> + val isVisible = mainNavigator.currentTab == FestabookMainTab.PLACE_MAP + PlaceMapRoute( + modifier = + Modifier + .graphicsLayer { + alpha = if (isVisible) 1f else 0f + }.padding(innerPadding) + .pointerInput(isVisible) { + if (!isVisible) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + .changes + .forEach { it.consume() } + } + } + } + }, + placeMapViewModel = placeMapViewModel, + locationSource = locationSource, + logger = appGraph.defaultFirebaseLogger, + onShowErrorSnackBar = snackbarManager::showError, + onStartPlaceDetail = { + mainNavigator.navigate( + FestabookRoute.PlaceDetail( + placeDetailUiModel = it.placeDetail.value, + ), + ) + }, + ) + FestabookNavHost( + modifier = Modifier.padding(innerPadding), + festabookNavigator = festabookNavigator, + navigator = mainNavigator, + mainViewModel = mainViewModel, + homeViewModel = homeViewModel, + scheduleViewModel = scheduleViewModel, + settingViewModel = settingViewModel, + placeDetailViewModelFactory = appGraph.placeDetailViewModelFactory, + newsViewModel = newsViewModel, + notificationPermissionManager = notificationPermissionManager, + snackbarManager = snackbarManager, + ) + } +} + +@Composable +private fun FestabookNavHost( + navigator: FestabookNavigator, + festabookNavigator: FestabookNavigator, + mainViewModel: MainViewModel, + homeViewModel: HomeViewModel, + scheduleViewModel: ScheduleViewModel, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, + newsViewModel: NewsViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + snackbarManager: SnackbarManager, + modifier: Modifier = Modifier, +) { + NavHost( + modifier = modifier, + startDestination = navigator.startRoute, + navController = navigator.navController, + ) { + homeNavGraph( + viewModel = homeViewModel, + mainViewModel = mainViewModel, + onNavigateToExplore = { festabookNavigator.navigate(FestabookRoute.Explore) }, + onSubscriptionConfirm = { + settingViewModel.notificationAllowClick() + mainViewModel.declineAlert() + }, + onShowSnackbar = snackbarManager::show, + onShowErrorSnackbar = snackbarManager::showError, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + ) + scheduleNavGraph( + viewModel = scheduleViewModel, + onShowErrorSnackbar = snackbarManager::showError, + ) + placeMapNavGraph( + placeDetailViewModelFactory = placeDetailViewModelFactory, + onBackToPreviousClick = { navigator.popBackStack() }, + onShowErrorSnackbar = snackbarManager::showError, + ) + newsNavGraph( + viewModel = newsViewModel, + onShowErrorSnackbar = snackbarManager::showError, + ) + settingNavGraph( + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = snackbarManager::show, + onShowErrorSnackBar = snackbarManager::showError, + ) + } +} + +private fun handleNavigation( + intent: Intent, + newsViewModel: NewsViewModel, + mainViewModel: MainViewModel, +) { + val noticeIdToExpand = + intent.getLongExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, DeepLinkKeys.INITIALIZED_ID) + if (noticeIdToExpand != DeepLinkKeys.INITIALIZED_ID) newsViewModel.expandNotice(noticeIdToExpand) + val canNavigateToNews = intent.getBooleanExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, false) + if (canNavigateToNews) mainViewModel.navigateToNews() +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt new file mode 100644 index 00000000..f4cf3301 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/main/navigation/MainNavigation.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.main.navigation + +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.di.FestaBookAppGraph +import com.daedan.festabook.presentation.main.FestabookNavigator +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.component.MainScreen +import com.naver.maps.map.util.FusedLocationSource + +fun NavGraphBuilder.mainNavGraph( + onAppFinish: () -> Unit, + appGraph: FestaBookAppGraph, + locationSource: FusedLocationSource, + festabookNavigator: FestabookNavigator, +) { + composable { + val mainBackEntry = + festabookNavigator.navController.getBackStackEntry() + MainScreen( + appGraph = appGraph, + locationSource = locationSource, + onAppFinish = onAppFinish, + festabookNavigator = festabookNavigator, + homeViewModel = viewModel(mainBackEntry, factory = appGraph.metroViewModelFactory), + scheduleViewModel = viewModel(mainBackEntry, factory = appGraph.metroViewModelFactory), + placeMapViewModel = viewModel(mainBackEntry, factory = appGraph.metroViewModelFactory), + settingViewModel = viewModel(mainBackEntry, factory = appGraph.metroViewModelFactory), + mainViewModel = viewModel(mainBackEntry, factory = appGraph.metroViewModelFactory), + newsViewModel = viewModel(mainBackEntry, factory = appGraph.metroViewModelFactory), + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt index 52866602..3299f19a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsFragment.kt @@ -1,7 +1,11 @@ package com.daedan.festabook.presentation.news import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider @@ -9,13 +13,8 @@ import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentNewsBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.main.MainViewModel -import com.daedan.festabook.presentation.news.adapter.NewsPagerAdapter -import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -import com.google.android.material.tabs.TabLayoutMediator +import com.daedan.festabook.presentation.news.component.NewsScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -27,53 +26,25 @@ import dev.zacsweers.metro.binding ) @FragmentKey(NewsFragment::class) @Inject -class NewsFragment : - BaseFragment(), - NewsClickListener { +class NewsFragment : BaseFragment() { + override val layoutId: Int = R.layout.fragment_news + @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - override val layoutId: Int = R.layout.fragment_news + private val newsViewModel: NewsViewModel by viewModels({ requireActivity() }) - private val newsPagerAdapter by lazy { - NewsPagerAdapter(this) - } - private val newsViewModel: NewsViewModel by viewModels() - private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) - - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - setupNewsTabLayout() - mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { - binding.vpNews.currentItem = NOTICE_TAB_INDEX + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + NewsScreen(newsViewModel = newsViewModel) + } + } } - } - - override fun onNoticeClick(notice: NoticeUiModel) { - newsViewModel.toggleNoticeExpanded(notice) - } - - override fun onFAQClick(faqItem: FAQItemUiModel) { - newsViewModel.toggleFAQExpanded(faqItem) - } - - override fun onLostGuideItemClick() { - newsViewModel.toggleLostGuideExpanded() - } - - private fun setupNewsTabLayout() { - binding.vpNews.adapter = newsPagerAdapter - TabLayoutMediator(binding.tlNews, binding.vpNews) { tab, position -> - val tabNameRes = NewsTab.entries[position].tabNameRes - tab.text = getString(tabNameRes) - }.attach() - } - - companion object { - private const val NOTICE_TAB_INDEX: Int = 0 - } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt index 539da37c..c9b1b766 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/NewsViewModel.kt @@ -1,9 +1,5 @@ package com.daedan.festabook.presentation.news -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey @@ -25,6 +21,9 @@ import com.daedan.festabook.presentation.news.notice.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -35,34 +34,29 @@ class NewsViewModel( private val faqRepository: FAQRepository, private val lostItemRepository: LostItemRepository, ) : ViewModel() { - var noticeUiState by mutableStateOf(NoticeUiState.InitialLoading) - private set + private val _noticeUiState: MutableStateFlow = + MutableStateFlow(NoticeUiState(content = NoticeUiState.Content.InitialLoading)) + val noticeUiState: StateFlow = _noticeUiState.asStateFlow() - val isNoticeScreenRefreshing by derivedStateOf { - noticeUiState is NoticeUiState.Refreshing - } - - var faqUiState by mutableStateOf(FAQUiState.InitialLoading) - private set + private val _faqUiState: MutableStateFlow = + MutableStateFlow(FAQUiState.InitialLoading) + val faqUiState: StateFlow = _faqUiState.asStateFlow() - var lostUiState by mutableStateOf(LostUiState.InitialLoading) - private set - - val isLostItemScreenRefreshing by derivedStateOf { - lostUiState is LostUiState.Refreshing - } + private val _lostUiState: MutableStateFlow = + MutableStateFlow(LostUiState(content = LostUiState.Content.InitialLoading)) + val lostUiState: StateFlow = _lostUiState.asStateFlow() private var noticeIdToExpand: Long? = null init { - loadAllNotices(NoticeUiState.InitialLoading) - loadAllFAQs() - loadAllLostItems(LostUiState.InitialLoading) + loadAllNotices(NoticeUiState(content = NoticeUiState.Content.InitialLoading)) + loadAllFAQs(FAQUiState.InitialLoading) + loadAllLostItems(LostUiState(content = LostUiState.Content.InitialLoading)) } fun loadAllNotices(state: NoticeUiState) { viewModelScope.launch { - noticeUiState = state + _noticeUiState.value = state val result = noticeRepository.fetchNotices() result .onSuccess { notices -> @@ -76,16 +70,19 @@ class NewsViewModel( notices.indexOfFirst { it.id == noticeIdToExpand }.let { if (it == -1) DEFAULT_POSITION else it } - noticeUiState = - NoticeUiState.Success(updatedNotices, expandPosition) + _noticeUiState.value = + NoticeUiState( + content = + NoticeUiState.Content.Success(updatedNotices, expandPosition), + ) noticeIdToExpand = null }.onFailure { - noticeUiState = NoticeUiState.Error(it) + _noticeUiState.value = NoticeUiState(content = NoticeUiState.Content.Error(it)) } } } - fun toggleNoticeExpanded(notice: NoticeUiModel) { + fun toggleNotice(notice: NoticeUiModel) { updateNoticeUiState { notices -> notices.map { updatedNotice -> if (notice.id == updatedNotice.id) { @@ -97,19 +94,14 @@ class NewsViewModel( } } - fun expandNotice(noticeId: Long) { - this.noticeIdToExpand = noticeId - val notices = - when (val currentState = noticeUiState) { - is NoticeUiState.Refreshing -> currentState.oldNotices - is NoticeUiState.Success -> currentState.notices - else -> return - } - - loadAllNotices(NoticeUiState.Refreshing(notices)) + fun expandNotice(noticeIdToExpand: Long) { + this.noticeIdToExpand = noticeIdToExpand + if (_noticeUiState.value.content !is NoticeUiState.Content.InitialLoading) { + loadAllNotices(_noticeUiState.value) + } } - fun toggleFAQExpanded(faqItem: FAQItemUiModel) { + fun toggleFAQ(faqItem: FAQItemUiModel) { updateFAQUiState { faqItems -> faqItems.map { updatedFAQItem -> if (faqItem.questionId == updatedFAQItem.questionId) { @@ -121,7 +113,7 @@ class NewsViewModel( } } - fun toggleLostGuideExpanded() { + fun toggleLostGuide() { updateLostUiState { lostUiModels -> lostUiModels.map { lostUiModel -> if (lostUiModel is LostUiModel.Guide) { @@ -135,7 +127,7 @@ class NewsViewModel( fun loadAllLostItems(state: LostUiState) { viewModelScope.launch { - lostUiState = state + _lostUiState.value = state val result = lostItemRepository.getLost() val lostUiModels = @@ -146,40 +138,45 @@ class NewsViewModel( null -> LostUiModel.Guide() } } - lostUiState = LostUiState.Success(lostUiModels) + _lostUiState.value = LostUiState(content = LostUiState.Content.Success(lostUiModels)) } } - private fun loadAllFAQs(state: FAQUiState = FAQUiState.InitialLoading) { + private fun loadAllFAQs(state: FAQUiState) { viewModelScope.launch { - faqUiState = state + _faqUiState.value = state val result = faqRepository.getAllFAQ() result .onSuccess { faqItems -> - faqUiState = FAQUiState.Success(faqItems.map { it.toUiModel() }) + _faqUiState.value = FAQUiState.Success(faqItems.map { it.toUiModel() }) }.onFailure { - faqUiState = FAQUiState.Error(it) + _faqUiState.value = FAQUiState.Error(it) } } } private fun updateNoticeUiState(onUpdate: (List) -> List) { - noticeUiState = - when (val currentState = noticeUiState) { - is NoticeUiState.Success -> + val currentState = _noticeUiState.value + _noticeUiState.value = + when (val currentContent = currentState.content) { + is NoticeUiState.Content.Success -> { currentState.copy( - notices = onUpdate(currentState.notices), + content = + currentContent.copy(notices = onUpdate(currentContent.notices)), ) + } - else -> currentState + else -> { + return + } } } private fun updateFAQUiState(onUpdate: (List) -> List) { - val currentState = faqUiState - faqUiState = + val currentState = _faqUiState.value + _faqUiState.value = when (currentState) { is FAQUiState.Success -> currentState.copy(faqs = onUpdate(currentState.faqs)) else -> currentState @@ -187,11 +184,19 @@ class NewsViewModel( } private fun updateLostUiState(onUpdate: (List) -> List) { - val currentState = lostUiState - lostUiState = - when (currentState) { - is LostUiState.Success -> currentState.copy(lostItems = onUpdate(currentState.lostItems)) - else -> currentState + val currentState = _lostUiState.value + val currentContent = currentState.content + _lostUiState.value = + when (currentContent) { + is LostUiState.Content.Success -> { + _lostUiState.value.copy( + content = currentContent.copy(lostItems = onUpdate(currentContent.lostItems)), + ) + } + + else -> { + currentState + } } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt deleted file mode 100644 index 9373d9f1..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/adapter/NewsPagerAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.daedan.festabook.presentation.news.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.daedan.festabook.presentation.news.NewsTab -import com.daedan.festabook.presentation.news.faq.FAQFragment -import com.daedan.festabook.presentation.news.lost.LostItemFragment -import com.daedan.festabook.presentation.news.notice.NoticeFragment - -class NewsPagerAdapter( - fragment: Fragment, -) : FragmentStateAdapter(fragment) { - override fun getItemCount(): Int = NewsTab.entries.size - - override fun createFragment(position: Int): Fragment = - when (NewsTab.entries[position]) { - NewsTab.NOTICE -> NoticeFragment.newInstance() - NewsTab.FAQ -> FAQFragment.newInstance() - NewsTab.LOST_ITEM -> LostItemFragment.newInstance() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt index c3f98760..d002aebb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsItem.kt @@ -21,11 +21,11 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.cardBackground import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing private const val ICON_ROTATION_EXPANDED: Float = 180F private const val ICON_ROTATION_COLLAPSED: Float = 0F @@ -54,7 +54,7 @@ fun NewsItem( indication = null, interactionSource = null, ) { onclick() } - .padding(16.dp), + .padding(festabookSpacing.paddingBody4), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -62,14 +62,14 @@ fun NewsItem( ) { if (icon != null) { icon() - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) } Text( text = title, style = FestabookTypography.titleSmall, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(festabookSpacing.paddingBody2)) if (createdAt != null) { Text( text = createdAt, @@ -86,7 +86,7 @@ fun NewsItem( } if (isExpanded) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody2)) Text(text = description) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt new file mode 100644 index 00000000..ed46fc32 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsScreen.kt @@ -0,0 +1,99 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.faq.FAQUiState +import com.daedan.festabook.presentation.news.lost.LostUiState +import com.daedan.festabook.presentation.news.notice.NoticeUiState + +@Composable +fun NewsScreen( + newsViewModel: NewsViewModel, + modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 +) { + val pageState = rememberPagerState { NewsTab.entries.size } + val scope = rememberCoroutineScope() + + val noticeUiState by newsViewModel.noticeUiState.collectAsStateWithLifecycle() + val lostUiState by newsViewModel.lostUiState.collectAsStateWithLifecycle() + val faqUiState by newsViewModel.faqUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + + LaunchedEffect(noticeUiState) { + when (val content = noticeUiState.content) { + is NoticeUiState.Content.Success -> { + pageState.animateScrollToPage(NewsTab.NOTICE.ordinal) + } + + is NoticeUiState.Content.Error -> { + currentOnShowErrorSnackbar(content.throwable) + } + + else -> {} + } + } + LaunchedEffect(lostUiState) { + when (val content = lostUiState.content) { + is LostUiState.Content.Error -> { + currentOnShowErrorSnackbar(content.throwable) + } + + else -> {} + } + } + + LaunchedEffect(faqUiState) { + when (val uiState = faqUiState) { + is FAQUiState.Error -> { + currentOnShowErrorSnackbar(uiState.throwable) + } + + else -> {} + } + } + + Scaffold( + topBar = { FestabookTopAppBar(title = stringResource(R.string.news_title)) }, + modifier = modifier, + ) { innerPadding -> + Column( + modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), + ) { + NewsTabRow(pageState, scope) + NewsTabPage( + pageState = pageState, + noticeUiState = noticeUiState, + faqUiState = faqUiState, + lostUiState = lostUiState, + onNoticeRefresh = { + val currentUiState = noticeUiState.copy(isRefreshing = true) + newsViewModel.loadAllNotices(currentUiState) + }, + onLostItemRefresh = { + val currentUiState = lostUiState.copy(isRefreshing = true) + newsViewModel.loadAllLostItems(currentUiState) + }, + onNoticeClick = { newsViewModel.toggleNotice(it) }, + onFaqClick = { newsViewModel.toggleFAQ(it) }, + onLostGuideClick = { newsViewModel.toggleLostGuide() }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt new file mode 100644 index 00000000..7bdaac3c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabPage.kt @@ -0,0 +1,85 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.news.faq.FAQUiState +import com.daedan.festabook.presentation.news.faq.component.FAQScreen +import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel +import com.daedan.festabook.presentation.news.lost.LostUiState +import com.daedan.festabook.presentation.news.lost.component.LostItemScreen +import com.daedan.festabook.presentation.news.notice.NoticeUiState +import com.daedan.festabook.presentation.news.notice.component.NoticeScreen +import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun NewsTabPage( + pageState: PagerState, + noticeUiState: NoticeUiState, + faqUiState: FAQUiState, + lostUiState: LostUiState, + onNoticeRefresh: () -> Unit, + onLostItemRefresh: () -> Unit, + onNoticeClick: (NoticeUiModel) -> Unit, + onFaqClick: (FAQItemUiModel) -> Unit, + onLostGuideClick: () -> Unit, + modifier: Modifier = Modifier, +) { + HorizontalPager( + state = pageState, + verticalAlignment = Alignment.Top, + modifier = modifier, + ) { index -> + val tab = NewsTab.entries[index] + when (tab) { + NewsTab.NOTICE -> { + NoticeScreen( + uiState = noticeUiState, + onNoticeClick = onNoticeClick, + onRefresh = onNoticeRefresh, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + } + + NewsTab.FAQ -> { + FAQScreen( + uiState = faqUiState, + onFaqClick = onFaqClick, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + } + + NewsTab.LOST_ITEM -> { + LostItemScreen( + lostUiState = lostUiState, + onLostGuideClick = onLostGuideClick, + onRefresh = onLostItemRefresh, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + } + } + } +} + +@Composable +@Preview +private fun NewsTabPagePreview() { + NewsTabPage( + pageState = rememberPagerState { 3 }, + noticeUiState = NoticeUiState(content = NoticeUiState.Content.Success(emptyList(), 0)), + faqUiState = FAQUiState.Success(emptyList()), + lostUiState = LostUiState(content = LostUiState.Content.Success(emptyList())), + onNoticeRefresh = {}, + onLostItemRefresh = {}, + onNoticeClick = {}, + onFaqClick = {}, + onLostGuideClick = {}, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt new file mode 100644 index 00000000..574d1447 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/component/NewsTabRow.kt @@ -0,0 +1,61 @@ +package com.daedan.festabook.presentation.news.component + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.presentation.news.NewsTab +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun NewsTabRow( + pageState: PagerState, + scope: CoroutineScope, + modifier: Modifier = Modifier, +) { + TabRow( + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + indicator = { tabPositions -> + TabRowDefaults.PrimaryIndicator( + color = FestabookColor.black, + width = tabPositions[pageState.currentPage].width, + modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage]), + ) + }, + modifier = modifier, + ) { + NewsTab.entries.forEachIndexed { index, title -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + selectedContentColor = FestabookColor.black, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = stringResource(title.tabNameRes)) }, + ) + } + } +} + +@Composable +@Preview +private fun NewsTabRowPreview() { + FestabookTheme { + NewsTabRow( + pageState = rememberPagerState { 3 }, + scope = rememberCoroutineScope(), + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt deleted file mode 100644 index 353e48fb..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/FAQFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.daedan.festabook.presentation.news.faq - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentFaqBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.faq.component.FAQScreen -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener - -class FAQFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_faq - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - private val viewModel: NewsViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FAQScreen(uiState = viewModel.faqUiState, onFaqClick = { faqItemUiModel -> - (requireParentFragment() as NewsClickListener).onFAQClick(faqItemUiModel) - }) - } - } - - companion object { - fun newInstance() = FAQFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt index 2114badf..7bdefd51 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/adapter/FAQViewHolder.kt @@ -21,7 +21,6 @@ class FAQViewHolder( init { binding.root.setOnClickListener { faqItem?.let { - newsClickListener.onFAQClick(it) } ?: run { Timber.w("${this::class.java.simpleName} : FAQ 아이템이 null입니다.") } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt index 89482d29..95fd81b6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/faq/component/FAQScreen.kt @@ -2,23 +2,23 @@ package com.daedan.festabook.presentation.news.faq.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.faq.FAQUiState import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PADDING: Int = 8 - @Composable fun FAQScreen( uiState: FAQUiState, @@ -27,12 +27,13 @@ fun FAQScreen( ) { when (uiState) { is FAQUiState.Error -> { - LaunchedEffect(uiState) { - Timber.w(uiState.throwable.stackTraceToString()) - } + Timber.w(uiState.throwable.stackTraceToString()) + ErrorStateScreen(modifier = modifier.fillMaxSize()) } - is FAQUiState.InitialLoading -> Unit + is FAQUiState.InitialLoading -> { + LoadingStateScreen() + } is FAQUiState.Success -> { if (uiState.faqs.isEmpty()) { @@ -40,8 +41,12 @@ fun FAQScreen( } else { LazyColumn( modifier = modifier, - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { items( items = uiState.faqs, diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt deleted file mode 100644 index 305c10d4..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostItemFragment.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.daedan.festabook.presentation.news.lost - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentLostItemBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.lost.component.LostItemScreen -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener - -class LostItemFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_lost_item - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - - private val viewModel: NewsViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val newsClickListener = requireParentFragment() as NewsClickListener - LostItemScreen( - lostUiState = viewModel.lostUiState, - onLostGuideClick = { newsClickListener.onLostGuideItemClick() }, - isRefreshing = viewModel.isLostItemScreenRefreshing, - onRefresh = { - val currentUiState = viewModel.lostUiState - val oldLostItems = - if (currentUiState is LostUiState.Success) currentUiState.lostItems else emptyList() - viewModel.loadAllLostItems(LostUiState.Refreshing(oldLostItems)) - }, - ) - } - } - - companion object { - fun newInstance() = LostItemFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostUiState.kt index 57b49393..9d6a11d9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/LostUiState.kt @@ -2,18 +2,19 @@ package com.daedan.festabook.presentation.news.lost import com.daedan.festabook.presentation.news.lost.model.LostUiModel -sealed interface LostUiState { - data object InitialLoading : LostUiState +data class LostUiState( + val content: Content, + val isRefreshing: Boolean = false, +) { + sealed interface Content { + data object InitialLoading : Content - data class Refreshing( - val oldLostItems: List, - ) : LostUiState + data class Success( + val lostItems: List, + ) : Content - data class Success( - val lostItems: List, - ) : LostUiState - - data class Error( - val throwable: Throwable, - ) : LostUiState + data class Error( + val throwable: Throwable, + ) : Content + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt index 741eeaa9..d96e756b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/adapter/LostGuideItemViewHolder.kt @@ -19,7 +19,6 @@ class LostGuideItemViewHolder private constructor( init { binding.root.setOnClickListener { lostGuideItem?.let { - newsClickListener.onLostGuideItemClick() } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt index 50e26352..bf3567ea 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItem.kt @@ -3,18 +3,16 @@ package com.daedan.festabook.presentation.news.lost.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.cardBackground - -private const val ROUNDED_CORNER_SHAPE = 16 +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes @Composable fun LostItem( @@ -23,10 +21,10 @@ fun LostItem( onLostItemClick: () -> Unit = {}, ) { Card( - shape = RoundedCornerShape(ROUNDED_CORNER_SHAPE.dp), + shape = festabookShapes.radius3, modifier = modifier - .cardBackground(roundedCornerShape = ROUNDED_CORNER_SHAPE.dp) + .cardBackground() .aspectRatio(1f) .clickable(indication = null, interactionSource = null) { onLostItemClick() }, ) { @@ -41,8 +39,10 @@ fun LostItem( @Composable @Preview private fun LostItemPreview() { - LostItem( - url = "https://i.imgur.com/Zblctu7.png", - onLostItemClick = { }, - ) + FestabookTheme { + LostItem( + url = "https://i.imgur.com/Zblctu7.png", + onLostItemClick = { }, + ) + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt index f140d804..32be691d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/lost/component/LostItemScreen.kt @@ -1,50 +1,50 @@ package com.daedan.festabook.presentation.news.lost.component import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.lost.LostUiState import com.daedan.festabook.presentation.news.lost.model.LostItemUiStatus import com.daedan.festabook.presentation.news.lost.model.LostUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber private const val SPAN_COUNT: Int = 2 -private const val PADDING: Int = 8 @OptIn(ExperimentalMaterial3Api::class) @Composable fun LostItemScreen( lostUiState: LostUiState, onLostGuideClick: () -> Unit, - isRefreshing: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { var clickedLostItem by remember { mutableStateOf(null) } + val scrollState = rememberScrollState() clickedLostItem?.let { LostItemModalDialog( @@ -54,46 +54,35 @@ fun LostItemScreen( } PullToRefreshContainer( - isRefreshing = isRefreshing, + isRefreshing = lostUiState.isRefreshing, onRefresh = onRefresh, modifier = modifier, - ) { pullToRefreshState -> - when (lostUiState) { - LostUiState.InitialLoading -> LoadingStateScreen() - - is LostUiState.Error -> { - LaunchedEffect(lostUiState) { - Timber.w(lostUiState.throwable.stackTraceToString()) - } + ) { graphicsLayer -> + when (val content = lostUiState.content) { + LostUiState.Content.InitialLoading -> { + LoadingStateScreen() } - is LostUiState.Refreshing -> { - LostItemContent( - lostItems = lostUiState.oldLostItems, - onLostGuideClick = onLostGuideClick, - onLostItemClick = { }, + is LostUiState.Content.Error -> { + Timber.w(content.throwable.stackTraceToString()) + ErrorStateScreen( modifier = - modifier + Modifier .fillMaxSize() - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer) + .verticalScroll(scrollState), ) } - is LostUiState.Success -> { + is LostUiState.Content.Success -> { LostItemContent( - lostItems = lostUiState.lostItems, + lostItems = content.lostItems, onLostGuideClick = onLostGuideClick, onLostItemClick = { clickedLostItem = it }, modifier = - modifier + Modifier .fillMaxSize() - .graphicsLayer { - translationY = - pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + .then(graphicsLayer), ) } } @@ -107,45 +96,50 @@ private fun LostItemContent( onLostItemClick: (LostUiModel.Item) -> Unit, modifier: Modifier = Modifier, ) { - val isLostItemEmpty = lostItems.none { it is LostUiModel.Item } - if (isLostItemEmpty) { - EmptyStateScreen(modifier = modifier) - } + Box(modifier = modifier) { + val isLostItemEmpty = lostItems.none { it is LostUiModel.Item } + if (isLostItemEmpty) { + EmptyStateScreen() + } - LazyVerticalGrid( - modifier = modifier, - columns = GridCells.Fixed(SPAN_COUNT), - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), - horizontalArrangement = Arrangement.spacedBy(PADDING.dp), - ) { - item(span = { GridItemSpan(SPAN_COUNT) }) { - val guide = lostItems.firstOrNull() as? LostUiModel.Guide - guide?.let { - NewsItem( - title = stringResource(R.string.lost_item_guide), - description = it.description, - isExpanded = it.isExpanded, - onclick = onLostGuideClick, - icon = - { - Icon( - painter = painterResource(R.drawable.ic_info), - contentDescription = stringResource(R.string.info), - ) - }, + LazyVerticalGrid( + columns = GridCells.Fixed(SPAN_COUNT), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + ) { + item(span = { GridItemSpan(SPAN_COUNT) }) { + val guide = lostItems.firstOrNull() as? LostUiModel.Guide + guide?.let { + NewsItem( + title = stringResource(R.string.lost_item_guide), + description = it.description, + isExpanded = it.isExpanded, + onclick = onLostGuideClick, + icon = + { + Icon( + painter = painterResource(R.drawable.ic_info), + contentDescription = stringResource(R.string.info), + ) + }, + ) + } + } + items( + items = lostItems.drop(1).filterIsInstance(), + key = { lostItem -> lostItem.lostItemId }, + ) { lostItem -> + LostItem( + url = lostItem.imageUrl, + onLostItemClick = { onLostItemClick(lostItem) }, ) } } - items( - items = lostItems.drop(1).filterIsInstance(), - key = { lostItem -> lostItem.lostItemId }, - ) { lostItem -> - LostItem( - url = lostItem.imageUrl, - onLostItemClick = { onLostItemClick(lostItem) }, - ) - } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt new file mode 100644 index 00000000..ee34b28f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/news/navigation/NewsNavigation.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.news.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.news.NewsViewModel +import com.daedan.festabook.presentation.news.component.NewsScreen + +fun NavGraphBuilder.newsNavGraph( + viewModel: NewsViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + NewsScreen( + newsViewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt deleted file mode 100644 index ba6e5c40..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.daedan.festabook.presentation.news.notice - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentNoticeBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.main.MainViewModel -import com.daedan.festabook.presentation.news.NewsViewModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.component.NoticeScreen - -class NoticeFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_notice - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = appGraph.metroViewModelFactory - private val newsViewModel: NewsViewModel by viewModels({ requireParentFragment() }) - private val mainViewModel: MainViewModel by viewModels({ requireActivity() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - NoticeScreen( - uiState = newsViewModel.noticeUiState, - onNoticeClick = { notice -> - (requireParentFragment() as NewsClickListener) - .onNoticeClick(notice) - }, - isRefreshing = newsViewModel.isNoticeScreenRefreshing, - onRefresh = { - val currentUiState = newsViewModel.noticeUiState - val oldNotices = - if (currentUiState is NoticeUiState.Success) currentUiState.notices else emptyList() - newsViewModel.loadAllNotices(NoticeUiState.Refreshing(oldNotices)) - }, - ) - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupObserver() - } - - private fun setupObserver() { - mainViewModel.noticeIdToExpand.observe(viewLifecycleOwner) { noticeId -> - newsViewModel.expandNotice(noticeId) - } - } - - companion object { - fun newInstance() = NoticeFragment() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeUiState.kt index 78cd73ed..4cd6607d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/NoticeUiState.kt @@ -2,21 +2,22 @@ package com.daedan.festabook.presentation.news.notice import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -sealed interface NoticeUiState { - data class Refreshing( - val oldNotices: List, - ) : NoticeUiState +data class NoticeUiState( + val content: Content, + val isRefreshing: Boolean = false, +) { + sealed interface Content { + data object InitialLoading : Content - data object InitialLoading : NoticeUiState + data class Success( + val notices: List, + val expandPosition: Int, + ) : Content - data class Success( - val notices: List, - val expandPosition: Int, - ) : NoticeUiState - - data class Error( - val throwable: Throwable, - ) : NoticeUiState + data class Error( + val throwable: Throwable, + ) : Content + } companion object { const val DEFAULT_POSITION: Int = 0 diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt index 36058886..975dca7e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NewsClickListener.kt @@ -1,7 +1,6 @@ package com.daedan.festabook.presentation.news.notice.adapter import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel interface NewsClickListener { diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt index 472fa804..cb31dd22 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/adapter/NoticeViewHolder.kt @@ -21,7 +21,6 @@ class NoticeViewHolder( init { binding.layoutNoticeItem.setOnClickListener { noticeItem?.let { - newsClickListener.onNoticeClick(it) } ?: run { Timber.w("${this::class.java.simpleName} 공지 아이템이 null입니다.") } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt index 4edb88b7..2f63e3b6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/component/NoticeScreen.kt @@ -1,75 +1,72 @@ package com.daedan.festabook.presentation.news.notice.component +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.common.component.PULL_OFFSET_LIMIT import com.daedan.festabook.presentation.common.component.PullToRefreshContainer import com.daedan.festabook.presentation.news.component.NewsItem import com.daedan.festabook.presentation.news.notice.NoticeUiState import com.daedan.festabook.presentation.news.notice.NoticeUiState.Companion.DEFAULT_POSITION import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.theme.festabookSpacing import timber.log.Timber -private const val PADDING: Int = 8 - @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoticeScreen( uiState: NoticeUiState, onNoticeClick: (NoticeUiModel) -> Unit, - isRefreshing: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { + val scrollState = rememberScrollState() + PullToRefreshContainer( - isRefreshing = isRefreshing, + isRefreshing = uiState.isRefreshing, onRefresh = onRefresh, - ) { pullToRefreshState -> - when (uiState) { - NoticeUiState.InitialLoading -> LoadingStateScreen() - - is NoticeUiState.Error -> { - LaunchedEffect(uiState) { - Timber.w(uiState.throwable.stackTraceToString()) - } + modifier = modifier, + ) { graphicsLayer -> + when (val content = uiState.content) { + NoticeUiState.Content.InitialLoading -> { + LoadingStateScreen() } - is NoticeUiState.Refreshing -> { - NoticeContent( - notices = uiState.oldNotices, - onNoticeClick = onNoticeClick, + is NoticeUiState.Content.Error -> { + Timber.e(content.throwable.stackTraceToString()) + ErrorStateScreen( modifier = - modifier.graphicsLayer { - translationY = pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + Modifier + .fillMaxSize() + .then(graphicsLayer) + .verticalScroll(scrollState), ) } - is NoticeUiState.Success -> { + is NoticeUiState.Content.Success -> { NoticeContent( - notices = uiState.notices, - expandPosition = uiState.expandPosition, + scrollState = scrollState, + notices = content.notices, + expandPosition = content.expandPosition, onNoticeClick = onNoticeClick, - modifier = - modifier.graphicsLayer { - translationY = pullToRefreshState.distanceFraction * PULL_OFFSET_LIMIT - }, + modifier = Modifier.then(graphicsLayer), ) } } @@ -78,6 +75,7 @@ fun NoticeScreen( @Composable private fun NoticeContent( + scrollState: ScrollState, notices: List, onNoticeClick: (NoticeUiModel) -> Unit, modifier: Modifier = Modifier, @@ -89,13 +87,22 @@ private fun NoticeContent( listState.animateScrollToItem(expandPosition) } if (notices.isEmpty()) { - EmptyStateScreen(modifier = modifier) + EmptyStateScreen( + modifier = + modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) } else { LazyColumn( modifier = modifier, state = listState, - contentPadding = PaddingValues(top = PADDING.dp, bottom = PADDING.dp), - verticalArrangement = Arrangement.spacedBy(PADDING.dp), + contentPadding = + PaddingValues( + top = festabookSpacing.paddingBody2, + bottom = festabookSpacing.paddingBody2, + ), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), ) { items( items = notices, @@ -131,12 +138,14 @@ private fun NoticeContent( private fun NoticeScreenPreview() { NoticeScreen( uiState = - NoticeUiState.Success( - notices = emptyList(), - expandPosition = 0, + NoticeUiState( + content = + NoticeUiState.Content.Success( + notices = emptyList(), + expandPosition = 0, + ), ), onNoticeClick = { }, - isRefreshing = false, onRefresh = {}, ) } diff --git a/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt index a9e48b54..a839749c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/news/notice/model/NoticeUiModel.kt @@ -2,11 +2,14 @@ package com.daedan.festabook.presentation.news.notice.model import android.os.Parcelable import com.daedan.festabook.domain.model.Notice +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toJavaLocalDateTime import kotlinx.parcelize.Parcelize -import java.time.LocalDateTime +import kotlinx.serialization.Serializable import java.time.format.DateTimeFormatter @Parcelize +@Serializable data class NoticeUiModel( val id: Long, val title: String, @@ -18,7 +21,7 @@ data class NoticeUiModel( val formattedCreatedAt: String get() = runCatching { - formatter.format(createdAt) + formatter.format(createdAt.toJavaLocalDateTime()) }.getOrDefault("") companion object { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt similarity index 97% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt index 2b1e0add..8546f3f1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList +package com.daedan.festabook.presentation.placeDetail import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt index 0012b000..735fee83 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailActivity.kt @@ -3,55 +3,29 @@ package com.daedan.festabook.presentation.placeDetail import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.View -import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.databinding.DataBindingUtil +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.toArgb import androidx.lifecycle.ViewModelProvider -import androidx.viewpager2.widget.ViewPager2 -import com.daedan.festabook.R -import com.daedan.festabook.databinding.ActivityPlaceDetailBinding +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.di.appGraph -import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.getObject -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.news.faq.model.FAQItemUiModel -import com.daedan.festabook.presentation.news.lost.model.LostUiModel -import com.daedan.festabook.presentation.news.notice.adapter.NewsClickListener -import com.daedan.festabook.presentation.news.notice.adapter.NoticeAdapter -import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel -import com.daedan.festabook.presentation.placeDetail.adapter.PlaceImageViewPagerAdapter -import com.daedan.festabook.presentation.placeDetail.logging.PlaceDetailImageSwipe -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel +import com.daedan.festabook.presentation.placeDetail.component.PlaceDetailScreen import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookColor import dev.zacsweers.metro.Inject import timber.log.Timber -class PlaceDetailActivity : - AppCompatActivity(), - NewsClickListener { +class PlaceDetailActivity : ComponentActivity() { @Inject private lateinit var viewModelFactory: PlaceDetailViewModel.Factory - private val noticeAdapter by lazy { - NoticeAdapter(this) - } - - private val placeImageAdapter by lazy { - PlaceImageViewPagerAdapter() - } - private lateinit var viewModel: PlaceDetailViewModel - private val binding: ActivityPlaceDetailBinding by lazy { - DataBindingUtil.setContentView(this, R.layout.activity_place_detail) - } - override fun onCreate(savedInstanceState: Bundle?) { appGraph.inject(this) super.onCreate(savedInstanceState) @@ -62,6 +36,7 @@ class PlaceDetailActivity : finish() return } + viewModel = ViewModelProvider( this, @@ -72,120 +47,25 @@ class PlaceDetailActivity : ), )[PlaceDetailViewModel::class.java] - enableEdgeToEdge() - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - - setUpBinding() - setUpObserver() - Timber.d("detailActivity : ${viewModel.placeDetail.value}") - } - - private fun setUpBinding() { - binding.lifecycleOwner = this - binding.rvPlaceNotice.adapter = noticeAdapter - binding.vpPlaceImages.adapter = placeImageAdapter - binding.tvLocation.setExpandedWhenClicked() - binding.tvHost.setExpandedWhenClicked() - binding.ivBackToPrevious.setOnClickListener { - finish() - } - } - - private fun setUpObserver() { - viewModel.placeDetail.observe(this) { result -> - - when (result) { - is PlaceDetailUiState.Error -> { - Timber.w(result.throwable, "PlaceDetailActivity: ${result.throwable.message}") - showErrorSnackBar(result.throwable) - } - - is PlaceDetailUiState.Loading -> { - showSkeleton() - Timber.d("Loading") - } - - is PlaceDetailUiState.Success -> { - hideSkeleton() - loadPlaceDetail(result.placeDetail) - } - } - } - } - - private fun loadPlaceDetail(placeDetail: PlaceDetailUiModel) { - binding.placeDetail = placeDetail - - if (placeDetail.images.isEmpty()) { - placeImageAdapter.submitList( - listOf(ImageUiModel()), + setContent { + enableEdgeToEdge( + statusBarStyle = + SystemBarStyle.light( + scrim = FestabookColor.white.toArgb(), + darkScrim = FestabookColor.white.toArgb(), + ), + ) + val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() + PlaceDetailScreen( + uiState = placeDetailUiState, + onBackToPreviousClick = { finish() }, ) - } else { - placeImageAdapter.submitList(placeDetail.images) - binding.clImageIndicator.setViewPager(binding.vpPlaceImages) - } - binding.vpPlaceImages.registerOnPageChangeCallback( - object : ViewPager2.OnPageChangeCallback() { - override fun onPageScrolled( - position: Int, - positionOffset: Float, - positionOffsetPixels: Int, - ) { - binding.logger.log( - PlaceDetailImageSwipe( - baseLogData = binding.logger.getBaseLogData(), - startIndex = position, - ), - ) - } - }, - ) - // 임시로 곰지사항을 보이지 않게 하였습니다. 추후 복구 예정입니다 -// if (placeDetail.notices.isEmpty()) { -// binding.rvPlaceNotice.visibility = View.GONE -// binding.tvNoNoticeDescription.visibility = View.VISIBLE -// } else { -// noticeAdapter.submitList(placeDetail.notices) -// } - } - - private fun showSkeleton() { - binding.layoutContent.visibility = View.GONE - binding.sflScheduleSkeleton.visibility = View.VISIBLE - binding.sflScheduleSkeleton.startShimmer() - } - - private fun hideSkeleton() { - binding.layoutContent.visibility = View.VISIBLE - binding.sflScheduleSkeleton.visibility = View.GONE - binding.sflScheduleSkeleton.stopShimmer() - } - - private fun TextView.setExpandedWhenClicked(defaultMaxLines: Int = DEFAULT_MAX_LINES) { - setOnClickListener { - maxLines = - if (maxLines == defaultMaxLines) { - Integer.MAX_VALUE - } else { - defaultMaxLines - } } - } - override fun onNoticeClick(notice: NoticeUiModel) { - viewModel.toggleNoticeExpanded(notice) + Timber.d("detailActivity : ${viewModel.placeDetail.value}") } - override fun onFAQClick(faqItem: FAQItemUiModel) = Unit - - override fun onLostGuideItemClick() = Unit - companion object { - private const val DEFAULT_MAX_LINES = 1 private const val KEY_PLACE_UI_MODEL = "placeUiModel" private const val KEY_PLACE_DETAIL_UI_MODEL = "placeDetailUiModel" diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt index 9453bf4e..f19439dc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/PlaceDetailViewModel.kt @@ -1,13 +1,12 @@ package com.daedan.festabook.presentation.placeDetail -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.presentation.news.notice.model.NoticeUiModel +import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState import com.daedan.festabook.presentation.placeDetail.model.toUiModel @@ -15,9 +14,12 @@ import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -class PlaceDetailViewModel @AssistedInject constructor( +@AssistedInject +class PlaceDetailViewModel( private val placeDetailRepository: PlaceDetailRepository, @Assisted private val place: PlaceUiModel?, @Assisted private val receivedPlaceDetail: PlaceDetailUiModel?, @@ -31,17 +33,18 @@ class PlaceDetailViewModel @AssistedInject constructor( } private val _placeDetail = - MutableLiveData( + MutableStateFlow( PlaceDetailUiState.Loading, ) - val placeDetail: LiveData = _placeDetail + val placeDetail: StateFlow = _placeDetail init { - if (receivedPlaceDetail != null) { - _placeDetail.value = PlaceDetailUiState.Success(receivedPlaceDetail) - } else if (place != null) { - loadPlaceDetail(place.id) + receivedPlaceDetail?.let { + val placeDetailUiModel = + if (it.images.isEmpty()) it.copy(images = listOf(ImageUiModel())) else it + _placeDetail.value = PlaceDetailUiState.Success(placeDetailUiModel) } + place?.let { loadPlaceDetail(it.id) } } fun loadPlaceDetail(placeId: Long) { @@ -49,10 +52,14 @@ class PlaceDetailViewModel @AssistedInject constructor( val result = placeDetailRepository.getPlaceDetail(placeId) result .onSuccess { placeDetail -> + val placeDetailUiModel = + if (placeDetail.sortedImages.isEmpty()) { + placeDetail.toUiModel().copy(images = listOf(ImageUiModel())) + } else { + placeDetail.toUiModel() + } _placeDetail.value = - PlaceDetailUiState.Success( - placeDetail.toUiModel(), - ) + PlaceDetailUiState.Success(placeDetailUiModel) }.onFailure { _placeDetail.value = PlaceDetailUiState.Error(it) } @@ -60,7 +67,7 @@ class PlaceDetailViewModel @AssistedInject constructor( } fun toggleNoticeExpanded(notice: NoticeUiModel) { - val currentState = _placeDetail.value ?: return + val currentState = _placeDetail.value if (currentState !is PlaceDetailUiState.Success) return _placeDetail.value = currentState.copy( @@ -83,11 +90,10 @@ class PlaceDetailViewModel @AssistedInject constructor( factory: Factory, place: PlaceUiModel?, receivedPlaceDetail: PlaceDetailUiModel?, - ) = - viewModelFactory { - initializer { - factory.create(place, receivedPlaceDetail) - } + ) = viewModelFactory { + initializer { + factory.create(place, receivedPlaceDetail) } + } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt deleted file mode 100644 index 06c804f0..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.daedan.festabook.presentation.placeDetail.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.common.loadImage -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel -import io.getstream.photoview.dialog.PhotoViewDialog - -class PlaceImageViewPagerAdapter : ListAdapter(DIFF_UTIL_CALLBACK) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): PlaceImageViewPagerViewHolder = PlaceImageViewPagerViewHolder.from(parent, currentList) - - override fun onBindViewHolder( - holder: PlaceImageViewPagerViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - companion object { - private val DIFF_UTIL_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ImageUiModel, - newItem: ImageUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: ImageUiModel, - newItem: ImageUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt deleted file mode 100644 index 580e826e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/adapter/PlaceImageViewPagerViewHolder.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.daedan.festabook.presentation.placeDetail.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.databinding.ItemPlaceImageBinding -import com.daedan.festabook.presentation.common.loadImage -import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel -import io.getstream.photoview.dialog.PhotoViewDialog - -class PlaceImageViewPagerViewHolder( - private val binding: ItemPlaceImageBinding, - private val images: List -) : RecyclerView.ViewHolder(binding.root) { - private val imageDialogBuilder = PhotoViewDialog.Builder( - context = binding.root.context, - images = images.map { it.url } - ) { imageView, url -> - imageView.loadImage(url) - } - - init { - binding.ivPlaceImage.setOnClickListener { - imageDialogBuilder - .withHiddenStatusBar(false) - .withStartPosition(bindingAdapterPosition) - .build() - .show() - } - } - - fun bind(imageUiModel: ImageUiModel) { - binding.image = imageUiModel - } - - companion object { - fun from(parent: ViewGroup, images: List): PlaceImageViewPagerViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = ItemPlaceImageBinding.inflate(layoutInflater, parent, false) - return PlaceImageViewPagerViewHolder(binding, images) - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt new file mode 100644 index 00000000..e4250823 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/component/PlaceDetailScreen.kt @@ -0,0 +1,469 @@ +package com.daedan.festabook.presentation.placeDetail.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.FestabookImage +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.URLText +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeDetail.model.ImageUiModel +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState +import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceDetailRoute( + viewModel: PlaceDetailViewModel, + onBackToPreviousClick: () -> Unit, + onShowErrorSnackbar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val placeDetailUiState by viewModel.placeDetail.collectAsStateWithLifecycle() + PlaceDetailScreen( + modifier = modifier, + uiState = placeDetailUiState, + onBackToPreviousClick = onBackToPreviousClick, + onShowErrorSnackbar = onShowErrorSnackbar, + ) +} + +@Composable +fun PlaceDetailScreen( + uiState: PlaceDetailUiState, + onBackToPreviousClick: () -> Unit, + modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 +) { + val scrollState = rememberScrollState() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + var isDialogOpen by remember { mutableStateOf(false) } + + BackHandler(enabled = !isDialogOpen) { + onBackToPreviousClick() + } + + LaunchedEffect(uiState) { + when (uiState) { + is PlaceDetailUiState.Error -> { + currentOnShowErrorSnackbar(uiState.throwable) + } + + else -> { + Unit + } + } + } + + when (uiState) { + is PlaceDetailUiState.Success -> { + val pagerState = + rememberPagerState( + pageCount = { uiState.placeDetail.images.size }, + ) + + PlaceDetailImageDialog( + isDialogOpen = isDialogOpen, + onDismissRequest = { isDialogOpen = false }, + pagerState = pagerState, + images = uiState.placeDetail.images, + ) + + Column( + modifier = + modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .verticalScroll(scrollState), + ) { + PlaceDetailImageContent( + images = uiState.placeDetail.images, + onBackToPreviousClick = onBackToPreviousClick, + onPageUpdate = { pagerState.scrollToPage(it) }, + modifier = + Modifier + .clickable { isDialogOpen = true } + .fillMaxWidth(), + ) + + PlaceDetailContent(placeDetail = uiState.placeDetail) + } + } + + is PlaceDetailUiState.Loading -> { + LoadingStateScreen() + } + + is PlaceDetailUiState.Error -> { + EmptyStateScreen() + } + } +} + +@Composable +private fun PlaceDetailImageDialog( + isDialogOpen: Boolean, + pagerState: PagerState, + images: List, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + if (isDialogOpen) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier = + modifier + .fillMaxSize() + .background(FestabookColor.black.copy(alpha = 0.8f)), + ) { + HorizontalPager( + state = pagerState, + verticalAlignment = Alignment.CenterVertically, + beyondViewportPageCount = 5, + ) { page -> + + FestabookImage( + imageUrl = images[page].url, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + isZoomable = true, + enablePopUp = false, + ) + } + + IconButton( + onClick = onDismissRequest, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close the popup", + tint = FestabookColor.white, + ) + } + } + } + } +} + +@Composable +private fun PlaceDetailImageContent( + images: List, + onBackToPreviousClick: (() -> Unit), + modifier: Modifier = Modifier, + onPageUpdate: suspend (page: Int) -> Unit = {}, +) { + val pagerState = rememberPagerState(pageCount = { images.size }) + val currentOnPageUpdate by rememberUpdatedState(onPageUpdate) + LaunchedEffect(pagerState.settledPage) { + currentOnPageUpdate(pagerState.settledPage) + } + + Box(modifier = modifier) { + BackToPreviousButton( + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + start = festabookSpacing.paddingScreenGutter, + ).zIndex(1f), + onClick = onBackToPreviousClick, + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + beyondViewportPageCount = 5, + ) { page -> + FestabookImage( + modifier = + Modifier + .fillMaxWidth() + .height(240.dp), + imageUrl = images[page].url, + ) + } + + PagerIndicator( + pagerState = pagerState, + modifier = + Modifier + .height(24.dp) + .align(Alignment.BottomCenter), + ) + } +} + +@Composable +private fun PlaceDetailContent( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + var isDescriptionExpand by remember { mutableStateOf(true) } + + Column( + modifier = modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) { + PlaceCategoryLabel( + modifier = Modifier.padding(top = 24.dp), + category = placeDetail.place.category, + ) + + Text( + modifier = Modifier.padding(top = festabookSpacing.paddingBody2), + text = placeDetail.place.title ?: stringResource(R.string.place_list_default_title), + style = FestabookTypography.displayMedium, + ) + + PlaceDetailInfo(placeDetail = placeDetail) + + URLText( + modifier = + Modifier + .animateContentSize( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium, + ), + ).padding( + top = festabookSpacing.paddingBody3, + ), + onClick = { + isDescriptionExpand = !isDescriptionExpand + }, + text = + placeDetail.place.description + ?: stringResource(R.string.place_list_default_description), + style = FestabookTypography.bodySmall, + maxLines = + if (isDescriptionExpand) { + Int.MAX_VALUE + } else { + 1 + }, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun PlaceDetailInfo( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + PlaceDetailInfoItem( + modifier = Modifier.padding(top = festabookSpacing.paddingBody4), + painter = painterResource(R.drawable.ic_place_detail_clock), + contentDescription = stringResource(R.string.content_description_iv_clock), + text = formattedDate(placeDetail.startTime, placeDetail.endTime), + ) + + PlaceDetailInfoItem( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + text = + placeDetail.place.location + ?: stringResource(R.string.place_list_default_location), + ) + + PlaceDetailInfoItem( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + painter = painterResource(R.drawable.ic_place_detail_host), + contentDescription = stringResource(R.string.content_description_iv_host), + text = + placeDetail.host + ?: stringResource(R.string.place_detail_default_host), + ) + } +} + +@Composable +private fun PlaceDetailInfoItem( + painter: Painter, + contentDescription: String, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + Icon( + painter = painter, + contentDescription = contentDescription, + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = text, + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } +} + +@Composable +private fun BackToPreviousButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Image( + modifier = + modifier + .size(30.dp) + .clickable { onClick() }, + painter = painterResource(id = R.drawable.btn_back_to_previous), + contentDescription = stringResource(R.string.content_description_exit_place_detail), + ) +} + +@Composable +private fun PagerIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pagerState.pageCount) { iteration -> + val isSelected = pagerState.currentPage == iteration + val color = if (isSelected) FestabookColor.black else FestabookColor.gray300 + val size by animateDpAsState(targetValue = if (isSelected) 10.dp else 8.dp) + + Box( + modifier = + Modifier + .padding(4.dp) + .clip(CircleShape) + .background(color) + .size(size), + ) + } + } +} + +@Composable +private fun formattedDate( + startTime: String?, + endTime: String?, +): String = + if (startTime == null && endTime == null) { + stringResource(R.string.place_detail_default_time) + } else { + stringResource(R.string.format_date, startTime.toString(), endTime.toString()) + } + +@Preview(showBackground = true) +@Composable +private fun PlaceDetailScreenPreview() { + FestabookTheme { + PlaceDetailScreen( + onBackToPreviousClick = {}, + onShowErrorSnackbar = {}, + uiState = + PlaceDetailUiState.Success( + placeDetail = + PlaceDetailUiModel( + place = + PlaceUiModel( + id = 1, + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = + "테스트테스트테스트테스트테스트테스.트테스트.테스트테스트테스트테스트//테스트테스트테스트테스트테스" + + "트테스트테스트테스트http://i1.sndcdn.com/art 트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테" + + "스트테스트테스트테스트https://i.ytimg.com/vi/Wr8egRRLU28/maxresdefault.com테스트테스트테스트테스트" + + "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), + ), + notices = emptyList(), + host = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + startTime = "09:00", + endTime = "18:00", + images = + listOf( + ImageUiModel( + id = 1, + url = "https://i1.sndcdn.com/artworks-AIxlEDn4gNDBnNJj-qHUnyA-t500x500.jpg", + ), + ImageUiModel( + id = 2, + url = "https://i.ytimg.com/vi/Wr8egRRLU28/maxresdefault.jpg", + ), + ), + ), + ), + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt index 999a2bd3..55399b3a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageClick.kt @@ -6,5 +6,5 @@ import kotlinx.parcelize.Parcelize @Parcelize data class PlaceDetailImageClick( override val baseLogData: BaseLogData.CommonLogData, - val index: Int -) : BaseLogData \ No newline at end of file + val index: Int, +) : BaseLogData diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt index ac40e5b8..15529d30 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/logging/PlaceDetailImageSwipe.kt @@ -7,4 +7,4 @@ import kotlinx.parcelize.Parcelize data class PlaceDetailImageSwipe( override val baseLogData: BaseLogData.CommonLogData, val startIndex: Int, -) : BaseLogData \ No newline at end of file +) : BaseLogData diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt index ba3a666f..667956fc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/ImageUiModel.kt @@ -3,8 +3,10 @@ package com.daedan.festabook.presentation.placeDetail.model import android.os.Parcelable import com.daedan.festabook.domain.model.PlaceDetailImage import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Parcelize +@Serializable data class ImageUiModel( val url: String? = null, val id: Long = -1, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt index 02504d96..d2b2f2a7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/model/PlaceDetailUiModel.kt @@ -7,10 +7,12 @@ import com.daedan.festabook.presentation.news.notice.model.toUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable import java.time.LocalTime import java.time.format.DateTimeFormatter @Parcelize +@Serializable data class PlaceDetailUiModel( val place: PlaceUiModel, val notices: List, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt deleted file mode 100644 index 01c1e1a8..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapTouchEventInterceptView.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -import android.content.Context -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.MotionEvent -import android.widget.FrameLayout - -class MapTouchEventInterceptView( - context: Context, - attrs: AttributeSet? = null, -) : FrameLayout( - context, - attrs, - ) { - private var onMapDragListener: OnMapDragListener? = null - - private var isMapDragging = false - - private val gestureDetector by lazy { - GestureDetector( - context, - object : GestureDetector.SimpleOnGestureListener() { - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float, - ): Boolean { - if (!isMapDragging) { - onMapDragListener?.onDrag() - isMapDragging = true - } - return super.onFling(e1, e2, velocityX, velocityY) - } - - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float, - ): Boolean { - if ((distanceY > 0 || distanceX > 0) && !isMapDragging) { - isMapDragging = true - onMapDragListener?.onDrag() - } - return super.onScroll(e1, e2, distanceX, distanceY) - } - }, - ) - } - - override fun onInterceptTouchEvent(event: MotionEvent?): Boolean { - event?.let { - if (it.action == MotionEvent.ACTION_UP) { - isMapDragging = false - } - gestureDetector.onTouchEvent(it) - } - return false - } - - fun setOnMapDragListener(listener: OnMapDragListener) { - onMapDragListener = listener - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt deleted file mode 100644 index a193bee1..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapUtil.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -import com.naver.maps.map.MapFragment -import com.naver.maps.map.NaverMap -import kotlinx.coroutines.suspendCancellableCoroutine - -suspend fun MapFragment.getMap() = - suspendCancellableCoroutine { cont -> - getMapAsync { map -> - cont.resumeWith( - Result.success(map), - ) - } - } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt deleted file mode 100644 index aa98139c..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnMapDragListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -fun interface OnMapDragListener { - fun onDrag() -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt deleted file mode 100644 index fb5eb580..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnTimeTagSelectedListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.daedan.festabook.presentation.placeMap - -import com.daedan.festabook.domain.model.TimeTag - -interface OnTimeTagSelectedListener { - fun onTimeTagSelected(item: TimeTag) - - fun onNothingSelected() -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index a80d71ab..563b3752 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -2,46 +2,56 @@ package com.daedan.festabook.presentation.placeMap import android.content.Context import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.AdapterView +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.ImageResult import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.di.mapManager.MapManagerGraph -import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment +import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.OnMenuItemReClickListener +import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapScreen +import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter -import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected -import com.daedan.festabook.presentation.placeMap.mapManager.MapManager -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeCategory.PlaceCategoryFragment -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewFragment -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewSecondaryFragment -import com.daedan.festabook.presentation.placeMap.placeList.PlaceListFragment -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.adapter.TimeTagSpinnerAdapter -import com.naver.maps.map.MapFragment -import com.naver.maps.map.NaverMap -import com.naver.maps.map.OnMapReadyCallback +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import timber.log.Timber @ContributesIntoMap( @@ -51,244 +61,127 @@ import timber.log.Timber @FragmentKey(PlaceMapFragment::class) @Inject class PlaceMapFragment( - placeListFragment: PlaceListFragment, - placeDetailPreviewFragment: PlaceDetailPreviewFragment, - placeCategoryFragment: PlaceCategoryFragment, - placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, - mapFragment: MapFragment, + override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment(), - OnMenuItemReClickListener, - OnTimeTagSelectedListener { + OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_map - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - - private lateinit var naverMap: NaverMap - - private val placeListFragment by lazy { getIfExists(placeListFragment) } - private val placeDetailPreviewFragment by lazy { getIfExists(placeDetailPreviewFragment) } - private val placeCategoryFragment by lazy { getIfExists(placeCategoryFragment) } - private val placeDetailPreviewSecondaryFragment by lazy { - getIfExists( - placeDetailPreviewSecondaryFragment, - ) - } - private val mapFragment by lazy { getIfExists(mapFragment) } - private val locationSource by lazy { FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) } - private var mapManager: MapManager? = null - - private val viewModel: PlaceMapViewModel by viewModels() + private val placeMapViewModel: PlaceMapViewModel by viewModels() - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - binding.spinnerSelectTimeTag.onItemSelectedListener = - object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>, - view: View?, - position: Int, - id: Long, - ) { - val item = parent.getItemAtPosition(position) as TimeTag - - onTimeTagSelected(item) - } - - override fun onNothingSelected(parent: AdapterView<*>) { - onNothingSelected() - } - } - if (savedInstanceState == null) { - childFragmentManager.commit { - addWithSimpleTag(R.id.fcv_map_container, mapFragment) - addWithSimpleTag(R.id.fcv_place_list_container, placeListFragment) - addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewFragment) - addWithSimpleTag(R.id.fcv_place_category_container, placeCategoryFragment) - addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewSecondaryFragment) - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - } - } - lifecycleScope.launch { - setUpMapManager() - setUpObserver() - } + ): View { + super.onCreateView(inflater, container, savedInstanceState) binding.logger.log( PlaceFragmentEnter( baseLogData = binding.logger.getBaseLogData(), ), ) - } - - override fun onMenuItemReClick() { - val childFragments = - listOf( - placeListFragment, - placeDetailPreviewFragment, - placeCategoryFragment, - ) - childFragments.forEach { fragment -> - (fragment as? OnMenuItemReClickListener)?.onMenuItemReClick() - } - mapManager?.moveToPosition() - } - - override fun onTimeTagSelected(item: TimeTag) { - viewModel.unselectPlace() - viewModel.onDaySelected(item) - binding.logger.log( - PlaceTimeTagSelected( - baseLogData = binding.logger.getBaseLogData(), - timeTagName = item.name, - ), - ) - } - - override fun onNothingSelected() = Unit - - private suspend fun setUpMapManager() { - naverMap = mapFragment.getMap() - naverMap.addOnLocationChangeListener { - binding.logger.log( - CurrentLocationChecked( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - (placeListFragment as? OnMapReadyCallback)?.onMapReady(naverMap) - naverMap.locationSource = locationSource - binding.viewMapTouchEventIntercept.setOnMapDragListener { - viewModel.onMapViewClick() - } - } - - private fun setUpObserver() { - viewModel.timeTags.observe(viewLifecycleOwner) { timeTags -> - // 타임태그가 없는 경우 메뉴 GONE - binding.layoutMapMenu.visibility = - if (timeTags.isNullOrEmpty()) View.GONE else View.VISIBLE - - if (binding.spinnerSelectTimeTag.adapter == null) { - val adapter = TimeTagSpinnerAdapter(requireContext(), timeTags.toMutableList()) - binding.spinnerSelectTimeTag.adapter = adapter - } else { - val adapter = binding.spinnerSelectTimeTag.adapter as TimeTagSpinnerAdapter - adapter.updateItems(timeTags) - adapter.notifyDataSetChanged() - } - } - - viewModel.placeGeographies.observe(viewLifecycleOwner) { placeGeographies -> - when (placeGeographies) { - is PlaceListUiState.Loading -> Unit - is PlaceListUiState.Success -> { - mapManager?.setupMarker(placeGeographies.value) - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { selectedTimeTag -> - mapManager?.filterMarkersByTimeTag(selectedTimeTag.timeTagId) + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() + val density = LocalDensity.current + val bottomSheetState = rememberPlaceListBottomSheetState() + val mapDelegate = remember { MapDelegate() } + val mapManagerDelegate = remember { MapManagerDelegate() } + val mapControlSideEffectHandler = + remember { + MapControlSideEffectHandler( + initialPadding = with(density) { 254.dp.toPx() }.toInt(), + logger = appGraph.defaultFirebaseLogger, + locationSource = locationSource, + viewModel = placeMapViewModel, + mapDelegate = mapDelegate, + mapManagerDelegate = mapManagerDelegate, + ) + } + val placeMapSideEffectHandler = + remember { + PlaceMapSideEffectHandler( + mapManagerDelegate = mapManagerDelegate, + bottomSheetState = bottomSheetState, + viewModel = placeMapViewModel, + logger = appGraph.defaultFirebaseLogger, + onStartPlaceDetail = { startPlaceDetailActivity(it.placeDetail.value) }, + onPreloadImages = { preloadImages(requireContext(), it.places) }, + onShowErrorSnackBar = { showErrorSnackBar(it.error.throwable) }, + ) } - } - is PlaceListUiState.Error -> { - Timber.w( - placeGeographies.throwable, - "PlaceListFragment: ${placeGeographies.throwable.message}", - ) - showErrorSnackBar(placeGeographies.throwable) + ObserveAsEvents(flow = placeMapViewModel.mapControlSideEffect) { event -> + mapControlSideEffectHandler(event) } - else -> Unit - } - } + ObserveAsEvents(flow = placeMapViewModel.placeMapSideEffect) { event -> + placeMapSideEffectHandler(event) + } - viewModel.initialMapSetting.observe(viewLifecycleOwner) { initialMapSetting -> - if (initialMapSetting !is PlaceListUiState.Success) return@observe - if (mapManager == null) { - val graph = - createGraphFactory().create( - naverMap, - initialMapSetting.value, - viewModel, - getInitialPadding(requireContext()), + FestabookTheme { + PlaceMapScreen( + uiState = uiState, + onEvent = { placeMapViewModel.onPlaceMapEvent(it) }, + bottomSheetState = bottomSheetState, + mapDelegate = mapDelegate, ) - mapManager = graph.mapManager - mapManager?.setupBackToInitialPosition { isExceededMaxLength -> - viewModel.setIsExceededMaxLength(isExceededMaxLength) } } } + } - viewModel.backToInitialPositionClicked.observe(viewLifecycleOwner) { - mapManager?.moveToPosition() - } - - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - mapManager?.clearFilter() - } else { - mapManager?.filterMarkersByCategories(selectedCategories) - } - } + override fun onMenuItemReClick() { + placeMapViewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - childFragmentManager.commit { - setReorderingAllowed(true) + private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { + Timber.d("start detail activity") + val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) + startActivity(intent) + } - when (selectedPlace) { - is SelectedPlaceUiState.Success -> { - mapManager?.selectMarker(selectedPlace.value.place.id) - if (selectedPlace.isSecondary) { - hide(placeListFragment) - hide(placeDetailPreviewFragment) - show(placeDetailPreviewSecondaryFragment) - } else { - hide(placeListFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeDetailPreviewFragment) + // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 + private fun preloadImages( + context: Context, + places: List, + maxSize: Int = 20, + ) { + val imageLoader = context.imageLoader + val deferredList = mutableListOf>() + + lifecycleScope.launch(Dispatchers.IO) { + places + .take(maxSize) + .filterNotNull() + .forEach { place -> + val deferred = + async { + val request = + ImageRequest + .Builder(context) + .data(place.imageUrl.convertImageUrl()) + .build() + + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + Timber.d("preload 실패") + }.getOrNull() } - binding.logger.log( - PlaceMarkerClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = selectedPlace.value.place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) - } - - is SelectedPlaceUiState.Empty -> { - mapManager?.unselectMarker() - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeListFragment) - } - - else -> Unit + deferredList.add(deferred) } - } + deferredList.awaitAll() } } - @Suppress("UNCHECKED_CAST") - private fun getIfExists(fragment: T): T = - childFragmentManager.findFragmentByTag(fragment::class.simpleName) as? T ?: fragment - - private fun FragmentTransaction.addWithSimpleTag( - containerViewId: Int, - fragment: Fragment, - ) { - add(containerViewId, fragment, fragment::class.simpleName) - } - companion object { private const val LOCATION_PERMISSION_REQUEST_CODE = 1234 - - private fun getInitialPadding(context: Context): Int = 254.toPx(context) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 7836f60f..d25c4da6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -1,150 +1,193 @@ package com.daedan.festabook.presentation.placeMap -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.presentation.common.Event -import com.daedan.festabook.presentation.common.SingleLiveData -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeDetail.model.toUiModel -import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.EventHandlerContext +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @ViewModelKey(PlaceMapViewModel::class) -class PlaceMapViewModel @Inject constructor( +@Inject +class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, - private val placeDetailRepository: PlaceDetailRepository, + handlerGraphFactory: PlaceMapHandlerGraph.Factory, ) : ViewModel() { - private val _initialMapSetting: MutableLiveData> = - MutableLiveData() - val initialMapSetting: LiveData> = _initialMapSetting - - private val _placeGeographies: MutableLiveData>> = - MutableLiveData() - val placeGeographies: LiveData>> - get() = _placeGeographies - - private val _timeTags = MutableLiveData>() - val timeTags: LiveData> = _timeTags - - private val _selectedTimeTag = MutableLiveData() - val selectedTimeTag: LiveData = _selectedTimeTag - - private val _selectedPlace: MutableLiveData = MutableLiveData() - val selectedPlace: LiveData = _selectedPlace - - private val _navigateToDetail = SingleLiveData() - val navigateToDetail: LiveData = _navigateToDetail - - private val _isExceededMaxLength: MutableLiveData = MutableLiveData() - val isExceededMaxLength: LiveData = _isExceededMaxLength - - private val _backToInitialPositionClicked: MutableLiveData> = MutableLiveData() - val backToInitialPositionClicked: LiveData> = _backToInitialPositionClicked - - private val _selectedCategories: MutableLiveData> = MutableLiveData() - val selectedCategories: LiveData> = _selectedCategories - - private val _onMapViewClick: MutableLiveData> = MutableLiveData() - val onMapViewClick: LiveData> = _onMapViewClick + private val cachedPlaces = MutableStateFlow(listOf()) + private val cachedPlaceByTimeTag = MutableStateFlow>(emptyList()) + + private val _uiState = MutableStateFlow(PlaceMapUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _placeMapSideEffect = Channel() + val placeMapSideEffect: Flow = _placeMapSideEffect.receiveAsFlow() + + private val _mapControlSideEffect = Channel() + val mapControlSideEffect: Flow = _mapControlSideEffect.receiveAsFlow() + + private val handlerGraph = + handlerGraphFactory + .create( + EventHandlerContext( + mapControlSideEffect = _mapControlSideEffect, + placeMapSideEffect = _placeMapSideEffect, + uiState = uiState, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + onUpdateState = { _uiState.update(it) }, + scope = viewModelScope, + ), + ) init { loadOrganizationGeography() loadTimeTags() + loadAllPlaces() + observeErrorEvent() } - private fun loadTimeTags() { - viewModelScope.launch { - placeListRepository - .getTimeTags() - .onSuccess { timeTags -> - _timeTags.value = timeTags - }.onFailure { - _timeTags.value = emptyList() - } - - // 기본 선택값 - if (!timeTags.value.isNullOrEmpty()) { - _selectedTimeTag.value = _timeTags.value?.first() - } else { - _selectedTimeTag.value = TimeTag.Companion.EMPTY - } + fun onPlaceMapEvent(event: PlaceMapEvent) { + when (event) { + is FilterEvent -> handlerGraph.filterEventHandler(event) + is MapControlEvent -> handlerGraph.mapControlEventHandler(event) + is SelectEvent -> handlerGraph.selectEventHandler(event) } } - fun onDaySelected(item: TimeTag) { - _selectedTimeTag.value = item + fun onMenuItemReClicked() { + _placeMapSideEffect.trySend( + PlaceMapSideEffect.MenuItemReClicked( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) } - fun selectPlace(placeId: Long) { + private fun loadTimeTags() { viewModelScope.launch { - _selectedPlace.value = SelectedPlaceUiState.Loading - placeDetailRepository - .getPlaceDetail(placeId = placeId) - .onSuccess { - _selectedPlace.value = SelectedPlaceUiState.Success(it.toUiModel()) + placeListRepository + .getTimeTags() + .onSuccess { timeTags -> + _uiState.update { + it.copy( + timeTags = LoadState.Success(timeTags), + ) + } }.onFailure { - _selectedPlace.value = SelectedPlaceUiState.Error(it) + _uiState.update { + it.copy( + timeTags = LoadState.Empty, + ) + } } - } - } - fun unselectPlace() { - _selectedPlace.value = SelectedPlaceUiState.Empty - } + // 기본 선택값 + val timeTags = uiState.value.timeTags + val selectedTimeTag = + if (timeTags is LoadState.Success && timeTags.value.isNotEmpty()) { + LoadState.Success( + timeTags.value.first(), + ) + } else { + LoadState.Empty + } + _uiState.update { + it.copy(selectedTimeTag = selectedTimeTag) + } - fun onExpandedStateReached() { - val currentPlace = _selectedPlace.value.let { it as? SelectedPlaceUiState.Success }?.value - if (currentPlace != null) { - _navigateToDetail.setValue(currentPlace) + val placeGeographies = + uiState.await>> { it.placeGeographies } + _mapControlSideEffect.send( + MapControlSideEffect.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = selectedTimeTag, + isInitial = true, + ), + ) } } - fun onBackToInitialPositionClicked() { - _backToInitialPositionClicked.value = Event(Unit) - } - - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - _isExceededMaxLength.value = isExceededMaxLength - } - - fun setSelectedCategories(categories: List) { - _selectedCategories.value = categories - } - - fun onMapViewClick() { - _onMapViewClick.value = Event(Unit) - } - private fun loadOrganizationGeography() { viewModelScope.launch { placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> - _initialMapSetting.value = - PlaceListUiState.Success(organizationGeography.toUiModel()) + _uiState.update { + it.copy(initialMapSetting = LoadState.Success(organizationGeography.toUiModel())) + } } launch { placeListRepository .getPlaceGeographies() .onSuccess { placeGeographies -> - _placeGeographies.value = - PlaceListUiState.Success(placeGeographies.map { it.toUiModel() }) - }.onFailure { - _placeGeographies.value = PlaceListUiState.Error(it) + _uiState.update { + it.copy( + placeGeographies = LoadState.Success(placeGeographies.map { it.toUiModel() }), + ) + } + }.onFailure { item -> + _uiState.update { + it.copy(placeGeographies = LoadState.Error(item)) + } + } + } + } + } + + private fun loadAllPlaces() { + viewModelScope.launch { + val result = placeListRepository.getPlaces() + result + .onSuccess { places -> + val placeUiModels = places.map { it.toUiModel() } + cachedPlaces.tryEmit(placeUiModels) + _uiState.update { it.copy(places = ListLoadState.PlaceLoaded(placeUiModels)) } + }.onFailure { error -> + _uiState.update { it.copy(places = ListLoadState.Error(error)) } + } + } + } + + @OptIn(FlowPreview::class) + private fun observeErrorEvent() { + viewModelScope.launch { + launch { + uiState + .map { it.hasAnyError } + .distinctUntilChanged() + .filterIsInstance() + .debounce(1000) + .collect { + _placeMapSideEffect.send(PlaceMapSideEffect.ShowErrorSnackBar(it)) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt new file mode 100644 index 00000000..676e2ddc --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes + +@Composable +fun BackToPositionButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + AssistChip( + modifier = modifier, + onClick = onClick, + label = { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + }, + border = + AssistChipDefaults.assistChipBorder( + enabled = true, + borderColor = FestabookColor.black, + borderWidth = 1.dp, + ), + colors = + AssistChipDefaults.assistChipColors( + containerColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BackToPositionButtonPreview() { + FestabookTheme { + BackToPositionButton( + text = "학교로 돌아가기", + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt new file mode 100644 index 00000000..e91d9363 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.naver.maps.map.NaverMap +import com.naver.maps.map.widget.LocationButtonView + +@Composable +fun CurrentLocationButton( + modifier: Modifier = Modifier, + map: NaverMap? = null, +) { + AndroidView( + modifier = modifier, + factory = { context -> LocationButtonView(context) }, + update = { view -> view.map = map }, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt new file mode 100644 index 00000000..c1931e25 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -0,0 +1,154 @@ +package com.daedan.festabook.presentation.placeMap.component + +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import android.os.Bundle +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.naver.maps.map.MapView +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.suspendCancellableCoroutine + +@Composable +fun NaverMapContent( + modifier: Modifier = Modifier, + mapDelegate: MapDelegate = MapDelegate(), + onMapDrag: () -> Unit = {}, + onMapReady: (NaverMap) -> Unit = {}, + content: @Composable (NaverMap?) -> Unit, +) { + val context = LocalContext.current + val mapView = remember { MapView(context) } + LaunchedEffect(mapView) { + val naverMap = mapView.getMapAndRunCallback(onMapReady) + mapDelegate.initMap(naverMap) + } + Box(modifier = modifier) { + // TODO AndroidView와 CMP 뷰의 혼용으로 컴파일러 경고 발생중 -> 추후 해결하겠습니다 + AndroidView( + factory = { mapView }, + modifier = Modifier.dragInterceptor(onMapDrag), + onRelease = { + mapView.onDestroy() + }, + ) + content(mapDelegate.value) + } + RegisterMapLifeCycle(mapView) +} + +private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = + this.then( + Modifier.pointerInput(Unit) { + val touchSlop = viewConfiguration.touchSlop // 시스템이 정의한 드래그 판단 기준 거리 + awaitPointerEventScope { + while (true) { + // 1. 첫 번째 터치(Down)를 기다립니다. + val downEvent = awaitPointerEvent(pass = PointerEventPass.Initial) + val downChange = downEvent.changes.firstOrNull { it.pressed } ?: continue + + // 터치 시작 지점 저장 + val startPosition = downChange.position + var isDragEmitted = false // 이번 드래그 세션에서 콜백을 호출했는지 체크 + + // 2. 터치가 유지되는 동안(드래그 중) 계속 감시합니다. + do { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == downChange.id } + + if (change != null && change.pressed) { + // 현재 위치와 시작 위치 사이의 거리 계산 + val currentPosition = change.position + val distance = (currentPosition - startPosition).getDistance() + + // 3. 이동 거리가 touchSlop보다 크고, 아직 콜백을 안 불렀다면 호출 + if (!isDragEmitted && distance > touchSlop) { + onMapDrag() + isDragEmitted = true + } + } + } while (event.changes.any { it.pressed }) // 손을 뗄 때까지 루프 + } + } + }, + ) + +@Composable +private fun RegisterMapLifeCycle(mapView: MapView) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } + val savedInstanceState = rememberSaveable { Bundle() } + + DisposableEffect(lifecycle, mapView) { + val mapLifecycleObserver = + mapView.lifecycleObserver( + savedInstanceState.takeUnless { it.isEmpty }, + previousState, + ) + + val callbacks = + object : ComponentCallbacks2 { + override fun onConfigurationChanged(config: Configuration) = Unit + + @Deprecated("This callback is superseded by onTrimMemory") + override fun onLowMemory() { + mapView.onLowMemory() + } + + override fun onTrimMemory(level: Int) { + mapView.onLowMemory() + } + } + + lifecycle.addObserver(mapLifecycleObserver) + context.registerComponentCallbacks(callbacks) + onDispose { + mapView.onSaveInstanceState(savedInstanceState) + lifecycle.removeObserver(mapLifecycleObserver) + context.unregisterComponentCallbacks(callbacks) + } + } +} + +private fun MapView.lifecycleObserver( + savedInstanceState: Bundle?, + previousState: MutableState, +): LifecycleEventObserver = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> this.onCreate(savedInstanceState) + Lifecycle.Event.ON_START -> this.onStart() + Lifecycle.Event.ON_RESUME -> this.onResume() + Lifecycle.Event.ON_PAUSE -> this.onPause() + Lifecycle.Event.ON_STOP -> this.onStop() + Lifecycle.Event.ON_DESTROY -> this.onDestroy() + else -> throw IllegalStateException() + } + previousState.value = event + } + +private suspend fun MapView.getMapAndRunCallback(onMapReady: (NaverMap) -> Unit = {}): NaverMap = + suspendCancellableCoroutine { continuation -> + getMapAsync { map -> + onMapReady(map) + continuation.resumeWith( + Result.success(map), + ) + } + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt new file mode 100644 index 00000000..4fab146c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapLogo.kt @@ -0,0 +1,18 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.naver.maps.map.widget.LogoView + +@Composable +fun NaverMapLogo(modifier: Modifier = Modifier) { + val context = LocalContext.current + val logoView = remember { LogoView(context) } + AndroidView( + factory = { logoView }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt new file mode 100644 index 00000000..5202e4a3 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt @@ -0,0 +1,32 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import kotlin.math.roundToInt + +@Composable +fun OffsetDependentLayout( + offset: Float, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Layout( + content = content, + modifier = modifier, + ) { measurables, constraints -> + val placeable = + measurables.firstOrNull()?.measure(constraints) ?: return@Layout layout( + width = constraints.minWidth, + height = constraints.minHeight, + placementBlock = { }, + ) + + // 부모의 크기를 결정 + layout(placeable.width, placeable.height + offset.roundToInt()) { + // offset만큼 배치 + val finalYPosition = offset.roundToInt() - placeable.height + placeable.placeRelative(x = 0, y = finalYPosition) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt new file mode 100644 index 00000000..cd521881 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt @@ -0,0 +1,80 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getLabelColor +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlin.math.roundToInt + +@Composable +fun PlaceCategoryLabel( + category: PlaceCategoryUiModel, + modifier: Modifier = Modifier, + iconColor: Color = category.getLabelColor(), +) { + Card( + shape = festabookShapes.radius1, + colors = + CardColors( + containerColor = getBackgroundColor(iconColor), + contentColor = Color.Unspecified, + disabledContainerColor = getBackgroundColor(iconColor), + disabledContentColor = Color.Unspecified, + ), + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(category.getIconId()), + contentDescription = stringResource(category.getTextId()), + tint = iconColor, + modifier = Modifier.size(12.dp), + ) + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = stringResource(category.getTextId()), + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + +private fun getBackgroundColor(color: Color): Color { + // 10% 투명도를 가지게 변경 + val alpha = (MAX_ALPHA * ALPHA_RATIO).roundToInt() + return color.copy(alpha = alpha / MAX_ALPHA.toFloat()) +} + +private const val MAX_ALPHA = 255 +private const val ALPHA_RATIO = 0.10f + +@Preview(showBackground = true) +@Composable +private fun PlaceCategoryLabelPreview() { + val category = PlaceCategoryUiModel.FOOD_TRUCK + PlaceCategoryLabel( + category = category, + iconColor = Color(0xFF00AB40), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt new file mode 100644 index 00000000..fc551b8f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt @@ -0,0 +1,140 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceCategoryScreen( + modifier: Modifier = Modifier, + selectedCategories: Set = emptySet(), + onDisplayAllClick: (selectedCategories: Set) -> Unit = {}, + onCategoryClick: (selectedCategories: Set) -> Unit = {}, + initialCategories: List = PlaceCategoryUiModel.entries, +) { + val scrollState = rememberScrollState() + + Row( + modifier = + modifier + .horizontalScroll(scrollState) + .padding( + vertical = festabookSpacing.paddingBody2, + horizontal = festabookSpacing.paddingScreenGutter, + ), + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody2), + ) { + CategoryChip( + text = stringResource(R.string.map_category_all), + selected = selectedCategories.isEmpty(), + onClick = { + onDisplayAllClick(emptySet()) + }, + ) + + initialCategories.forEach { category -> + val text = stringResource(category.getTextId()) + CategoryChip( + text = text, + selected = selectedCategories.contains(category), + icon = { + Icon( + painter = painterResource(category.getIconId()), + contentDescription = text, + tint = Color.Unspecified, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + }, + onClick = { + val newSelectedCategories = + if (selectedCategories.contains(category)) { + selectedCategories.filter { it != category } + } else { + selectedCategories + setOf(category) + } + onCategoryClick(newSelectedCategories.toSet()) + }, + ) + } + } +} + +@Composable +private fun CategoryChip( + text: String, + modifier: Modifier = Modifier, + selected: Boolean = false, + icon: @Composable (() -> Unit)? = null, + onClick: () -> Unit = {}, +) { + FilterChip( + selected = selected, + onClick = { + onClick() + }, + modifier = modifier, + label = { + Text( + text = text, + style = FestabookTypography.bodyLarge, + ) + }, + shape = festabookShapes.radiusFull, + colors = + FilterChipDefaults.filterChipColors( + containerColor = FestabookColor.white, + selectedContainerColor = FestabookColor.gray200, + labelColor = FestabookColor.black, + selectedLabelColor = FestabookColor.black, + ), + border = + FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = FestabookColor.gray200, + selectedBorderColor = FestabookColor.black, + borderWidth = 2.dp, + selectedBorderWidth = 2.dp, + ), + leadingIcon = icon, + ) +} + +@Composable +@Preview(showBackground = true) +private fun CategoryChipPreview() { + FestabookTheme { + CategoryChip("전체") + } +} + +@Composable +@Preview(showBackground = true) +private fun PlaceCategoryScreenPreview() { + FestabookTheme { + PlaceCategoryScreen() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt new file mode 100644 index 00000000..caff6471 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -0,0 +1,214 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.URLText +import com.daedan.festabook.presentation.common.convertImageUrl +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceDetailPreviewScreen( + selectedPlace: LoadState, + modifier: Modifier = Modifier, + visible: Boolean = false, + onClick: (LoadState) -> Unit = {}, + onBackPress: () -> Unit = {}, +) { + BackHandler(enabled = visible) { + onBackPress() + } + PreviewAnimatableBox( + visible = visible, + modifier = + modifier + .wrapContentSize() + .clickable { onClick(selectedPlace) }, + ) { + when (selectedPlace) { + is LoadState.Success -> { + PlaceDetailPreviewContent(placeDetail = selectedPlace.value) + } + + else -> Unit + } + } +} + +@Composable +private fun PlaceDetailPreviewContent( + placeDetail: PlaceDetailUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.previewVerticalPadding, + ), + ) { + PlaceCategoryLabel( + category = placeDetail.place.category, + ) + + Row(modifier = Modifier.wrapContentSize()) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + modifier = + Modifier + .padding(top = festabookSpacing.paddingBody1), + style = FestabookTypography.displaySmall, + text = + placeDetail.place.title + ?: stringResource(R.string.place_list_default_title), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody3), + ) { + Icon( + painter = painterResource(R.drawable.ic_place_detail_clock), + contentDescription = stringResource(R.string.content_description_iv_clock), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = formattedDate(placeDetail.startTime, placeDetail.endTime), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } + + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + placeDetail.place.location + ?: stringResource(R.string.place_list_default_location), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } + + Row( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_place_detail_host), + contentDescription = stringResource(R.string.content_description_iv_host), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + placeDetail.host + ?: stringResource(R.string.place_detail_default_host), + style = FestabookTypography.bodySmall, + color = FestabookColor.gray500, + ) + } + } + + CoilImage( + modifier = + Modifier + .size(festabookSpacing.previewImageSize) + .clip(festabookShapes.radius2), + url = placeDetail.place.imageUrl.convertImageUrl() ?: "", + contentDescription = stringResource(R.string.content_description_booth_image), + ) + } + + URLText( + modifier = Modifier.padding(top = festabookSpacing.paddingBody3), + text = + placeDetail.place.description + ?: stringResource(R.string.place_list_default_description), + style = FestabookTypography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun formattedDate( + startTime: String?, + endTime: String?, +): String = + if (startTime == null && endTime == null) { + stringResource(R.string.place_detail_default_time) + } else { + listOf(startTime, endTime).joinToString(" ~ ") + } + +@Preview +@Composable +private fun PlaceDetailPreviewScreenPreview() { + FestabookTheme { + PlaceDetailPreviewScreen( + modifier = + Modifier + .padding(festabookSpacing.paddingScreenGutter), + selectedPlace = + LoadState.Success( + value = FAKE_PLACE_DETAIL, + ), + ) + } +} + +private val FAKE_PLACE = + PlaceUiModel( + id = 1, + imageUrl = null, + category = PlaceCategoryUiModel.FOOD_TRUCK, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "https://onlyfor-me-blog.tistory.com/1190", + location = null, + isBookmarked = false, + timeTagId = listOf(1), + ) + +private val FAKE_PLACE_DETAIL = + PlaceDetailUiModel( + place = FAKE_PLACE, + notices = listOf(), + host = null, + startTime = null, + endTime = null, + images = listOf(), + ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt new file mode 100644 index 00000000..26660464 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -0,0 +1,126 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun PlaceDetailPreviewSecondaryScreen( + selectedPlace: LoadState, + modifier: Modifier = Modifier, + onClick: (LoadState) -> Unit = {}, + onBackPress: () -> Unit = {}, + visible: Boolean = false, +) { + BackHandler(enabled = visible) { + onBackPress() + } + PreviewAnimatableBox( + visible = visible, + modifier = + modifier + .fillMaxWidth() + .clickable { + onClick(selectedPlace) + }, + shape = festabookShapes.radius2, + ) { + when (selectedPlace) { + is LoadState.Success -> { + Row( + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingBody4, + vertical = festabookSpacing.paddingBody3, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = + painterResource( + selectedPlace.value.place.category + .getIconId(), + ), + tint = Color.Unspecified, + contentDescription = stringResource(R.string.content_description_iv_category_marker), + ) + + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody2), + text = + selectedPlace.value.place.title + ?: stringResource( + selectedPlace.value.place.category + .getTextId(), + ), + style = FestabookTypography.displaySmall, + ) + } + } + + else -> Unit + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaceDetailPreviewSecondaryScreenPreview() { + FestabookTheme { + PlaceDetailPreviewSecondaryScreen( + visible = true, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + selectedPlace = + LoadState.Success( + FAKE_PLACE_DETAIL, + ), + ) + } +} + +private val FAKE_PLACE = + PlaceUiModel( + id = 1, + imageUrl = null, + category = PlaceCategoryUiModel.TOILET, + title = "테스트테스", + description = "https://onlyfor-me-blog.tistory.com/1190", + location = null, + isBookmarked = false, + timeTagId = listOf(1), + ) + +private val FAKE_PLACE_DETAIL = + PlaceDetailUiModel( + place = FAKE_PLACE, + notices = listOf(), + host = null, + startTime = null, + endTime = null, + images = listOf(), + ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt new file mode 100644 index 00000000..0089b872 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt @@ -0,0 +1,223 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun PlaceListBottomSheet( + peekHeight: Dp, + halfExpandedRatio: Float, + modifier: Modifier = Modifier, + bottomSheetState: PlaceListBottomSheetState = + rememberPlaceListBottomSheetState( + PlaceListBottomSheetValue.HALF_EXPANDED, + ), + shape: Shape = PlaceListBottomSheetDefault.bottomSheetBackgroundShape, + color: Color = PlaceListBottomSheetDefault.bottomSheetBackgroundColor, + onStateUpdate: (PlaceListBottomSheetValue) -> Unit = {}, + onScroll: (Float) -> Unit = {}, + dragHandle: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + require(halfExpandedRatio in 0.0..1.0) { "halfExpandedRatio는 0과 1 사이여야 합니다." } + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val currentOnStateUpdate by rememberUpdatedState(onStateUpdate) + + LaunchedEffect(bottomSheetState.settledValue) { + currentOnStateUpdate(bottomSheetState.settledValue) + } + + val nestedScrollConnection = placeListBottomSheetNestedScrollConnection(bottomSheetState) + + Column( + modifier = + modifier + .fillMaxSize() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + // 실제 레이아웃 측정 시에만 앵커 설정 + if (!isLookingAhead) { + val screenHeightPx = constraints.maxHeight.toFloat() + // 3가지 앵커 높이 정의 (DP) + val halfExpandedOffsetPx = + screenHeightPx - screenHeightPx * halfExpandedRatio + val collapsedOffsetPx = with(density) { screenHeightPx - peekHeight.toPx() } + val expandedOffsetPx = 0f // 화면 최상단 + + bottomSheetState.state.updateAnchors( + newAnchors = + DraggableAnchors { + PlaceListBottomSheetValue.EXPANDED at expandedOffsetPx + PlaceListBottomSheetValue.HALF_EXPANDED at halfExpandedOffsetPx + PlaceListBottomSheetValue.COLLAPSED at collapsedOffsetPx + }, + newTarget = bottomSheetState.currentValue, + ) + // 스크롤 되었을 때 호출하는 콜백 + scope.launch { + snapshotFlow { bottomSheetState.state.requireOffset() } + .collect { currentOffset -> + onScroll(currentOffset) + } + } + } + + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + }.nestedScroll(nestedScrollConnection) + .offset { + IntOffset( + 0, + if (bottomSheetState.offset.isNaN()) 0 else bottomSheetState.offset.roundToInt(), + ) + }.background( + color = color, + shape = shape, + ).anchoredDraggable( + state = bottomSheetState.state, + orientation = Orientation.Vertical, + ), + ) { + PlaceListBottomSheetDefault.DefaultDragHandle() + dragHandle() + content() + } +} + +/** + * PlaceListBottomSheet의 기본 스타일을 정의합니다. + * 기본적인 DragHandle 컴포저블을 정의합니다. + */ +object PlaceListBottomSheetDefault { + val bottomSheetBackgroundShape: Shape = + RoundedCornerShape( + topStart = 30.dp, + topEnd = 30.dp, + ) + + val bottomSheetBackgroundColor: Color + @Composable + get() = FestabookColor.white + + private val dragHandleVerticalPadding = 12.dp + private val dragHandleWidth = 32.dp + private val dragHandleHeight = 4.dp + + private val dragHandleCorner = + RoundedCornerShape( + percent = 50, + ) + + private val dragHandleColor + @Composable + get() = FestabookColor.gray400 + + @Composable + fun DefaultDragHandle(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(vertical = dragHandleVerticalPadding) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = dragHandleWidth, height = dragHandleHeight) + .background( + color = dragHandleColor, + shape = dragHandleCorner, + ), + ) + } + } +} + +/** NestedScroll을 위한 Connection 객체를 반환합니다. + */ +private fun placeListBottomSheetNestedScrollConnection(placeListBottomSheetState: PlaceListBottomSheetState): NestedScrollConnection { + return object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = + if (available.y < 0 && source == NestedScrollSource.UserInput) { + placeListBottomSheetState.state.dispatchRawDelta(available.y).toOffset() + } else { + Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + if (source == NestedScrollSource.UserInput) { + placeListBottomSheetState.state.dispatchRawDelta(available.y).toOffset() + } else { + Offset.Zero + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity { + placeListBottomSheetState.settleImmediately(available) + return available + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.y + val currentOffset = placeListBottomSheetState.state.requireOffset() + val minAnchor = placeListBottomSheetState.anchors.minPosition() + return if (toFling < 0 && currentOffset > minAnchor) { + placeListBottomSheetState.settleImmediately(available) + available + } else { + Velocity.Zero + } + } + + private fun Float.toOffset() = + Offset( + x = 0f, + y = this, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt new file mode 100644 index 00000000..3cdf12fe --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt @@ -0,0 +1,75 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Velocity + +class PlaceListBottomSheetState( + val state: AnchoredDraggableState, +) { + val anchors get() = state.anchors + val settledValue get() = state.settledValue + + val currentValue get() = state.currentValue + val offset get() = state.offset + + suspend fun update(newState: PlaceListBottomSheetValue) { + state.animateTo(newState) + } + + /** + anchoredState의 기본 settle() 동작은 거리 기반으로 동작합니다. + 거리 기반 동작을, 상태 기반으로 동작하도록 변경하여, 미세한 드래그에도 바텀시트가 펼쳐지도록 합니다. + */ + suspend fun settleImmediately( + available: Velocity, + animationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) { + val targetState = + if (available.y < 0) { + when (state.currentValue) { + PlaceListBottomSheetValue.EXPANDED -> state.currentValue + PlaceListBottomSheetValue.HALF_EXPANDED -> PlaceListBottomSheetValue.EXPANDED + PlaceListBottomSheetValue.COLLAPSED -> PlaceListBottomSheetValue.HALF_EXPANDED + } + } else if (available.y > 0) { + when (state.currentValue) { + PlaceListBottomSheetValue.EXPANDED -> PlaceListBottomSheetValue.HALF_EXPANDED + PlaceListBottomSheetValue.HALF_EXPANDED -> PlaceListBottomSheetValue.COLLAPSED + PlaceListBottomSheetValue.COLLAPSED -> state.currentValue + } + } else { + state.currentValue + } + + state.animateTo( + targetValue = targetState, + animationSpec = animationSpec, + ) + } +} + +enum class PlaceListBottomSheetValue { + EXPANDED, + HALF_EXPANDED, + COLLAPSED, +} + +@Composable +fun rememberPlaceListBottomSheetState( + initialState: PlaceListBottomSheetValue = PlaceListBottomSheetValue.HALF_EXPANDED, +): PlaceListBottomSheetState { + val anchoredState = + remember { + AnchoredDraggableState(initialValue = initialState) + } + + return remember(anchoredState) { + PlaceListBottomSheetState(anchoredState) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt new file mode 100644 index 00000000..6b32fb17 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -0,0 +1,307 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.launch + +@Composable +fun PlaceListScreen( + placesUiState: ListLoadState>, + modifier: Modifier = Modifier, + map: NaverMap? = null, + isExceededMaxLength: Boolean = false, + bottomSheetState: PlaceListBottomSheetState = + rememberPlaceListBottomSheetState( + PlaceListBottomSheetValue.HALF_EXPANDED, + ), + onPlaceClick: (place: PlaceUiModel) -> Unit = {}, + onPlaceLoadFinish: (places: List) -> Unit = {}, + onPlaceLoad: suspend () -> Unit = {}, + onBackToInitialPositionClick: () -> Unit = {}, +) { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + var offset by remember { mutableFloatStateOf(0f) } + val currentOnPlaceLoad by rememberUpdatedState(onPlaceLoad) + val currentOnPlaceLoadFinish by rememberUpdatedState(onPlaceLoadFinish) + + LaunchedEffect(placesUiState) { + when (placesUiState) { + is ListLoadState.PlaceLoaded -> launch { currentOnPlaceLoad() } + is ListLoadState.Success -> currentOnPlaceLoadFinish(placesUiState.value) + else -> Unit + } + } + + Box(modifier = modifier.fillMaxSize()) { + if (bottomSheetState.currentValue != PlaceListBottomSheetValue.EXPANDED) { + OffsetDependentLayout( + modifier = + Modifier + .padding(horizontal = festabookSpacing.paddingBody1), + offset = offset, + ) { + Box { + CurrentLocationButton( + map = map, + ) + if (isExceededMaxLength) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + BackToPositionButton( + text = stringResource(R.string.map_back_to_initial_position), + onClick = onBackToInitialPositionClick, + ) + } + } + } + } + } + + PlaceListBottomSheet( + peekHeight = festabookSpacing.placeListBottomSheetPeekHeight, + halfExpandedRatio = festabookSpacing.placeListBottomSheetHalfRatio, + onStateUpdate = { + if (listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } + } + }, + onScroll = { offset = it }, + bottomSheetState = bottomSheetState, + dragHandle = { + Text( + text = stringResource(R.string.place_list_title), + style = MaterialTheme.typography.displayLarge, + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + bottom = festabookSpacing.paddingBody1, + ).padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + }, + ) { + when (placesUiState) { + is ListLoadState.Loading -> + LoadingStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + + is ListLoadState.Error -> { + EmptyStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + } + + is ListLoadState.Success -> { + if (placesUiState.value.isEmpty()) { + EmptyStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + } else { + PlaceListContent( + places = placesUiState.value, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + listState = listState, + onPlaceClick = onPlaceClick, + ) + } + } + + is ListLoadState.PlaceLoaded -> Unit + } + } + } +} + +@Composable +private fun PlaceListContent( + places: List, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + onPlaceClick: (PlaceUiModel) -> Unit = {}, +) { + LazyColumn( + state = listState, + modifier = modifier.fillMaxHeight(), + ) { + items( + items = places, + key = { place -> place.id }, + ) { place -> + PlaceListItem( + place = place, + onPlaceClick = onPlaceClick, + ) + } + } +} + +@Composable +private fun PlaceListItem( + place: PlaceUiModel, + modifier: Modifier = Modifier, + onPlaceClick: (PlaceUiModel) -> Unit = {}, +) { + Column( + modifier = + modifier + .padding(bottom = festabookSpacing.paddingBody3) + .clickable( + onClick = { onPlaceClick(place) }, + interactionSource = null, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CoilImage( + url = place.imageUrl ?: "", + contentDescription = stringResource(R.string.content_description_booth_image), + modifier = + Modifier + .size(festabookSpacing.placeListImageSize) + .clip(festabookShapes.radius2), + ) + PlaceListItemContent( + modifier = + Modifier + .padding(start = festabookSpacing.paddingBody3) + .weight(1f), + place = place, + ) + } + HorizontalDivider( + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + ), + ) + } +} + +private val HALF_EXPANDED_OFFSET = (-200).dp + +@Composable +private fun PlaceListItemContent( + place: PlaceUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + PlaceCategoryLabel( + category = place.category, + ) + Text( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + text = place.title ?: stringResource(R.string.place_list_default_title), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.padding(top = 2.dp), + text = + place.description + ?: stringResource(R.string.place_list_default_description), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(14.dp), + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + place.location + ?: stringResource(R.string.place_list_default_location), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +private fun PlaceListScreenPreview() { + FestabookTheme { + PlaceListScreen( + placesUiState = + ListLoadState.Success( + (0..100).map { + PlaceUiModel( + id = it.toLong(), + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), + ) + }, + ), + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt new file mode 100644 index 00000000..c2b70acc --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -0,0 +1,250 @@ +package com.daedan.festabook.presentation.placeMap.component + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.ImageResult +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.convertImageUrl +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapSideEffectHandler +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing +import com.naver.maps.map.util.FusedLocationSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +@Composable +@Suppress("ktlint:compose:vm-forwarding-check") +fun PlaceMapRoute( + placeMapViewModel: PlaceMapViewModel, + onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, + locationSource: FusedLocationSource, + logger: DefaultFirebaseLogger, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() + val density = LocalDensity.current + val bottomSheetState = rememberPlaceListBottomSheetState() + val mapDelegate = remember { MapDelegate() } + val mapManagerDelegate = remember { MapManagerDelegate() } + + val mapControlSideEffectHandler = + remember(placeMapViewModel, logger, locationSource, mapDelegate, mapManagerDelegate) { + MapControlSideEffectHandler( + initialPadding = with(density) { 254.dp.toPx() }.toInt(), + logger = logger, + locationSource = locationSource, + viewModel = placeMapViewModel, + mapDelegate = mapDelegate, + mapManagerDelegate = mapManagerDelegate, + ) + } + val placeMapSideEffectHandler = + remember(placeMapViewModel, logger, mapManagerDelegate) { + PlaceMapSideEffectHandler( + mapManagerDelegate = mapManagerDelegate, + bottomSheetState = bottomSheetState, + viewModel = placeMapViewModel, + logger = logger, + onStartPlaceDetail = onStartPlaceDetail, + onPreloadImages = { + preloadImages( + context = context, + scope = scope, + places = it.places, + ) + }, + onShowErrorSnackBar = { onShowErrorSnackBar(it.error.throwable) }, + ) + } + + ObserveAsEvents(flow = placeMapViewModel.mapControlSideEffect) { event -> + mapControlSideEffectHandler(event) + } + + ObserveAsEvents(flow = placeMapViewModel.placeMapSideEffect) { event -> + placeMapSideEffectHandler(event) + } + + PlaceMapScreen( + uiState = uiState, + modifier = modifier, + onEvent = { placeMapViewModel.onPlaceMapEvent(it) }, + bottomSheetState = bottomSheetState, + mapDelegate = mapDelegate, + ) +} + +@Composable +fun PlaceMapScreen( + uiState: PlaceMapUiState, + onEvent: (PlaceMapEvent) -> Unit, + bottomSheetState: PlaceListBottomSheetState, + mapDelegate: MapDelegate, + modifier: Modifier = Modifier, +) { + NaverMapContent( + modifier = modifier.fillMaxSize(), + mapDelegate = mapDelegate, + onMapReady = { onEvent(MapControlEvent.OnMapReady) }, + onMapDrag = { onEvent(MapControlEvent.OnMapDrag) }, + ) { naverMap -> + Column( + modifier = Modifier.wrapContentSize(), + ) { + TimeTagMenu( + timeTagsState = uiState.timeTags, + selectedTimeTagState = uiState.selectedTimeTag, + onTimeTagClick = { timeTag -> + onEvent(SelectEvent.OnTimeTagClick(timeTag)) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = festabookSpacing.timeTagHorizontalPadding), + ) + PlaceCategoryScreen( + initialCategories = uiState.initialCategories, + selectedCategories = uiState.selectedCategories, + onCategoryClick = { onEvent(FilterEvent.OnCategoryClick(it)) }, + onDisplayAllClick = { onEvent(FilterEvent.OnCategoryClick(it)) }, + ) + + Box( + modifier = Modifier.fillMaxSize(), + ) { + NaverMapLogo( + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), + ) + + PlaceListScreen( + modifier = + Modifier.alpha( + if (uiState.selectedPlace is LoadState.Empty) { + 1f + } else { + 0f + }, + ), + placesUiState = uiState.places, + map = naverMap, + onPlaceClick = { onEvent(SelectEvent.OnPlaceClick(it.id)) }, + bottomSheetState = bottomSheetState, + isExceededMaxLength = uiState.isExceededMaxLength, + onPlaceLoadFinish = { onEvent(MapControlEvent.OnPlaceLoadFinish(it)) }, + onPlaceLoad = { onEvent(FilterEvent.OnPlaceLoad) }, + onBackToInitialPositionClick = { onEvent(MapControlEvent.OnBackToInitialPositionClick) }, + ) + + if (uiState.isPlacePreviewVisible) { + PlaceDetailPreviewScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onClick = { onEvent(SelectEvent.OnPlacePreviewClick(it)) }, + onBackPress = { onEvent(SelectEvent.OnBackPress) }, + ) + } + + if (uiState.isPlaceSecondaryPreviewVisible) { + PlaceDetailPreviewSecondaryScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onBackPress = { onEvent(SelectEvent.OnBackPress) }, + ) + } + } + } + } +} + +private fun preloadImages( + context: Context, + scope: CoroutineScope, + places: List, + maxSize: Int = 20, +) { + val imageLoader = context.imageLoader + val deferredList = mutableListOf>() + scope.launch(Dispatchers.IO) { + places + .take(maxSize) + .filterNotNull() + .forEach { place -> + val deferred = + async { + val request = + ImageRequest + .Builder(context) + .data(place.imageUrl.convertImageUrl()) + .build() + + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + Timber.d("preload 실패") + }.getOrNull() + } + deferredList.add(deferred) + } + deferredList.awaitAll() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt new file mode 100644 index 00000000..495dbbe4 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt @@ -0,0 +1,67 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookShapes +import kotlinx.coroutines.launch + +@Composable +fun PreviewAnimatableBox( + visible: Boolean, + modifier: Modifier = Modifier, + backgroundColor: Color = FestabookColor.white, + borderColor: Color = FestabookColor.gray200, + shape: Shape = festabookShapes.radius5, + borderStroke: Dp = 1.dp, + content: @Composable BoxScope.() -> Unit = {}, +) { + val offsetY = remember { Animatable(120f) } + val alpha = remember { Animatable(0.3f) } + + LaunchedEffect(visible) { + if (visible) { + launch { + offsetY.animateTo( + targetValue = 0f, + animationSpec = tween(300), + ) + } + launch { + alpha.animateTo(1f, animationSpec = tween(300)) + } + } else { + // 나갈 때 애니메이션 (위에서 아래로 + 페이드아웃) + launch { offsetY.snapTo(120f) } + launch { alpha.snapTo(0.3f) } + } + } + + Box( + modifier = + modifier + .graphicsLayer { + translationY = offsetY.value + this.alpha = alpha.value + }.cardBackground( + backgroundColor = backgroundColor, + borderColor = borderColor, + shape = shape, + borderStroke = borderStroke, + ), + ) { + content() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt new file mode 100644 index 00000000..c4aa9679 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookSpacing + +val FestabookSpacing.previewVerticalPadding + get() = 20.dp + +val FestabookSpacing.timeTagHorizontalPadding + get() = 24.dp + +val FestabookSpacing.previewImageSize + get() = 88.dp + +val FestabookSpacing.placeListImageSize + get() = 80.dp + +val FestabookSpacing.placeListBottomSheetPeekHeight + get() = 70.dp + +val FestabookSpacing.placeListBottomSheetHalfRatio + get() = 0.4f + +val FestabookSpacing.timeTagButtonWidth + get() = 140.dp diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt new file mode 100644 index 00000000..5b535f8d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -0,0 +1,208 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuBoxScope +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeTagMenu( + timeTagsState: LoadState>, + selectedTimeTagState: LoadState, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + when (timeTagsState) { + is LoadState.Success -> { + if (selectedTimeTagState !is LoadState.Success) return + TimeTagContent( + title = selectedTimeTagState.value.name, + timeTags = timeTagsState.value, + modifier = modifier, + onTimeTagClick = onTimeTagClick, + ) + } + + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimeTagContent( + title: String, + timeTags: List, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + var expanded by remember { mutableStateOf(false) } + var dropdownWidth by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + Row( + modifier = modifier.fillMaxWidth(), + ) { + ExposedDropdownMenuBox( + modifier = + Modifier + .wrapContentSize() + .background(Color.Transparent), + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + TimeTagButton( + title = title, + onSizeDetermine = { dropdownWidth = it }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 0.dp, y = festabookSpacing.paddingBody2), + modifier = + Modifier + .width( + with(density) { dropdownWidth.width.toDp() }, + ).cardBackground( + backgroundColor = FestabookColor.white, + borderStroke = 2.dp, + borderColor = FestabookColor.gray300, + shape = festabookShapes.radius2, + ), + shape = festabookShapes.radius2, + ) { + timeTags.forEach { item -> + DropdownMenuItem( + text = { + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = { + scope.launch { + onTimeTagClick(item) + waitForRipple { + expanded = false + } + } + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ExposedDropdownMenuBoxScope.TimeTagButton( + title: String, + onSizeDetermine: (IntSize) -> Unit, +) { + Row( + modifier = + Modifier + .width(festabookSpacing.timeTagButtonWidth) + .onGloballyPositioned { coordinates -> + onSizeDetermine(coordinates.size) + }.menuAnchor( + type = MenuAnchorType.PrimaryNotEditable, + enabled = true, + ).height(TopAppBarDefaults.MediumAppBarCollapsedHeight) // Festabook TopAppbar Size + .background(Color.Transparent) + .clickable( + onClick = {}, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall, + ) + + Icon( + painter = painterResource(id = R.drawable.ic_chevron_down), + contentDescription = stringResource(R.string.chevron_down), + ) + } +} + +private suspend inline fun waitForRipple( + timeMillis: Long = 100, + after: () -> Unit = {}, +) { + delay(timeMillis) + after() +} + +@Composable +@Preview(showBackground = true) +private fun TimeTagMenuPreview() { + val timeTags = + listOf( + TimeTag(1, "1일차 오전"), + TimeTag(2, "오후"), + ) + var title by remember { mutableStateOf("1일차 오전") } + FestabookTheme { + TimeTagContent( + title = title, + timeTags = timeTags, + modifier = + Modifier + .background(FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter), + // Festabook Gutter + onTimeTagClick = { }, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt new file mode 100644 index 00000000..93129f0a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/FilterEvent.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel + +sealed interface FilterEvent : PlaceMapEvent { + data class OnCategoryClick( + val categories: Set, + ) : FilterEvent + + data object OnPlaceLoad : FilterEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt new file mode 100644 index 00000000..2b4364c7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface MapControlEvent : PlaceMapEvent { + data object OnMapReady : MapControlEvent + + data object OnMapDrag : MapControlEvent + + data class OnPlaceLoadFinish( + val places: List, + ) : MapControlEvent + + data object OnBackToInitialPositionClick : MapControlEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt new file mode 100644 index 00000000..f5a5c7cb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +sealed interface PlaceMapEvent diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt new file mode 100644 index 00000000..0a98f437 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/SelectEvent.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState + +sealed interface SelectEvent : PlaceMapEvent { + data class OnPlaceClick( + val placeId: Long, + ) : SelectEvent + + data class OnPlacePreviewClick( + val place: LoadState, + ) : SelectEvent + + data object UnSelectPlace : SelectEvent + + data class ExceededMaxLength( + val isExceededMaxLength: Boolean, + ) : SelectEvent + + data class OnTimeTagClick( + val timeTag: TimeTag, + ) : SelectEvent + + data object OnBackPress : SelectEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt new file mode 100644 index 00000000..149a5c79 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import kotlinx.coroutines.flow.StateFlow + +interface EventHandler { + val uiState: StateFlow + val onUpdateState: ((before: STATE) -> STATE) -> Unit + + operator fun invoke(event: ACTION) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt new file mode 100644 index 00000000..2dfb2aeb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandlerContext.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow + +data class EventHandlerContext( + val uiState: StateFlow, + val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, + val mapControlSideEffect: Channel, + val scope: CoroutineScope, + val cachedPlaces: StateFlow>, + val cachedPlaceByTimeTag: StateFlow>, + val onUpdateCachedPlace: (List) -> Unit, + val placeMapSideEffect: Channel, +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt new file mode 100644 index 00000000..cd52b1dd --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterEventHandler.kt @@ -0,0 +1,139 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.domain.model.PlaceCategory +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.event.FilterEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class FilterEventHandler( + private val context: EventHandlerContext, + private val logger: DefaultFirebaseLogger, +) : EventHandler { + override val uiState: StateFlow = context.uiState + override val onUpdateState = context.onUpdateState + + override operator fun invoke(event: FilterEvent) { + when (event) { + is FilterEvent.OnCategoryClick -> { + context.scope.launch { + uiState.await> { it.places } + unselectPlace() + updatePlacesByCategories(event.categories.toList()) + + onUpdateState.invoke { + it.copy(selectedCategories = event.categories) + } + + context.mapControlSideEffect.send(MapControlSideEffect.FilterMapByCategory(event.categories.toList())) + + logger.log( + PlaceCategoryClick( + baseLogData = logger.getBaseLogData(), + currentCategories = event.categories.joinToString(",") { it.toString() }, + ), + ) + } + } + + is FilterEvent.OnPlaceLoad -> { + context.scope.launch { + val selectedTimeTag = + uiState + .map { it.selectedTimeTag } + .distinctUntilChanged() + .first() + + when (selectedTimeTag) { + is LoadState.Success -> { + updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) + } + + is LoadState.Empty -> { + updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + } + } + } + + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + context.mapControlSideEffect.trySend(MapControlSideEffect.UnselectMarker) + } + + fun updatePlacesByTimeTag(timeTagId: Long) { + val filteredPlaces = + if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + context.cachedPlaces.value + } else { + filterPlacesByTimeTag(timeTagId) + } + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(filteredPlaces)) + } + context.onUpdateCachedPlace(filteredPlaces) + } + + private fun updatePlacesByCategories(category: List) { + if (category.isEmpty()) { + clearPlacesFilter() + return + } + + val secondaryCategories = + PlaceCategory.SECONDARY_CATEGORIES.map { + it.toUiModel() + } + val primaryCategoriesSelected = category.none { it in secondaryCategories } + + if (!primaryCategoriesSelected) { + clearPlacesFilter() + return + } + + val filteredPlaces = + context.cachedPlaceByTimeTag.value + .filter { place -> + place.category in category + } + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(filteredPlaces)) + } + } + + private fun filterPlacesByTimeTag(timeTagId: Long): List { + val filteredPlaces = + context.cachedPlaces.value.filter { place -> + place.timeTagId.contains(timeTagId) + } + return filteredPlaces + } + + private fun clearPlacesFilter() { + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(context.cachedPlaceByTimeTag.value)) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt new file mode 100644 index 00000000..ab35d2be --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt @@ -0,0 +1,69 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class MapControlEventHandler( + private val context: EventHandlerContext, + private val logger: DefaultFirebaseLogger, +) : EventHandler { + override val uiState: StateFlow = context.uiState + override val onUpdateState = context.onUpdateState + + override operator fun invoke(event: MapControlEvent) { + when (event) { + is MapControlEvent.OnMapReady -> { + context.scope.launch { + context.mapControlSideEffect.send(MapControlSideEffect.InitMap) + val setting = + uiState.await> { it.initialMapSetting } + context.mapControlSideEffect.send(MapControlSideEffect.InitMapManager(setting.value)) + } + } + + is MapControlEvent.OnPlaceLoadFinish -> + context.scope.launch { + context.placeMapSideEffect.send( + PlaceMapSideEffect.PreloadImages( + event.places, + ), + ) + } + + is MapControlEvent.OnBackToInitialPositionClick -> { + context.scope.launch { + logger.log( + PlaceBackToSchoolClick( + baseLogData = logger.getBaseLogData(), + ), + ) + context.mapControlSideEffect.send(MapControlSideEffect.BackToInitialPosition) + } + } + + is MapControlEvent.OnMapDrag -> { + context.scope.launch { + context.placeMapSideEffect.send( + PlaceMapSideEffect.MapViewDrag( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt new file mode 100644 index 00000000..da6a199e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlSideEffectHandler.kt @@ -0,0 +1,126 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.mapManager.MapManagerGraph +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager +import com.naver.maps.map.LocationSource +import dev.zacsweers.metro.createGraphFactory + +class MapControlSideEffectHandler( + private val initialPadding: Int, + private val logger: DefaultFirebaseLogger, + private val locationSource: LocationSource, + private val viewModel: PlaceMapViewModel, + private val mapDelegate: MapDelegate, + private val mapManagerDelegate: MapManagerDelegate, +) : SideEffectHandler { + private val uiState get() = viewModel.uiState.value + private val mapManager: MapManager? get() = mapManagerDelegate.value + + override suspend operator fun invoke(event: MapControlSideEffect) { + when (event) { + is MapControlSideEffect.InitMap -> { + val naverMap = mapDelegate.await() + naverMap.addOnLocationChangeListener { + logger.log( + CurrentLocationChecked( + baseLogData = logger.getBaseLogData(), + ), + ) + } + naverMap.locationSource = locationSource + } + + is MapControlSideEffect.InitMapManager -> { + val naverMap = mapDelegate.await() + if (mapManager == null) { + val graph = + createGraphFactory().create( + naverMap, + event.initialMapSetting, + viewModel, + initialPadding, + ) + mapManagerDelegate.init(graph.mapManager) + mapManager?.setupBackToInitialPosition { isExceededMaxLength -> + viewModel.onPlaceMapEvent( + SelectEvent.ExceededMaxLength(isExceededMaxLength), + ) + } + } + } + + is MapControlSideEffect.BackToInitialPosition -> { + mapManager?.moveToPosition() + } + + is MapControlSideEffect.SetMarkerByTimeTag -> { + if (event.isInitial) { + mapManager?.setupMarker(event.placeGeographies) + } + + when (val selectedTimeTag = event.selectedTimeTag) { + is LoadState.Success -> { + mapManager?.filterMarkersByTimeTag( + selectedTimeTag.value.timeTagId, + ) + } + + is LoadState.Empty -> { + mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + + is MapControlSideEffect.FilterMapByCategory -> { + val selectedCategories = event.selectedCategories + if (selectedCategories.isEmpty()) { + mapManager?.clearFilter() + } else { + mapManager?.filterMarkersByCategories(selectedCategories) + } + } + + is MapControlSideEffect.SelectMarker -> { + when (val place = event.placeDetail) { + is LoadState.Success -> { + mapManager?.selectMarker(place.value.place.id) + + val currentTimeTag = uiState.selectedTimeTag + val timeTagName = + if (currentTimeTag is LoadState.Success) { + currentTimeTag.value.name + } else { + "undefined" + } + logger.log( + PlaceMarkerClick( + baseLogData = logger.getBaseLogData(), + placeId = place.value.place.id, + timeTagName = timeTagName, + category = place.value.place.category.name, + ), + ) + } + + else -> Unit + } + } + + is MapControlSideEffect.UnselectMarker -> { + mapManager?.unselectMarker() + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt new file mode 100644 index 00000000..99d97fee --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapSideEffectHandler.kt @@ -0,0 +1,57 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager + +class PlaceMapSideEffectHandler( + private val mapManagerDelegate: MapManagerDelegate, + private val bottomSheetState: PlaceListBottomSheetState, + private val viewModel: PlaceMapViewModel, + private val logger: DefaultFirebaseLogger, + // 안드로이드 종속적인 액션은 외부에서 주입 + // TODO Compose로 전환 시, 콜백이 아닌 Compose State 주입 + private val onPreloadImages: (PlaceMapSideEffect.PreloadImages) -> Unit, + private val onStartPlaceDetail: (PlaceMapSideEffect.StartPlaceDetail) -> Unit, + private val onShowErrorSnackBar: (PlaceMapSideEffect.ShowErrorSnackBar) -> Unit, +) : SideEffectHandler { + private val mapManager: MapManager? get() = mapManagerDelegate.value + + override suspend operator fun invoke(event: PlaceMapSideEffect) { + when (event) { + is PlaceMapSideEffect.PreloadImages -> { + onPreloadImages(event) + } + + is PlaceMapSideEffect.MenuItemReClicked -> { + mapManager?.moveToPosition() + if (!event.isPreviewVisible) return + viewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) + logger.log( + PlaceMapButtonReClick( + baseLogData = logger.getBaseLogData(), + ), + ) + } + + is PlaceMapSideEffect.StartPlaceDetail -> { + onStartPlaceDetail(event) + } + + is PlaceMapSideEffect.ShowErrorSnackBar -> { + onShowErrorSnackBar(event) + } + + is PlaceMapSideEffect.MapViewDrag -> { + if (event.isPreviewVisible) return + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt new file mode 100644 index 00000000..b1ebc3ad --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectEventHandler.kt @@ -0,0 +1,149 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.MapControlSideEffect +import com.daedan.festabook.presentation.placeMap.intent.sideEffect.PlaceMapSideEffect +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick +import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick +import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class SelectEventHandler( + private val context: EventHandlerContext, + private val filterActionHandler: FilterEventHandler, + private val logger: DefaultFirebaseLogger, + private val placeDetailRepository: PlaceDetailRepository, +) : EventHandler { + override val uiState: StateFlow = context.uiState + override val onUpdateState = context.onUpdateState + + override operator fun invoke(event: SelectEvent) { + when (event) { + is SelectEvent.OnPlaceClick -> { + selectPlace(event.placeId) + } + + is SelectEvent.UnSelectPlace -> { + unselectPlace() + } + + is SelectEvent.ExceededMaxLength -> { + onUpdateState.invoke { + it.copy( + isExceededMaxLength = event.isExceededMaxLength, + ) + } + } + + is SelectEvent.OnTimeTagClick -> { + onDaySelected(event.timeTag) + filterActionHandler.updatePlacesByTimeTag(event.timeTag.timeTagId) + logger.log( + PlaceTimeTagSelected( + baseLogData = logger.getBaseLogData(), + timeTagName = event.timeTag.name, + ), + ) + } + + is SelectEvent.OnPlacePreviewClick -> { + val selectedTimeTag = uiState.value.selectedTimeTag + val selectedPlace = event.place + if (selectedPlace is LoadState.Success) { + context.scope.launch { + context.placeMapSideEffect.send(PlaceMapSideEffect.StartPlaceDetail(event.place)) + logger.log( + PlacePreviewClick( + baseLogData = logger.getBaseLogData(), + placeName = + selectedPlace.value.place.title + ?: "undefined", + timeTag = + (selectedTimeTag as? LoadState.Success) + ?.value + ?.name ?: "undefined", + category = selectedPlace.value.place.category.name, + ), + ) + } + } + } + + is SelectEvent.OnBackPress -> { + unselectPlace() + } + } + } + + private fun selectPlace(placeId: Long) { + context.scope.launch { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Loading) } + placeDetailRepository + .getPlaceDetail(placeId = placeId) + .onSuccess { item -> + val newSelectedPlace = LoadState.Success(item.toUiModel()) + + onUpdateState.invoke { + it.copy(selectedPlace = newSelectedPlace) + } + context.mapControlSideEffect.send( + MapControlSideEffect.SelectMarker( + newSelectedPlace, + ), + ) + val selectedTimeTag = uiState.value.selectedTimeTag + val timeTagName = + if (selectedTimeTag is LoadState.Success) selectedTimeTag.value.name else "undefined" + logger.log( + PlaceItemClick( + baseLogData = logger.getBaseLogData(), + placeId = placeId, + timeTagName = timeTagName, + category = item.place.category.name, + ), + ) + }.onFailure { item -> + onUpdateState.invoke { + it.copy(selectedPlace = LoadState.Error(item)) + } + } + } + } + + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + context.mapControlSideEffect.trySend(MapControlSideEffect.UnselectMarker) + } + + private fun onDaySelected(item: TimeTag) { + unselectPlace() + onUpdateState.invoke { + it.copy(selectedTimeTag = LoadState.Success(item)) + } + context.scope.launch { + val placeGeographies = + uiState.await>> { it.placeGeographies } + context.mapControlSideEffect.send( + MapControlSideEffect.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = LoadState.Success(item), + isInitial = false, + ), + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt new file mode 100644 index 00000000..6aa77182 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SideEffectHandler.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +interface SideEffectHandler { + suspend operator fun invoke(event: EVENT) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt new file mode 100644 index 00000000..6ec25e0a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/MapControlSideEffect.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.placeMap.intent.sideEffect + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel + +sealed interface MapControlSideEffect { + data object InitMap : MapControlSideEffect + + data class InitMapManager( + val initialMapSetting: InitialMapSettingUiModel, + ) : MapControlSideEffect + + data object BackToInitialPosition : MapControlSideEffect + + data class SetMarkerByTimeTag( + val placeGeographies: List, + val selectedTimeTag: LoadState, + val isInitial: Boolean, + ) : MapControlSideEffect + + data class FilterMapByCategory( + val selectedCategories: List, + ) : MapControlSideEffect + + data class SelectMarker( + val placeDetail: LoadState, + ) : MapControlSideEffect + + data object UnselectMarker : MapControlSideEffect +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt new file mode 100644 index 00000000..bf630d3a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/sideEffect/PlaceMapSideEffect.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.placeMap.intent.sideEffect + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface PlaceMapSideEffect { + data class StartPlaceDetail( + val placeDetail: LoadState.Success, + ) : PlaceMapSideEffect + + data class PreloadImages( + val places: List, + ) : PlaceMapSideEffect + + data class ShowErrorSnackBar( + val error: LoadState.Error, + ) : PlaceMapSideEffect + + data class MenuItemReClicked( + val isPreviewVisible: Boolean, + ) : PlaceMapSideEffect + + data class MapViewDrag( + val isPreviewVisible: Boolean, + ) : PlaceMapSideEffect +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt new file mode 100644 index 00000000..1c305b1e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface ListLoadState { + data object Loading : ListLoadState + + data class Success( + val value: T, + ) : ListLoadState + + data class PlaceLoaded( + val value: List, + ) : ListLoadState> + + data class Error( + val throwable: Throwable, + ) : ListLoadState +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt new file mode 100644 index 00000000..a29a9081 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel + +sealed interface LoadState { + data object Loading : LoadState + + data object Empty : LoadState + + data class Success( + val value: T, + ) : LoadState + + data class Error( + val throwable: Throwable, + ) : LoadState +} + +val LoadState.Success.isSecondary get() = value.place.category in PlaceCategoryUiModel.Companion.SECONDARY_CATEGORIES diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt new file mode 100644 index 00000000..051f4b98 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt @@ -0,0 +1,30 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class MapDelegate { + var value: NaverMap? by mutableStateOf(null) + private set + + fun initMap(map: NaverMap) { + value = map + } + + suspend fun await(timeout: Duration = 3.seconds): NaverMap = + withTimeout(timeout) { + snapshotFlow { value } + .distinctUntilChanged() + .filterNotNull() + .first() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt new file mode 100644 index 00000000..947ee52e --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager + +class MapManagerDelegate { + var value: MapManager? by mutableStateOf(null) + private set + + fun init(manager: MapManager) { + value = manager + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt new file mode 100644 index 00000000..95b9d657 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt @@ -0,0 +1,38 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +data class PlaceMapUiState( + val initialMapSetting: LoadState = LoadState.Loading, + val placeGeographies: LoadState> = LoadState.Loading, + val timeTags: LoadState> = LoadState.Empty, + val selectedTimeTag: LoadState = LoadState.Empty, + val selectedPlace: LoadState = LoadState.Empty, + val places: ListLoadState> = ListLoadState.Loading, + val isExceededMaxLength: Boolean = false, + val selectedCategories: Set = emptySet(), + val initialCategories: List = PlaceCategoryUiModel.entries, +) { + val isPlacePreviewVisible: Boolean = + (selectedPlace is LoadState.Success && !selectedPlace.isSecondary) + + val isPlaceSecondaryPreviewVisible: Boolean = + (selectedPlace is LoadState.Success && selectedPlace.isSecondary) + + val hasAnyError: LoadState<*>? + get() = + listOf( + initialMapSetting, + placeGeographies, + timeTags, + selectedTimeTag, + selectedPlace, + if (places is ListLoadState.Error) LoadState.Error(places.throwable) else LoadState.Empty, + ).filterIsInstance() + .firstOrNull() +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt new file mode 100644 index 00000000..74cdbf20 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt @@ -0,0 +1,31 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +suspend inline fun StateFlow.await( + timeout: Duration = 3.seconds, + onTimeout: (Throwable) -> Unit = {}, + crossinline selector: (PlaceMapUiState) -> Any?, +): R = + try { + withTimeout(timeout) { + this@await + .map { selector(it) } + .distinctUntilChanged() + .filterIsInstance() + .first() + } + } catch (e: TimeoutCancellationException) { + onTimeout(e) + throw e + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt similarity index 80% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt index d7c1de71..534af28a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt similarity index 58% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt index 2e902eb3..15cfa3c9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt @@ -1,5 +1,7 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.event.SelectEvent import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import timber.log.Timber @@ -11,12 +13,14 @@ class MapClickListenerImpl( category: PlaceCategoryUiModel, ): Boolean { Timber.d("Marker CLick : placeID: $placeId categoty: $category") - viewModel.selectPlace(placeId) + viewModel.onPlaceMapEvent( + SelectEvent.OnPlaceClick(placeId), + ) return true } override fun onMapClickListener() { Timber.d("Map CLick") - viewModel.unselectPlace() + viewModel.onPlaceMapEvent(SelectEvent.UnSelectPlace) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt similarity index 61% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt index 6f8c2a5d..a953f3e7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener fun interface OnCameraChangeListener { fun onCameraChanged(isExceededMaxLength: Boolean) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt index 5ff9986e..426f99c6 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt @@ -1,6 +1,6 @@ package com.daedan.festabook.presentation.placeMap.mapManager -import com.daedan.festabook.presentation.placeMap.OnCameraChangeListener +import com.daedan.festabook.presentation.placeMap.listener.OnCameraChangeListener import com.naver.maps.geometry.LatLng interface MapCameraManager { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt index c62a9065..ddccdc17 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapFilterManager.kt @@ -20,7 +20,7 @@ interface MapFilterManager { * * @param selectedTimeTagId 필터링에 사용할 시간 태그의 ID입니다. null 또는 특정 ID가 될 수 있습니다. */ - fun filterMarkersByTimeTag(selectedTimeTagId: Long?) + fun filterMarkersByTimeTag(selectedTimeTagId: Long) /** * 모든 필터링 조건을 해제하고 마커를 초기 상태로 복원합니다. diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt index 3a9b73ff..6b81594f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt @@ -4,7 +4,7 @@ import androidx.core.content.ContextCompat import com.daedan.festabook.BuildConfig import com.daedan.festabook.R import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.placeMap.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener import com.daedan.festabook.presentation.placeMap.model.CoordinateUiModel import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.toLatLng diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt index a55ca399..92807e2b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.mapManager.internal import com.daedan.festabook.di.mapManager.PlaceMapScope -import com.daedan.festabook.presentation.placeMap.OnCameraChangeListener +import com.daedan.festabook.presentation.placeMap.listener.OnCameraChangeListener import com.daedan.festabook.presentation.placeMap.mapManager.MapCameraManager import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.toLatLng diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt index d934fbd9..18435685 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt @@ -20,25 +20,32 @@ class MapFilterManagerImpl( ) : MapFilterManager { private var selectedMarker: Marker? = null - private var selectedTimeTagId: Long? = null + private var selectedTimeTagId: Long = TimeTag.EMTPY_TIME_TAG_ID override fun filterMarkersByCategories(categories: List) { markers.forEach { marker -> val place = marker.tag as? PlaceCoordinateUiModel ?: return@forEach val isSelectedMarker = marker == selectedMarker - // 필터링된 마커이거나 선택된 마커인 경우에만 보이게 처리 - marker.isVisible = - place.category in categories && - place.timeTagIds.contains(selectedTimeTagId) || - isSelectedMarker + // 필터링된 마커이거나 선택된 마커인 경우에만 보이게 처리, + // 타임태그가 없다면, 타임태그 검사 생략 + if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + marker.isVisible = + place.category in categories || + isSelectedMarker + } else { + marker.isVisible = + place.category in categories && + place.timeTagIds.contains(selectedTimeTagId) || + isSelectedMarker + } // 선택된 마커는 크기를 유지하고, 필터링되지 않은 마커는 원래 크기로 되돌림 markerManager.setMarkerIcon(marker, isSelectedMarker) } } - override fun filterMarkersByTimeTag(selectedTimeTagId: Long?) { + override fun filterMarkersByTimeTag(selectedTimeTagId: Long) { if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { markers.forEach { it.isVisible = true } return @@ -57,7 +64,12 @@ class MapFilterManagerImpl( override fun clearFilter() { markers.forEach { marker -> val place = marker.tag as? PlaceCoordinateUiModel ?: return@forEach - marker.isVisible = place.timeTagIds.contains(selectedTimeTagId) + // 타임태그가 없다면, 타임태그 검사 생략 + if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + marker.isVisible = true + } else { + marker.isVisible = place.timeTagIds.contains(selectedTimeTagId) + } val isSelectedMarker = marker == selectedMarker diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt index 8a7e2d12..597fd4cc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.mapManager.internal import com.daedan.festabook.di.mapManager.PlaceMapScope -import com.daedan.festabook.presentation.placeMap.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener import com.daedan.festabook.presentation.placeMap.mapManager.MapCameraManager import com.daedan.festabook.presentation.placeMap.mapManager.MapMarkerManager import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt index bad82c67..064b092d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeMap.model +import androidx.compose.ui.graphics.Color import com.daedan.festabook.R import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.presentation.placeMap.mapManager.internal.OverlayImageManager @@ -9,14 +10,18 @@ enum class PlaceCategoryUiModel { FOOD_TRUCK, BOOTH, BAR, - TRASH_CAN, - TOILET, - SMOKING_AREA, - PRIMARY, - PARKING, + STAGE, PHOTO_BOOTH, + PRIMARY, + EXTRA, + PARKING, + TOILET, + + SMOKING_AREA, + + TRASH_CAN, ; companion object { @@ -61,6 +66,14 @@ val PlaceCategoryUiModel.Companion.iconResources: List R.drawable.ic_extra_selected, ) +fun PlaceCategoryUiModel.getLabelColor() = + when (this) { + PlaceCategoryUiModel.BOOTH -> Color(0xFF0094FF) + PlaceCategoryUiModel.FOOD_TRUCK -> Color(0xFF00AB40) + PlaceCategoryUiModel.BAR -> Color(0xFFFF9D00) + else -> Color.Unspecified + } + fun OverlayImageManager.getNormalIcon(category: PlaceCategoryUiModel): OverlayImage? = when (category) { PlaceCategoryUiModel.BOOTH -> getImage(R.drawable.ic_booth) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt deleted file mode 100644 index 96740048..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -sealed interface PlaceListUiState { - class Loading : PlaceListUiState - - data class Success( - val value: T, - ) : PlaceListUiState - - data class PlaceLoaded( - val value: List, - ) : PlaceListUiState> - - data class Error( - val throwable: Throwable, - ) : PlaceListUiState - - class Complete : PlaceListUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt index deff0d7f..608d67a8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiModel.kt @@ -3,8 +3,10 @@ package com.daedan.festabook.presentation.placeMap.model import android.os.Parcelable import com.daedan.festabook.domain.model.Place import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Parcelize +@Serializable data class PlaceUiModel( val id: Long, val imageUrl: String?, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt deleted file mode 100644 index fbb9bc6e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel - -sealed interface SelectedPlaceUiState { - data object Loading : SelectedPlaceUiState - - data object Empty : SelectedPlaceUiState - - data class Success( - val value: PlaceDetailUiModel, - ) : SelectedPlaceUiState { - val isSecondary = value.place.category in PlaceCategoryUiModel.SECONDARY_CATEGORIES - } - - data class Error( - val throwable: Throwable, - ) : SelectedPlaceUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt new file mode 100644 index 00000000..fac91a7b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/navigation/PlaceMapNavigation.kt @@ -0,0 +1,91 @@ +package com.daedan.festabook.presentation.placeMap.navigation + +import android.net.Uri +import android.os.Bundle +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel +import com.daedan.festabook.presentation.placeDetail.component.PlaceDetailRoute +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import kotlinx.serialization.json.Json +import kotlin.reflect.typeOf + +fun NavGraphBuilder.placeMapNavGraph( + onBackToPreviousClick: () -> Unit, + placeDetailViewModelFactory: PlaceDetailViewModel.Factory, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + } + + composable( + typeMap = + mapOf( + typeOf() to defaultNavType(), + typeOf() to defaultNavType(), + ), + enterTransition = { + slideInVertically(initialOffsetY = { it / 10 }) + fadeIn() + }, + exitTransition = { + slideOutVertically(targetOffsetY = { it / 10 }) + fadeOut() + }, + ) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel = + viewModel( + factory = + PlaceDetailViewModel.factory( + placeDetailViewModelFactory, + route.placeUiModel, + route.placeDetailUiModel, + ), + ) + PlaceDetailRoute( + modifier = + Modifier.graphicsLayer( + compositingStrategy = CompositingStrategy.Offscreen, + clip = true, + ), + viewModel = viewModel, + onBackToPreviousClick = onBackToPreviousClick, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} + +// TODO UIModel에서 Parcelable 제거 및 CMP에 맞게 안드로이드 의존성 제거 + +private inline fun defaultNavType() = + object : NavType(isNullableAllowed = true) { + override fun get( + bundle: Bundle, + key: String, + ): T? = bundle.getString(key)?.let { Json.decodeFromString(it) } + + override fun parseValue(value: String): T = + Json.decodeFromString( + Uri.decode(value), + ) + + override fun put( + bundle: Bundle, + key: String, + value: T, + ) = bundle.putString(key, Json.encodeToString(value)) + + override fun serializeAsValue(value: T): String = Uri.encode(Json.encodeToString(value)) + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt deleted file mode 100644 index 44db4b87..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeCategory - -import android.os.Bundle -import android.view.View -import androidx.core.view.children -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceCategoryBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.google.android.material.chip.Chip -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceCategoryFragment::class) -@Inject -class PlaceCategoryFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_place_category - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpBinding() - } - - private fun setUpBinding() { - binding.cgCategories.setOnCheckedStateChangeListener { group, checkedIds -> - val selectedCategories = - checkedIds.mapNotNull { - val category = group.findViewById(it).tag - category as? PlaceCategoryUiModel - } - - viewModel.unselectPlace() - viewModel.setSelectedCategories(selectedCategories) - binding.chipCategoryAll.isChecked = selectedCategories.isEmpty() - binding.logger.log( - PlaceCategoryClick( - baseLogData = binding.logger.getBaseLogData(), - currentCategories = selectedCategories.joinToString(",") { it.toString() }, - ), - ) - } - - setUpChipCategoryAllListener() - } - - private fun setUpChipCategoryAllListener() { - binding.chipCategoryAll.setOnClickListener { - binding.cgCategories.children.forEach { - val chip = (it as? Chip) ?: return@forEach - chip.isChecked = chip.id == binding.chipCategoryAll.id - } - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt deleted file mode 100644 index 474ca061..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview - -import android.os.Bundle -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.loadImage -import com.daedan.festabook.presentation.common.setFormatDate -import com.daedan.festabook.presentation.common.showBottomAnimation -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceDetailPreviewFragment::class) -@Inject -class PlaceDetailPreviewFragment : - BaseFragment(), - OnMenuItemReClickListener { - override val layoutId: Int = R.layout.fragment_place_detail_preview - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val backPressedCallback = - object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - viewModel.unselectPlace() - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpObserver() - setupBinding() - setUpBackPressedCallback() - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } - - private fun setUpBackPressedCallback() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - backPressedCallback, - ) - } - - private fun setupBinding() { - binding.layoutSelectedPlace.setOnClickListener { - val selectedPlaceState = viewModel.selectedPlace.value - if (selectedPlaceState is SelectedPlaceUiState.Success) { - startPlaceDetailActivity(selectedPlaceState.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = selectedPlaceState.value.place.title ?: "undefined", - timeTag = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlaceState.value.place.category.name, - ), - ) - } - } - } - - private fun setUpObserver() { - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - backPressedCallback.isEnabled = true - binding.layoutSelectedPlace.visibility = - if (selectedPlace == SelectedPlaceUiState.Empty) View.GONE else View.VISIBLE - - when (selectedPlace) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Success -> { - binding.layoutSelectedPlace.showBottomAnimation() - updateSelectedPlaceUi(selectedPlace.value) - } - - is SelectedPlaceUiState.Error -> showErrorSnackBar(selectedPlace.throwable) - is SelectedPlaceUiState.Empty -> backPressedCallback.isEnabled = false - } - } - } - - private fun updateSelectedPlaceUi(selectedPlace: PlaceDetailUiModel) { - with(binding) { - layoutSelectedPlace.visibility = View.VISIBLE - tvSelectedPlaceTitle.text = - selectedPlace.place.title ?: getString(R.string.place_list_default_title) - tvSelectedPlaceLocation.text = - selectedPlace.place.location ?: getString(R.string.place_list_default_location) - setFormatDate( - binding.tvSelectedPlaceTime, - selectedPlace.startTime, - selectedPlace.endTime, - ) - tvSelectedPlaceHost.text = - selectedPlace.host ?: getString(R.string.place_detail_default_host) - tvSelectedPlaceDescription.text = selectedPlace.place.description - ?: getString(R.string.place_list_default_description) - cvPlaceCategory.setCategory(selectedPlace.place.category) - ivSelectedPlaceImage.loadImage(selectedPlace.featuredImage) - } - } - - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { - startActivity(PlaceDetailActivity.newIntent(requireContext(), placeDetail)) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt deleted file mode 100644 index 58fb7cca..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview - -import android.os.Bundle -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import coil3.load -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewSecondaryBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showBottomAnimation -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.model.getIconId -import com.daedan.festabook.presentation.placeMap.model.getTextId -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceDetailPreviewSecondaryFragment::class) -@Inject -class PlaceDetailPreviewSecondaryFragment : - BaseFragment(), - OnMenuItemReClickListener { - override val layoutId: Int = R.layout.fragment_place_detail_preview_secondary - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val backPressedCallback = - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - viewModel.unselectPlace() - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpObserver() - setUpBackPressedCallback() - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } - - private fun setUpBackPressedCallback() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - backPressedCallback, - ) - } - - private fun setUpObserver() { - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - backPressedCallback.isEnabled = true - when (selectedPlace) { - is SelectedPlaceUiState.Success -> { - binding.layoutSelectedPlace.visibility = View.VISIBLE - binding.layoutSelectedPlace.showBottomAnimation() - updateSelectedPlaceUi(selectedPlace.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = selectedPlace.value.place.title ?: "undefined", - timeTag = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) - } - - is SelectedPlaceUiState.Error -> showErrorSnackBar(selectedPlace.throwable) - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Empty -> backPressedCallback.isEnabled = false - } - } - } - - private fun updateSelectedPlaceUi(selectedPlace: PlaceDetailUiModel) { - with(binding) { - ivSecondaryCategoryItem.load(selectedPlace.place.category.getIconId()) - tvSelectedPlaceTitle.text = - selectedPlace.place.title ?: getString(selectedPlace.place.category.getTextId()) - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt deleted file mode 100644 index 9ab3aaf8..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel - -fun interface OnPlaceClickListener { - fun onPlaceClicked(place: PlaceUiModel) -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt deleted file mode 100644 index 6dd7cd47..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import android.view.View -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.google.android.material.bottomsheet.BottomSheetBehavior -import timber.log.Timber - -class PlaceListBottomSheetCallback( - private val viewModel: PlaceMapViewModel, -) : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - when (newState) { - BottomSheetBehavior.STATE_DRAGGING -> { - Timber.d("STATE_DRAGGING") - viewModel.onExpandedStateReached() - } - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) { - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt deleted file mode 100644 index 8c17bcf2..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ /dev/null @@ -1,284 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat -import androidx.core.view.isGone -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DefaultItemAnimator -import coil3.ImageLoader -import coil3.asImage -import coil3.request.ImageRequest -import coil3.request.ImageResult -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceListBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.placeListBottomSheetFollowBehavior -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.adapter.PlaceListAdapter -import com.daedan.festabook.presentation.placeMap.placeList.behavior.BottomSheetFollowCallback -import com.daedan.festabook.presentation.placeMap.placeList.behavior.MoveToInitialPositionCallback -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.naver.maps.map.NaverMap -import com.naver.maps.map.OnMapReadyCallback -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import timber.log.Timber - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceListFragment::class) -@Inject -class PlaceListFragment : - BaseFragment(), - OnPlaceClickListener, - OnMenuItemReClickListener, - OnMapReadyCallback { - override val layoutId: Int = R.layout.fragment_place_list - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val childViewModel: PlaceListViewModel by viewModels() - - private val placeAdapter by lazy { - PlaceListAdapter(this) - } - - private val placeListBottomSheetBehavior by lazy { - val params = binding.layoutPlaceList.layoutParams as? CoordinatorLayout.LayoutParams - params?.behavior as? PlaceListBottomSheetBehavior - } - - private lateinit var moveToInitialPositionCallback: MoveToInitialPositionCallback - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - setUpPlaceAdapter() - setBehaviorCallback() - setUpObserver() - setUpBinding() - } - } - - override fun onPlaceClicked(place: PlaceUiModel) { - Timber.d("onPlaceClicked: $place") - startPlaceDetailActivity(place) - binding.logger.log( - PlaceItemClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefinded", - category = place.category.name, - ), - ) - } - - override fun onMenuItemReClick() { - if (binding.root.isGone || !isResumed || view == null) return - val layoutParams = binding.layoutPlaceList.layoutParams as? CoordinatorLayout.LayoutParams - val behavior = layoutParams?.behavior as? BottomSheetBehavior - behavior?.state = BottomSheetBehavior.STATE_HALF_EXPANDED - binding.logger.log( - PlaceMapButtonReClick( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - - override fun onMapReady(naverMap: NaverMap) { - binding.lbvCurrentLocation.map = naverMap - } - - private fun setUpPlaceAdapter() { - binding.rvPlaces.adapter = placeAdapter - (binding.rvPlaces.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false - } - - private fun setUpObserver() { - childViewModel.places.observe(viewLifecycleOwner) { places -> - when (places) { - is PlaceListUiState.Loading -> showSkeleton() - is PlaceListUiState.Success -> { - preloadImages( - requireContext(), - places.value, - ) - placeAdapter.submitList(places.value) { - if (places.value.isEmpty()) { - binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE - } - binding.rvPlaces.scrollToPosition(0) - } - } - - is PlaceListUiState.PlaceLoaded -> { - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { timeTag -> - childViewModel.updatePlacesByTimeTag(timeTag.timeTagId) - } - } - - is PlaceListUiState.Complete -> { - hideSkeleton() - } - - is PlaceListUiState.Error -> { - hideSkeleton() - binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE - Timber.w(places.throwable, "PlaceListFragment: ${places.throwable.message}") - showErrorSnackBar(places.throwable) - } - } - } - - viewModel.navigateToDetail.observe(viewLifecycleOwner) { selectedPlace -> - startPlaceDetailActivity(selectedPlace) - } - - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - childViewModel.clearPlacesFilter() - } else { - childViewModel.updatePlacesByCategories(selectedCategories) - } - } - - viewModel.isExceededMaxLength.observe(viewLifecycleOwner) { isExceededMaxLength -> - moveToInitialPositionCallback.setIsExceededMaxLength(isExceededMaxLength) - binding.chipBackToInitialPosition.visibility = - if (isExceededMaxLength) View.VISIBLE else View.GONE - } - - viewModel.onMapViewClick.observe(viewLifecycleOwner) { - placeListBottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED - } - } - - private fun setUpBinding() { - binding.chipBackToInitialPosition.setOnClickListener { - viewModel.onBackToInitialPositionClicked() - binding.logger.log( - PlaceBackToSchoolClick( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - binding.rvPlaces.itemAnimator = null - } - - private fun setBehaviorCallback() { - moveToInitialPositionCallback = - MoveToInitialPositionCallback(binding.chipBackToInitialPosition.id) - - binding.lbvCurrentLocation - .placeListBottomSheetFollowBehavior() - ?.setCallback( - BottomSheetFollowCallback(binding.lbvCurrentLocation.id), - ) - - binding.chipBackToInitialPosition - .placeListBottomSheetFollowBehavior() - ?.setCallback(moveToInitialPositionCallback) - } - - private fun startPlaceDetailActivity(place: PlaceUiModel) { - viewModel.selectPlace(place.id) - } - - private fun showSkeleton() { - binding.tvErrorToLoadPlaceInfo.visibility = View.GONE - binding.rvPlaces.visibility = View.GONE - binding.sflScheduleSkeleton.visibility = View.VISIBLE - binding.sflScheduleSkeleton.startShimmer() - } - - private fun hideSkeleton() { - binding.rvPlaces.visibility = View.VISIBLE - binding.sflScheduleSkeleton.visibility = View.GONE - binding.sflScheduleSkeleton.stopShimmer() - } - - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { - Timber.d("start detail activity") - val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) - startActivity(intent) - } - - // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 - private fun preloadImages( - context: Context, - places: List, - maxSize: Int = 20, - ) { - val imageLoader = ImageLoader(context) - val deferredList = mutableListOf>() - val defaultImage = - ContextCompat - .getDrawable( - requireContext(), - R.drawable.img_fallback, - )?.asImage() - - lifecycleScope.launch(Dispatchers.IO) { - places - .take(maxSize) - .filterNotNull() - .forEach { place -> - val deferred = - async { - val request = - ImageRequest - .Builder(context) - .data(place.imageUrl) - .error { - defaultImage - }.fallback { - defaultImage - }.build() - - runCatching { - withTimeout(2000) { - imageLoader.execute(request) - } - }.onFailure { - imageLoader.shutdown() - }.getOrNull() - } - deferredList.add(deferred) - } - deferredList.awaitAll() - withContext(Dispatchers.Main) { - childViewModel.setPlacesStateComplete() - } - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt deleted file mode 100644 index 7704b83e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.domain.model.PlaceCategory -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.toUiModel -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import kotlinx.coroutines.launch - -@ContributesIntoMap(AppScope::class) -@ViewModelKey(PlaceListViewModel::class) -class PlaceListViewModel @Inject constructor( - private val placeListRepository: PlaceListRepository, -) : ViewModel() { - private var cachedPlaces = listOf() - private var cachedPlaceByTimeTag: List = emptyList() - - private val _places: MutableLiveData>> = - MutableLiveData(PlaceListUiState.Loading()) - val places: LiveData>> = _places - - init { - loadAllPlaces() - } - - fun updatePlacesByCategories(category: List) { - val secondaryCategories = - PlaceCategory.SECONDARY_CATEGORIES.map { - it.toUiModel() - } - val primaryCategoriesSelected = category.any { it !in secondaryCategories } - - if (!primaryCategoriesSelected) { - clearPlacesFilter() - return - } - val filteredPlaces = - cachedPlaceByTimeTag - .filter { place -> - place.category in category - } - _places.value = PlaceListUiState.Success(filteredPlaces) - } - - private fun filterPlacesByTimeTag(timeTagId: Long): List { - val filteredPlaces = - cachedPlaces.filter { place -> - place.timeTagId.contains(timeTagId) - } - return filteredPlaces - } - - fun updatePlacesByTimeTag(timeTagId: Long) { - val filteredPlaces = - if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { - cachedPlaces - } else { - filterPlacesByTimeTag(timeTagId) - } - - _places.value = PlaceListUiState.Success(filteredPlaces) - cachedPlaceByTimeTag = filteredPlaces - } - - fun clearPlacesFilter() { - _places.value = PlaceListUiState.Success(cachedPlaceByTimeTag) - } - - fun setPlacesStateComplete() { - _places.value = PlaceListUiState.Complete() - } - - private fun loadAllPlaces() { - viewModelScope.launch { - val result = placeListRepository.getPlaces() - result - .onSuccess { places -> - val placeUiModels = places.map { it.toUiModel() } - cachedPlaces = placeUiModels - _places.value = PlaceListUiState.PlaceLoaded(placeUiModels) - }.onFailure { - _places.value = PlaceListUiState.Error(it) - } - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt deleted file mode 100644 index d7f3677e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.OnPlaceClickListener - -class PlaceListAdapter( - private val handler: OnPlaceClickListener, -) : ListAdapter(DIFF_UTIL) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): PlaceViewHolder = PlaceViewHolder.from(parent, handler) - - override fun onBindViewHolder( - holder: PlaceViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - companion object { - private val DIFF_UTIL = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: PlaceUiModel, - newItem: PlaceUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: PlaceUiModel, - newItem: PlaceUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt deleted file mode 100644 index 9c3b096e..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.databinding.ItemPlaceListBinding -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.OnPlaceClickListener - -class PlaceViewHolder private constructor( - private val binding: ItemPlaceListBinding, - private val listener: OnPlaceClickListener, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(placeUiModel: PlaceUiModel) { - binding.place = placeUiModel - binding.listener = listener - } - - companion object { - fun from( - parent: ViewGroup, - listener: OnPlaceClickListener, - ): PlaceViewHolder = - PlaceViewHolder( - ItemPlaceListBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - listener, - ) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt deleted file mode 100644 index b39c4910..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.view.View -import androidx.annotation.IdRes -import com.google.android.material.bottomsheet.BottomSheetBehavior - -open class BottomSheetFollowCallback( - @IdRes private val viewId: Int, -) : BottomSheetBehavior.BottomSheetCallback() { - private lateinit var child: View - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (!::child.isInitialized) { - child = bottomSheet.rootView.findViewById(viewId) ?: return - } - - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - child.visibility = View.GONE - } else { - child.visibility = View.VISIBLE - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt deleted file mode 100644 index 5bfbcaab..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.view.View -import androidx.annotation.IdRes -import com.google.android.material.bottomsheet.BottomSheetBehavior - -class MoveToInitialPositionCallback( - @IdRes private val viewId: Int, -) : BottomSheetBehavior.BottomSheetCallback() { - private lateinit var child: View - private var isExceededMaxLength = true - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (!::child.isInitialized) { - child = bottomSheet.rootView.findViewById(viewId) ?: return - } - if (newState == BottomSheetBehavior.STATE_EXPANDED || !isExceededMaxLength) { - child.visibility = View.GONE - } else { - child.visibility = View.VISIBLE - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit - - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - this.isExceededMaxLength = isExceededMaxLength - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt deleted file mode 100644 index c1f68eb3..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.FestaBookApp -import com.daedan.festabook.R -import com.daedan.festabook.logging.DefaultFirebaseLogger -import com.daedan.festabook.presentation.placeMap.logging.PlaceListSwipeUp -import com.google.android.material.bottomsheet.BottomSheetBehavior -import dev.zacsweers.metro.Inject - -class PlaceListBottomSheetBehavior( - context: Context, - attrs: AttributeSet, -) : BottomSheetBehavior( - context, - attrs, - ) { - private lateinit var recyclerView: RecyclerView - private var headerRange: IntRange = 0..0 - - @Inject - private lateinit var logger: DefaultFirebaseLogger - - init { - (context.applicationContext as FestaBookApp).festaBookGraph.inject(this) - state = STATE_HALF_EXPANDED - isGestureInsetBottomIgnored = true - addBottomSheetCallback( - object : BottomSheetCallback() { - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (newState == STATE_HALF_EXPANDED && ::recyclerView.isInitialized) { - recyclerView.scrollToPosition(HEADER_POSITION) - } - if (newState == STATE_EXPANDED) { - logger.log( - PlaceListSwipeUp( - baseLogData = logger.getBaseLogData(), - ), - ) - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit - }, - ) - } - - override fun onLayoutChild( - parent: CoordinatorLayout, - child: V, - layoutDirection: Int, - ): Boolean { - recyclerView = child.findViewById(R.id.rv_places) - recyclerView.getChildAt(HEADER_POSITION)?.let { - headerRange = expandedOffset..(expandedOffset + it.height) - } - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onInterceptTouchEvent( - parent: CoordinatorLayout, - child: V, - event: MotionEvent, - ): Boolean { - if (event.action == MotionEvent.ACTION_DOWN && - state == STATE_EXPANDED && - event.y.toInt() in headerRange - ) { - state = STATE_COLLAPSED - } - return super.onInterceptTouchEvent(parent, child, event) - } - - override fun onNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - type: Int, - consumed: IntArray, - ) { - super.onNestedScroll( - coordinatorLayout, - child, - target, - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - type, - consumed, - ) - if (!recyclerView.canScrollVertically(-1)) { - state = STATE_HALF_EXPANDED - } - } - - override fun onStopNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - type: Int, - ) { - super.onStopNestedScroll(coordinatorLayout, child, target, type) - - if (!recyclerView.canScrollVertically(-1) && state == STATE_EXPANDED) { - state = STATE_HALF_EXPANDED - } - } - - fun setOffset(height: Int) { - expandedOffset = height - } - - companion object { - private const val HEADER_POSITION = 0 - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt deleted file mode 100644 index d0f07b31..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.BottomSheetBehavior - -class PlaceListBottomSheetFollowBehavior( - context: Context, - attrs: AttributeSet, -) : CoordinatorLayout.Behavior( - context, - attrs, - ) { - private var currentBehavior: BottomSheetBehavior<*>? = null - private var callback: BottomSheetBehavior.BottomSheetCallback? = null - - override fun layoutDependsOn( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val behavior = (dependency.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior - if (behavior is BottomSheetBehavior<*>) { - currentBehavior = behavior - } - return behavior is BottomSheetBehavior<*> - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val bottomSheetTopY = dependency.y - dependency.height - child.translationY = bottomSheetTopY - return true - } - - override fun onDetachedFromLayoutParams() { - super.onDetachedFromLayoutParams() - callback?.let { - currentBehavior?.removeBottomSheetCallback(it) - } - currentBehavior = null - } - - fun setCallback(callback: BottomSheetBehavior.BottomSheetCallback) { - this.callback?.let { - currentBehavior?.removeBottomSheetCallback(it) - } - this.callback = callback - currentBehavior?.addBottomSheetCallback(callback) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt deleted file mode 100644 index e827b1fa..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.content.res.TypedArray -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.withStyledAttributes -import androidx.core.view.ViewCompat -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.R -import com.daedan.festabook.presentation.common.canScrollUp -import com.daedan.festabook.presentation.common.getSystemBarHeightCompat -import com.daedan.festabook.presentation.common.scrollAnimation -import com.google.android.material.chip.ChipGroup - -/** - * @deprecated - * @see - * 이 클래스는 더 이상 사용되지 않으며, 향후 버전에서 제거될 예정입니다. - * 대안으로 현재 PlaceListBottomSheetBehavior이 사용되고 있습니다 - * 네이버 지도 검색 UI를 본딴 동작을 수행합니다 - * 자세한 내용은 해당 링크를 참조해주세요 - * "https://github.com/woowacourse-teams/2025-festabook/pull/174" - */ -class PlaceListScrollBehavior( - context: Context, - attrs: AttributeSet, -) : CoordinatorLayout.Behavior() { - private lateinit var attribute: Attribute - private lateinit var state: BehaviorState - private var isInitialized: Boolean = false - private lateinit var minimumHeightView: View - - init { - context.withStyledAttributes(attrs, R.styleable.PlaceListScrollBehavior) { - setAttribute() - } - } - - override fun onLayoutChild( - parent: CoordinatorLayout, - child: ConstraintLayout, - layoutDirection: Int, - ): Boolean { - minimumHeightView = parent.findViewById(R.id.cg_categories) - if (!isInitialized) { - val recyclerView: RecyclerView? = parent.findViewById(attribute.recyclerViewId) - val companionView: View? = parent.findViewById(attribute.companionViewId) - isInitialized = true - - // 기기 높이 - 시스템 바 높이 - val rootViewHeight = child.rootView.height - child.getSystemBarHeightCompat() - child.translationY = rootViewHeight - attribute.initialY - state = BehaviorState(recyclerView, companionView, rootViewHeight) - } - state.companionView.setCompanionHeight(child) - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - directTargetChild: View, - target: View, - axes: Int, - type: Int, - ): Boolean = axes == ViewCompat.SCROLL_AXIS_VERTICAL - - override fun onNestedPreScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - target: View, - dx: Int, - dy: Int, - consumed: IntArray, - type: Int, - ) { - super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) - state.companionView.setCompanionHeight(child) - - val isAlreadyConsumed = child.consumeIfRecyclerViewCanScrollUp(dy, consumed) - if (isAlreadyConsumed) return - child.consumeBackgroundLayoutScroll(dy, consumed) - } - - override fun onNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - type: Int, - consumed: IntArray, - ) { - if (dyUnconsumed == 0) { - state.companionView?.visibility = View.GONE - } - super.onNestedScroll( - coordinatorLayout, - child, - target, - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - type, - consumed, - ) - } - - fun setOnScrollListener(listener: (dy: Float) -> Unit) { - state = state.copy(onScrollListener = listener) - } - - private fun TypedArray.setAttribute() { - val initialY = - getDimension(R.styleable.PlaceListScrollBehavior_initialY, UNINITIALIZED_VALUE) - val minimumY = - getDimension(R.styleable.PlaceListScrollBehavior_minimumY, UNINITIALIZED_VALUE) - val recyclerViewId = - getResourceId( - R.styleable.PlaceListScrollBehavior_recyclerView, - UNINITIALIZED_VALUE.toInt(), - ) - val companionViewId = - getResourceId( - R.styleable.PlaceListScrollBehavior_companionView, - UNINITIALIZED_VALUE.toInt(), - ) - attribute = - Attribute( - initialY, - minimumY, - recyclerViewId, - companionViewId, - ) - } - - private fun View?.setCompanionHeight(child: ConstraintLayout) { - this?.apply { - y = child.translationY - height - } - } - - private fun ViewGroup.consumeBackgroundLayoutScroll( - dy: Int, - consumed: IntArray, - ) { - apply { - // 최대 높이 (0일수록 천장에 가깝고, contentAreaHeight일수록 바닥에 가까움), 즉 maxHeight 까지만 스크롤을 내릴 수 있습니다 - val maxHeight = state.rootViewHeight - attribute.minimumY - val requestedTranslationY = translationY - dy - val newTranslationY = getNewTranslationY(requestedTranslationY, maxHeight) - - // 외부 레이아웃이 스크롤이 되었을 때만 스크롤 리스너 적용 - if (requestedTranslationY in minimumHeightView.height.toFloat()..maxHeight) { - state.onScrollListener?.invoke(dy.toFloat()) - } - translationY = newTranslationY - scrollAnimation(newTranslationY) - if (newTranslationY.toInt() == minimumHeightView.height) { - consumed[1] = 0 - } else { - consumed[1] = newTranslationY.toInt() - } - } - } - - private fun ViewGroup.getNewTranslationY( - requestedTranslationY: Float, - maxHeight: Float, - ): Float = requestedTranslationY.coerceIn(minimumHeightView.height.toFloat(), maxHeight) - - private fun ViewGroup.consumeIfRecyclerViewCanScrollUp( - dy: Int, - consumed: IntArray, - ): Boolean { - state.recyclerView?.let { - // 리사이클러 뷰가 위로 스크롤 될 수 있을 때 - if (dy < 0 && it.canScrollUp()) { - state.companionView?.visibility = View.VISIBLE - consumed[1] = 0 - return true - } - } - - return false - } - - private data class Attribute( - val initialY: Float, - val minimumY: Float, - val recyclerViewId: Int, - val companionViewId: Int, - ) - - private data class BehaviorState( - val recyclerView: RecyclerView?, - val companionView: View?, - val rootViewHeight: Int, - val onScrollListener: ((dy: Float) -> Unit)? = null, - ) - - companion object { - private const val UNINITIALIZED_VALUE = 0f - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt deleted file mode 100644 index 2d214153..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.timeTagSpinner.adapter - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import com.daedan.festabook.databinding.ItemSpinnerDropdownBinding -import com.daedan.festabook.databinding.ItemSpinnerSelectedBinding -import com.daedan.festabook.domain.model.TimeTag - -class TimeTagSpinnerAdapter( - context: Context, - private val items: MutableList, -) : ArrayAdapter(context, 0, items) { - override fun getView( - position: Int, - convertView: View?, - parent: ViewGroup, - ): View { - val binding: ItemSpinnerSelectedBinding - val view: View - - if (convertView == null) { - binding = - ItemSpinnerSelectedBinding.inflate( - LayoutInflater.from(context), - parent, - false, - ) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ItemSpinnerSelectedBinding - } - - binding.tvSelectedItem.text = items[position].name - return view - } - - override fun getDropDownView( - position: Int, - convertView: View?, - parent: ViewGroup, - ): View { - val binding: ItemSpinnerDropdownBinding - val view: View - - if (convertView == null) { - binding = - ItemSpinnerDropdownBinding.inflate( - LayoutInflater.from(context), - parent, - false, - ) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ItemSpinnerDropdownBinding - } - - binding.tvDropdownItem.text = items[position].name - return view - } - - fun updateItems(newItems: List) { - items.clear() - items.addAll(newItems) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/platform/RememberAppGraph.kt b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberAppGraph.kt new file mode 100644 index 00000000..4c31a198 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberAppGraph.kt @@ -0,0 +1,13 @@ +package com.daedan.festabook.presentation.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.daedan.festabook.di.FestaBookAppGraph +import com.daedan.festabook.di.appGraph + +@Composable +fun rememberAppGraph(): FestaBookAppGraph { + val context = LocalContext.current + return remember(context) { context.appGraph } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/platform/RememberAppVersionManager.kt b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberAppVersionManager.kt new file mode 100644 index 00000000..df2a5275 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberAppVersionManager.kt @@ -0,0 +1,30 @@ +package com.daedan.festabook.presentation.platform + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.daedan.festabook.presentation.splash.AppVersionManager + +@Composable +fun rememberAppVersionManager( + factory: AppVersionManager.Factory, + onUpdateSuccess: () -> Unit, + onUpdateFailure: () -> Unit, +): AppVersionManager { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + onUpdateSuccess() + } else { + onUpdateFailure() + } + } + + return remember(factory, launcher) { + factory.create(launcher) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt new file mode 100644 index 00000000..8afebb7d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberDeepLinkHandler.kt @@ -0,0 +1,37 @@ +package com.daedan.festabook.presentation.platform + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.core.util.Consumer + +object DeepLinkKeys { + const val KEY_NOTICE_ID_TO_EXPAND = "noticeIdToExpand" + const val KEY_CAN_NAVIGATE_TO_NEWS = "canNavigateToNews" + const val INITIALIZED_ID = -1L +} + +@Composable +fun RememberDeepLinkHandler(onDeepLink: (Intent) -> Unit) { + val context = LocalContext.current + val activity = context as ComponentActivity + val currentOnDeepLink by rememberUpdatedState(onDeepLink) + + LaunchedEffect(Unit) { + currentOnDeepLink(activity.intent) + } + + DisposableEffect(activity) { + val listener = + Consumer { intent -> + currentOnDeepLink(intent) + } + activity.addOnNewIntentListener(listener) + onDispose { activity.removeOnNewIntentListener(listener) } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/platform/RememberLocationSource.kt b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberLocationSource.kt new file mode 100644 index 00000000..3831e720 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberLocationSource.kt @@ -0,0 +1,18 @@ +package com.daedan.festabook.presentation.platform + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.naver.maps.map.util.FusedLocationSource + +private const val LOCATION_PERMISSION_REQUEST_CODE = 1234 + +@Composable +fun rememberLocationSource(): FusedLocationSource { + val context = LocalContext.current + val activity = context as ComponentActivity + return remember(activity) { + FusedLocationSource(activity, LOCATION_PERMISSION_REQUEST_CODE) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/platform/RememberNotificationPermissionManager.kt b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberNotificationPermissionManager.kt new file mode 100644 index 00000000..3301d53f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberNotificationPermissionManager.kt @@ -0,0 +1,44 @@ +package com.daedan.festabook.presentation.platform + +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.daedan.festabook.presentation.NotificationPermissionManager +import timber.log.Timber + +@Composable +fun rememberNotificationPermissionManager( + factory: NotificationPermissionManager.Factory, + onPermissionGrant: () -> Unit, + onPermissionDeny: () -> Unit, +): NotificationPermissionManager { + val context = LocalContext.current + + val permissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + Timber.d("Notification permission granted") + onPermissionGrant() + } else { + Timber.d("Notification permission denied") + onPermissionDeny() + } + } + + return remember(factory, permissionLauncher) { + factory.create( + launchPermission = { permission -> permissionLauncher.launch(permission) }, + shouldShowRationale = { permission -> + (context as? ComponentActivity) + ?.shouldShowRequestPermissionRationale(permission) ?: false + }, + onPermissionGranted = onPermissionGrant, + onPermissionDenied = onPermissionDeny, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/platform/RememberOpenAppSettings.kt b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberOpenAppSettings.kt new file mode 100644 index 00000000..c9e71658 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/platform/RememberOpenAppSettings.kt @@ -0,0 +1,22 @@ +package com.daedan.festabook.presentation.platform + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +fun rememberOpenAppSettings(): () -> Unit { + val context = LocalContext.current + return remember(context) { + { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt deleted file mode 100644 index a6e1e0eb..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/OnBookmarkCheckedListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -fun interface OnBookmarkCheckedListener { - fun onBookmarkChecked(scheduleEventId: Long) -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt deleted file mode 100644 index 679e7e8b..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleDatesUiState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel - -sealed interface ScheduleDatesUiState { - data object Loading : ScheduleDatesUiState - - data class Success( - val dates: List, - val initialDatePosition: Int, - ) : ScheduleDatesUiState - - data class Error( - val throwable: Throwable, - ) : ScheduleDatesUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt index f576e209..7c2301d7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleEventsUiState.kt @@ -2,15 +2,22 @@ package com.daedan.festabook.presentation.schedule import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel -sealed interface ScheduleEventsUiState { - data object Loading : ScheduleEventsUiState +data class ScheduleEventsUiState( + val content: Content, + val isRefreshing: Boolean = false, +) { + sealed interface Content { + data object InitialLoading : Content - data class Success( - val events: List, - val currentEventPosition: Int, - ) : ScheduleEventsUiState + data class Success( + val events: List, + val currentEventPosition: Int, + ) : Content { + val isEventsEmpty get() = events.isEmpty() + } - data class Error( - val throwable: Throwable, - ) : ScheduleEventsUiState + data class Error( + val throwable: Throwable, + ) : Content + } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt index 5acec93e..d2e7ecd9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleFragment.kt @@ -1,29 +1,25 @@ package com.daedan.festabook.presentation.schedule -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentScheduleBinding -import com.daedan.festabook.databinding.ItemScheduleTabBinding import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleMenuItemReClickLogData import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel -import com.daedan.festabook.presentation.schedule.adapter.SchedulePagerAdapter -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator +import com.daedan.festabook.presentation.schedule.component.ScheduleScreen +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import timber.log.Timber @ContributesIntoMap( scope = AppScope::class, @@ -37,92 +33,25 @@ class ScheduleFragment : override val layoutId: Int = R.layout.fragment_schedule @Inject - private lateinit var viewModelFactory: ScheduleViewModel.Factory - private val adapter: SchedulePagerAdapter by lazy { - SchedulePagerAdapter(this) - } + override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val viewModel: ScheduleViewModel by viewModels { ScheduleViewModel.factory( - viewModelFactory - ) } + private val viewModel: ScheduleViewModel by viewModels() - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - binding.vpSchedule.adapter = adapter - setupObservers() - } - - override fun onMenuItemReClick() { - viewModel.loadAllDates() - viewModel.loadScheduleByDate() - binding.logger.log(ScheduleMenuItemReClickLogData(binding.logger.getBaseLogData())) - } - - @SuppressLint("WrongConstant") - private fun setupScheduleTabLayout(initialCurrentDateIndex: Int) { - binding.vpSchedule.offscreenPageLimit = PRELOAD_PAGE_COUNT - - TabLayoutMediator(binding.tlSchedule, binding.vpSchedule) { tab, position -> - setupScheduleTabView(tab, position) - binding.vpSchedule.setCurrentItem(initialCurrentDateIndex, false) - }.attach() - } - - private fun setupScheduleTabView( - tab: TabLayout.Tab, - position: Int, - ) { - val itemScheduleTabBinding = - ItemScheduleTabBinding.inflate( - LayoutInflater.from(requireContext()), - binding.tlSchedule, - false, - ) - tab.customView = itemScheduleTabBinding.root - - itemScheduleTabBinding.tvScheduleTabItem.text = - viewModel.scheduleDatesUiState.value - .let { - (it as? ScheduleDatesUiState.Success)?.dates?.get(position)?.date - ?: EMPTY_DATE_TEXT - } - } - - private fun setupObservers() { - viewModel.scheduleDatesUiState.observe(viewLifecycleOwner) { scheduleDatesUiState -> - - when (scheduleDatesUiState) { - is ScheduleDatesUiState.Loading -> { - showLoadingView(isLoading = true) - } - - is ScheduleDatesUiState.Success -> { - showLoadingView(isLoading = false) - setupScheduleTabLayout(scheduleDatesUiState.initialDatePosition) - adapter.submitList(scheduleDatesUiState.dates) - } - - is ScheduleDatesUiState.Error -> { - showLoadingView(isLoading = false) - Timber.w( - scheduleDatesUiState.throwable, - "${this::class.simpleName}: ${scheduleDatesUiState.throwable.message}", - ) - showErrorSnackBar(scheduleDatesUiState.throwable) + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + ScheduleScreen(scheduleViewModel = viewModel) } } } - } - private fun showLoadingView(isLoading: Boolean) { - binding.lavScheduleLoading.visibility = if (isLoading) View.VISIBLE else View.GONE - binding.vpSchedule.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE - } - - companion object { - private const val PRELOAD_PAGE_COUNT: Int = 2 - private const val EMPTY_DATE_TEXT: String = "" + override fun onMenuItemReClick() { + viewModel.loadSchedules() } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt deleted file mode 100644 index 3bd30acb..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleTabPageFragment.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.daedan.festabook.presentation.schedule - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.LinearLayoutManager -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentScheduleTabPageBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleSwipeRefreshLogData -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.INVALID_ID -import com.daedan.festabook.presentation.schedule.adapter.ScheduleAdapter -import timber.log.Timber - -class ScheduleTabPageFragment : BaseFragment() { - override val layoutId: Int = R.layout.fragment_schedule_tab_page - private val viewModel: ScheduleViewModel by viewModels { - val dateId: Long = arguments?.getLong(KEY_DATE_ID, INVALID_ID) ?: INVALID_ID - ScheduleViewModel.factory(appGraph.scheduleViewModelFactory, dateId) - } - private val adapter: ScheduleAdapter by lazy { - ScheduleAdapter() - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupObservers() - setupScheduleEventRecyclerView() - - binding.lifecycleOwner = viewLifecycleOwner - onSwipeRefreshScheduleByDateListener() - } - - private fun setupScheduleEventRecyclerView() { - binding.rvScheduleEvent.adapter = adapter - (binding.rvScheduleEvent.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = - false - viewModel.loadScheduleByDate() - } - - private fun onSwipeRefreshScheduleByDateListener() { - binding.srlScheduleEvent.setOnRefreshListener { - binding.logger.log(ScheduleSwipeRefreshLogData(binding.logger.getBaseLogData())) - viewModel.loadScheduleByDate() - } - } - - private fun setupObservers() { - viewModel.scheduleEventsUiState.observe(viewLifecycleOwner) { schedule -> - when (schedule) { - is ScheduleEventsUiState.Loading, - -> { - showLoadingView(isLoading = true) - showEmptyStateMessage() - } - - is ScheduleEventsUiState.Success -> { - showLoadingView(isLoading = false) - adapter.submitList(schedule.events) { - showEmptyStateMessage() - scrollToCenterOfCurrentEvent(schedule.currentEventPosition) - } - } - - is ScheduleEventsUiState.Error -> { - Timber.w( - schedule.throwable, - "ScheduleTabPageFragment: ${schedule.throwable.message}", - ) - showErrorSnackBar(schedule.throwable) - showLoadingView(isLoading = false) - showEmptyStateMessage() - } - } - } - } - - private fun showLoadingView(isLoading: Boolean) { - if (isLoading) { - binding.rvScheduleEvent.visibility = View.INVISIBLE - binding.viewScheduleEventTimeLine.visibility = View.INVISIBLE - binding.lavScheduleLoading.visibility = View.VISIBLE - } else { - binding.lavScheduleLoading.visibility = View.GONE - binding.viewScheduleEventTimeLine.visibility = View.VISIBLE - binding.rvScheduleEvent.visibility = View.VISIBLE - } - binding.srlScheduleEvent.isRefreshing = false - } - - private fun scrollToCenterOfCurrentEvent(position: Int) { - val recyclerView = binding.rvScheduleEvent - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - layoutManager.scrollToPositionWithOffset(position, NO_OFFSET) - - recyclerView.post { - val view = layoutManager.findViewByPosition(position) - if (view != null) { - val viewTop = layoutManager.getDecoratedTop(view) - val viewHeight = view.height - val parentHeight = recyclerView.height - val dy = viewTop - ((parentHeight - viewHeight) / HALF) - - recyclerView.smoothScrollBy(NO_OFFSET, dy) - } - } - } - - private fun showEmptyStateMessage() { - val itemCount = binding.rvScheduleEvent.adapter?.itemCount ?: 0 - - if (itemCount == 0) { - binding.rvScheduleEvent.visibility = View.GONE - binding.viewScheduleEventTimeLine.visibility = View.GONE - binding.tvEmptyState.root.visibility = View.VISIBLE - } else { - binding.rvScheduleEvent.visibility = View.VISIBLE - binding.viewScheduleEventTimeLine.visibility = View.VISIBLE - binding.tvEmptyState.root.visibility = View.GONE - } - } - - companion object { - const val KEY_DATE_ID = "dateId" - private const val NO_OFFSET: Int = 0 - private const val HALF: Int = 2 - - fun newInstance(dateId: Long): ScheduleTabPageFragment { - val fragment = ScheduleTabPageFragment() - val args = - Bundle().apply { - putLong(KEY_DATE_ID, dateId) - } - fragment.arguments = args - return fragment - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt new file mode 100644 index 00000000..18bc51ef --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleUiState.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.schedule + +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel + +data class ScheduleUiState( + val content: Content, +) { + sealed interface Content { + data object InitialLoading : Content + + data class Success( + val dates: List, + val currentDatePosition: Int, + val eventsUiStateByPosition: Map = emptyMap(), + ) : Content + + data class Error( + val throwable: Throwable, + ) : Content + } + + companion object { + const val DEFAULT_POSITION: Int = 0 + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt index 2495957a..2f70902c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/ScheduleViewModel.kt @@ -1,107 +1,210 @@ package com.daedan.festabook.presentation.schedule -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.daedan.festabook.FestaBookApp +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.model.ScheduleDate import com.daedan.festabook.domain.repository.ScheduleRepository +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus import com.daedan.festabook.presentation.schedule.model.toUiModel -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.time.LocalDate -class ScheduleViewModel @AssistedInject constructor( +@ContributesIntoMap(AppScope::class) +@ViewModelKey(ScheduleViewModel::class) +@Inject +class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, - @Assisted private val dateId: Long, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(dateId: Long): ScheduleViewModel - } - - private val _scheduleEventsUiState: MutableLiveData = - MutableLiveData() - val scheduleEventsUiState: LiveData get() = _scheduleEventsUiState - - private val _scheduleDatesUiState: MutableLiveData = - MutableLiveData() - val scheduleDatesUiState: LiveData get() = _scheduleDatesUiState + private val _scheduleUiState: MutableStateFlow = + MutableStateFlow( + ScheduleUiState(content = ScheduleUiState.Content.InitialLoading), + ) + val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() init { - loadAllDates() - if (dateId != INVALID_ID) loadScheduleByDate() + loadSchedules() } - fun loadScheduleByDate() { - if (dateId == INVALID_ID) return - if (_scheduleEventsUiState.value == ScheduleEventsUiState.Loading) return + fun loadSchedules( + scheduleUiState: ScheduleUiState = ScheduleUiState(content = ScheduleUiState.Content.InitialLoading), + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState(ScheduleEventsUiState.Content.InitialLoading), + selectedDatePosition: Int? = null, + preloadCount: Int = PRELOAD_PAGE_COUNT, + ) { viewModelScope.launch { - _scheduleEventsUiState.value = ScheduleEventsUiState.Loading - - val result = scheduleRepository.fetchScheduleEventsById(dateId) - result - .onSuccess { scheduleEvents -> - val scheduleEventUiModels = scheduleEvents.map { it.toUiModel() } - val currentEventPosition = - scheduleEventUiModels - .indexOfFirst { scheduleEvent -> scheduleEvent.status == ScheduleEventUiStatus.ONGOING } - .coerceAtLeast(FIRST_INDEX) - - _scheduleEventsUiState.value = - ScheduleEventsUiState.Success(scheduleEventUiModels, currentEventPosition) - }.onFailure { - _scheduleEventsUiState.value = - ScheduleEventsUiState.Error(it) - } + val datesResult = loadAllDates(scheduleUiState, selectedDatePosition) + + if (datesResult.isSuccess) { + val currentPosition = + (_scheduleUiState.value.content as ScheduleUiState.Content.Success).currentDatePosition + loadEventsInRange(currentPosition, scheduleEventUiState, preloadCount) + } } } - fun loadAllDates() { - if (_scheduleDatesUiState.value == ScheduleDatesUiState.Loading) return - viewModelScope.launch { - _scheduleDatesUiState.value = ScheduleDatesUiState.Loading - - val result = scheduleRepository.fetchAllScheduleDates() - result - .onSuccess { scheduleDates -> - val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } - val today = LocalDate.now() - - val currentDatePosition = - scheduleDates - .indexOfFirst { !it.date.isBefore(today) } - .let { currentIndex -> if (currentIndex == INVALID_INDEX) FIRST_INDEX else currentIndex } - - _scheduleDatesUiState.value = - ScheduleDatesUiState.Success(scheduleDateUiModels, currentDatePosition) - }.onFailure { - _scheduleDatesUiState.value = ScheduleDatesUiState.Error(it) + private suspend fun loadAllDates( + scheduleUiState: ScheduleUiState, + selectedDatePosition: Int?, + ): Result> { + _scheduleUiState.value = scheduleUiState + val result = scheduleRepository.fetchAllScheduleDates() + + return result.fold( + onSuccess = { scheduleDates -> + val scheduleDateUiModels = scheduleDates.map { it.toUiModel() } + val currentDatePosition = + selectedDatePosition ?: getCurrentDatePosition(scheduleDates) + + _scheduleUiState.value = + ScheduleUiState( + content = + ScheduleUiState.Content.Success( + dates = scheduleDateUiModels, + currentDatePosition = currentDatePosition, + ), + ) + + Result.success(scheduleDateUiModels) + }, + onFailure = { throwable -> + _scheduleUiState.value = + ScheduleUiState(content = ScheduleUiState.Content.Error(throwable)) + Result.failure(throwable) + }, + ) + } + + fun loadEventsInRange( + currentPosition: Int, + scheduleEventUiState: ScheduleEventsUiState = ScheduleEventsUiState(ScheduleEventsUiState.Content.InitialLoading), + preloadCount: Int = PRELOAD_PAGE_COUNT, + ) { + (_scheduleUiState.value.content as? ScheduleUiState.Content.Success)?.dates?.let { scheduleDates -> + val range = + getPreloadRange( + totalPageSize = scheduleDates.size, + currentPosition = currentPosition, + preloadCount = preloadCount, + ) + viewModelScope.launch { + range.forEach { position -> + if (isEventLoaded(position)) return@forEach + + val scheduleDateUiModel = scheduleDates[position] + launch { + loadEventsByPosition( + position = position, + scheduleDateUiModel = scheduleDateUiModel, + scheduleEventsUiState = scheduleEventUiState, + ) + } } + } } } + private suspend fun loadEventsByPosition( + position: Int, + scheduleDateUiModel: ScheduleDateUiModel, + scheduleEventsUiState: ScheduleEventsUiState, + ) { + updateEventUiState(position, scheduleEventsUiState) + + val result = + scheduleRepository.fetchScheduleEventsById(scheduleDateUiModel.id) + + result + .onSuccess { scheduleEvents -> + val uiModels = scheduleEvents.map { it.toUiModel() } + updateEventUiState( + position = position, + scheduleEventsUiState = + ScheduleEventsUiState( + content = + ScheduleEventsUiState.Content.Success( + events = uiModels, + currentEventPosition = getCurrentEventPosition(uiModels), + ), + ), + ) + }.onFailure { + updateEventUiState( + position, + ScheduleEventsUiState( + content = ScheduleEventsUiState.Content.Error(it), + ), + ) + } + } + + private fun updateEventUiState( + position: Int, + scheduleEventsUiState: ScheduleEventsUiState, + ) { + val currentState = _scheduleUiState.value + val content = currentState.content as? ScheduleUiState.Content.Success ?: return + + _scheduleUiState.value = + currentState.copy( + content = + content + .copy( + eventsUiStateByPosition = content.eventsUiStateByPosition + (position to scheduleEventsUiState), + ), + ) + } + + private fun getCurrentEventPosition(scheduleEventUiModels: List): Int { + val currentEventPosition = + scheduleEventUiModels + .indexOfFirst { + it.status != ScheduleEventUiStatus.COMPLETED + }.coerceAtLeast(FIRST_INDEX) + return currentEventPosition + } + + private fun getCurrentDatePosition( + scheduleDates: List, + today: LocalDate = LocalDate.now(), + ): Int { + val currentDatePosition = + scheduleDates + .indexOfFirst { !it.date.isBefore(today) } + .coerceAtLeast(FIRST_INDEX) + return currentDatePosition + } + + private fun getPreloadRange( + totalPageSize: Int, + preloadCount: Int, + currentPosition: Int, + ): IntRange { + val start = (currentPosition - preloadCount).coerceAtLeast(FIRST_INDEX) + val end = (currentPosition + preloadCount).coerceAtMost(totalPageSize - 1) + return start..end + } + + private fun isEventLoaded(position: Int): Boolean { + val currentScheduleUiStateContent = _scheduleUiState.value.content + if (currentScheduleUiStateContent !is ScheduleUiState.Content.Success) return false + + val currentScheduleEventsUiStateContent = + currentScheduleUiStateContent.eventsUiStateByPosition[position]?.content ?: return false + + return currentScheduleEventsUiStateContent is ScheduleEventsUiState.Content.Success + } + companion object { - const val INVALID_ID: Long = -1L private const val FIRST_INDEX: Int = 0 - private const val INVALID_INDEX: Int = -1 - - fun factory( - factory: Factory, - dateId: Long = INVALID_ID - ): ViewModelProvider.Factory = - viewModelFactory { - initializer { - factory.create(dateId) - } - } + const val PRELOAD_PAGE_COUNT: Int = 2 } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt deleted file mode 100644 index c1e37207..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.daedan.festabook.presentation.schedule.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.schedule.OnBookmarkCheckedListener -import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel - -class ScheduleAdapter : ListAdapter(DIFF_UTIL) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ScheduleItemViewHolder = ScheduleItemViewHolder.from(parent) - - override fun onBindViewHolder( - holder: ScheduleItemViewHolder, - position: Int, - ) { - holder.bind(getItem(position), itemCount) - } - - companion object { - private val DIFF_UTIL = - object : - DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ScheduleEventUiModel, - newItem: ScheduleEventUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: ScheduleEventUiModel, - newItem: ScheduleEventUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt deleted file mode 100644 index be6953e3..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/ScheduleItemViewHolder.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.daedan.festabook.presentation.schedule.adapter - -import android.content.Context -import android.view.Gravity -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.R -import com.daedan.festabook.databinding.ItemScheduleTabPageBinding -import com.daedan.festabook.logging.logger -import com.daedan.festabook.logging.model.schedule.ScheduleEventClickLogData -import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel -import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus -import com.daedan.festabook.presentation.schedule.model.toKoreanString - -class ScheduleItemViewHolder( - private val binding: ItemScheduleTabPageBinding, -) : RecyclerView.ViewHolder(binding.root) { - private var scheduleEventItem: ScheduleEventUiModel? = null - - init { - binding.clScheduleEventCard.setOnClickListener { - scheduleEventItem?.let { - binding.logger.log( - ScheduleEventClickLogData( - binding.logger.getBaseLogData(), - it.id, - it.title, - ), - ) - } - } - } - - fun bind( - scheduleEventItem: ScheduleEventUiModel, - itemCount: Int, - ) { - this.scheduleEventItem = scheduleEventItem - setupBottomMargin(itemCount) - binding.scheduleEvent = scheduleEventItem - setupEventViewByStatus(scheduleEventItem.status) - } - - private fun setupBottomMargin(itemCount: Int) { - val layoutParams = itemView.layoutParams as ViewGroup.MarginLayoutParams - if (layoutPosition == itemCount - 1) { - layoutParams.bottomMargin = BOTTOM_MARGIN.toPx(binding.clScheduleEventCard.context) - } else { - layoutParams.bottomMargin = 0 - } - } - - private fun setupEventViewByStatus(status: ScheduleEventUiStatus) { - val context = binding.root.context - val gray050 = ContextCompat.getColor(context, R.color.gray050) - val gray400 = ContextCompat.getColor(context, R.color.gray400) - val gray500 = ContextCompat.getColor(context, R.color.gray500) - val gray900 = ContextCompat.getColor(context, R.color.gray900) - - when (status) { - ScheduleEventUiStatus.COMPLETED -> { - val borderColor = R.drawable.bg_stroke_gray400_radius_10dp - binding.clScheduleEventCard.setBackgroundResource(borderColor) - setupScheduleEventStatusText( - context = context, - status = status, - textColor = gray400, - backgroundResId = null, - ) - setupScheduleEventContentsColor( - titleColor = gray400, - timeColor = gray400, - locationColor = gray400, - ) - } - - ScheduleEventUiStatus.ONGOING -> { - val borderColor = R.drawable.bg_stroke_blue400_radius_10dp - binding.clScheduleEventCard.setBackgroundResource(borderColor) - setupScheduleEventStatusText( - context = context, - status = status, - textColor = gray050, - backgroundResId = R.drawable.bg_gray900_radius_6dp, - ) - setupScheduleEventContentsColor( - titleColor = gray900, - timeColor = gray500, - locationColor = gray500, - ) - } - - ScheduleEventUiStatus.UPCOMING -> { - val borderColor = R.drawable.bg_stroke_green400_radius_10dp - binding.clScheduleEventCard.setBackgroundResource(borderColor) - setupScheduleEventStatusText( - context = context, - status = status, - textColor = gray900, - backgroundResId = R.drawable.bg_stroke_gray900_radius_6dp, - ) - setupScheduleEventContentsColor( - titleColor = gray900, - timeColor = gray500, - locationColor = gray500, - ) - binding.tvScheduleEventStatus.layoutParams = - binding.tvScheduleEventStatus.layoutParams.apply { - width = UPCOMING_TEXT_WIDTH.toPx(context) - height = UPCOMING_TEXT_HEIGHT.toPx(context) - } - } - } - } - - private fun setupScheduleEventContentsColor( - titleColor: Int, - timeColor: Int, - locationColor: Int, - ) { - binding.tvScheduleEventTitle.setTextColor(titleColor) - binding.ivScheduleEventLocation.setColorFilter(locationColor) - binding.tvScheduleEventLocation.setTextColor(locationColor) - binding.ivScheduleEventClock.setColorFilter(timeColor) - binding.tvScheduleEventTime.setTextColor(timeColor) - } - - private fun setupScheduleEventStatusText( - context: Context, - status: ScheduleEventUiStatus, - textColor: Int, - backgroundResId: Int?, - ) = with(binding.tvScheduleEventStatus) { - val gray050 = ContextCompat.getColor(context, R.color.gray050) - text = status.toKoreanString(context) - setTextColor(textColor) - gravity = if (status == ScheduleEventUiStatus.COMPLETED) Gravity.END else Gravity.CENTER - backgroundResId?.let { setBackgroundResource(it) } ?: setBackgroundColor(gray050) - } - - companion object { - private const val UPCOMING_TEXT_WIDTH = 36 - private const val UPCOMING_TEXT_HEIGHT = 24 - private const val BOTTOM_MARGIN = 20 - - fun from(parent: ViewGroup): ScheduleItemViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = ItemScheduleTabPageBinding.inflate(inflater, parent, false) - return ScheduleItemViewHolder(binding) - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt deleted file mode 100644 index ebc1d3e6..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/adapter/SchedulePagerAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.daedan.festabook.presentation.schedule.adapter - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.daedan.festabook.presentation.schedule.ScheduleTabPageFragment -import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel - -class SchedulePagerAdapter( - fragment: Fragment, - private val items: MutableList = mutableListOf(), -) : FragmentStateAdapter(fragment) { - override fun getItemCount(): Int = items.size - - override fun createFragment(position: Int): Fragment { - val dateId: Long = items[position].id - return ScheduleTabPageFragment.newInstance(dateId) - } - - fun submitList(newItems: List) { - items.clear() - items.addAll(newItems) - notifyItemRangeChanged(FIRST_INDEX, itemCount) - } - - companion object { - private const val FIRST_INDEX: Int = 0 - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt new file mode 100644 index 00000000..b3a7a2a1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventCard.kt @@ -0,0 +1,224 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ScheduleEventCard( + scheduleEvent: ScheduleEventUiModel, + modifier: Modifier = Modifier, +) { + val scheduleEventCardColors = scheduleEventCardColors(scheduleEvent.status) + + Column( + modifier = + modifier + .cardBackground( + backgroundColor = MaterialTheme.colorScheme.background, + borderColor = scheduleEventCardColors.cardBorderColor, + shape = festabookShapes.radius2, + ).padding(festabookSpacing.paddingBody4), + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Text( + text = scheduleEvent.title, + style = FestabookTypography.titleLarge, + color = scheduleEventCardColors.titleColor, + modifier = Modifier.weight(1f), + ) + ScheduleEventLabel(scheduleEvent.status) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_clock), + contentDescription = stringResource(R.string.content_description_iv_location), + tint = scheduleEventCardColors.contentColor, + ) + Text( + text = + stringResource( + R.string.format_date, + scheduleEvent.startTime, + scheduleEvent.endTime, + ), + style = FestabookTypography.bodySmall, + color = scheduleEventCardColors.contentColor, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody1), + ) { + Icon( + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + tint = scheduleEventCardColors.contentColor, + ) + Text( + text = scheduleEvent.location, + style = FestabookTypography.bodySmall, + color = scheduleEventCardColors.contentColor, + ) + } + } +} + +@Composable +private fun ScheduleEventLabel(scheduleEventUiStatus: ScheduleEventUiStatus) { + val scheduleEventCardProps = scheduleEventCardColors(scheduleEventUiStatus) + Box( + modifier = + Modifier + .size(48.dp, 24.dp) + .cardBackground( + backgroundColor = scheduleEventCardProps.labelBackgroundColor, + borderColor = scheduleEventCardProps.labelBorderColor, + shape = festabookShapes.radius1, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = scheduleLabelText(scheduleEventUiStatus), + style = FestabookTypography.bodySmall, + color = scheduleEventCardProps.labelTextColor, + ) + } +} + +@Composable +private fun scheduleLabelText(status: ScheduleEventUiStatus): String = + when (status) { + ScheduleEventUiStatus.UPCOMING -> stringResource(R.string.schedule_status_upcoming) + ScheduleEventUiStatus.ONGOING -> stringResource(R.string.schedule_status_ongoing) + ScheduleEventUiStatus.COMPLETED -> stringResource(R.string.schedule_status_completed) + } + +@Composable +private fun scheduleEventCardColors(status: ScheduleEventUiStatus): ScheduleEventCardProps = + when (status) { + ScheduleEventUiStatus.UPCOMING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentGreen, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.black, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.accentBlue, + titleColor = FestabookColor.black, + contentColor = FestabookColor.gray500, + labelTextColor = FestabookColor.white, + labelBackgroundColor = FestabookColor.black, + labelBorderColor = FestabookColor.black, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + ScheduleEventCardProps( + cardBorderColor = FestabookColor.gray400, + titleColor = FestabookColor.gray400, + contentColor = FestabookColor.gray400, + labelTextColor = FestabookColor.gray400, + labelBackgroundColor = FestabookColor.white, + labelBorderColor = FestabookColor.white, + ) + } + } + +@Composable +@Preview(showBackground = true) +private fun OnGoingScheduleEventCardPreview() { + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun UpComingScheduleEventCardPreview() { + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.UPCOMING, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun CompleteScheduleEventCardONGOINGPreview() { + FestabookTheme { + ScheduleEventCard( + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.COMPLETED, + startTime = "09:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) + } +} + +data class ScheduleEventCardProps( + val cardBorderColor: Color, + val titleColor: Color, + val contentColor: Color, + val labelTextColor: Color, + val labelBackgroundColor: Color, + val labelBorderColor: Color, +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt new file mode 100644 index 00000000..afed2f0c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleEventItem.kt @@ -0,0 +1,140 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.tooling.preview.Preview +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing + +@Composable +fun ScheduleEventItem( + composition: LottieComposition?, + scheduleEvent: ScheduleEventUiModel, + modifier: Modifier = Modifier, +) { + val props = lottieTimeLineCircleProps(scheduleEvent.status) + val dynamicProperties = rememberScheduleEventDynamicProperties(props) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + isPlaying = scheduleEvent.status != ScheduleEventUiStatus.COMPLETED, + ) + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.size(festabookSpacing.paddingBody4 * 4), + dynamicProperties = dynamicProperties, + ) + + ScheduleEventCard(scheduleEvent = scheduleEvent) + } +} + +@Composable +@Preview +private fun ScheduleEventItemPreview() { + ScheduleEventItem( + composition = null, + scheduleEvent = + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ) +} + +@Composable +private fun rememberScheduleEventDynamicProperties(props: LottieTimeLineCircleProps) = + rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.centerColor.toArgb(), + *props.centerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.outerOpacity * 100).toInt(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.OPACITY, + value = (props.innerOpacity * 100).toInt(), + *props.innerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.outerColor.toArgb(), + *props.outerKeyPath.toTypedArray(), + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = props.innerColor.toArgb(), + *props.innerKeyPath.toTypedArray(), + ), + ) + +@Composable +private fun lottieTimeLineCircleProps(status: ScheduleEventUiStatus): LottieTimeLineCircleProps = + when (status) { + ScheduleEventUiStatus.UPCOMING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentGreen, + outerOpacity = 0f, + innerOpacity = 1f, + outerColor = FestabookColor.accentGreen, + innerColor = FestabookColor.accentGreen, + ) + } + + ScheduleEventUiStatus.ONGOING -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.accentBlue, + outerOpacity = 1f, + innerOpacity = 1f, + outerColor = FestabookColor.accentBlue, + innerColor = FestabookColor.accentBlue, + ) + } + + ScheduleEventUiStatus.COMPLETED -> { + LottieTimeLineCircleProps( + centerColor = FestabookColor.gray300, + outerOpacity = 0f, + innerOpacity = 0f, + outerColor = FestabookColor.gray300, + innerColor = FestabookColor.gray300, + ) + } + } + +data class LottieTimeLineCircleProps( + val centerColor: Color, + val outerOpacity: Float, + val innerOpacity: Float, + val outerColor: Color, + val innerColor: Color, + val centerKeyPath: List = listOf("centerCircle", "**", "Fill 1"), + val outerKeyPath: List = listOf("outerWave", "**", "Fill 1"), + val innerKeyPath: List = listOf("innerWave", "**", "Fill 1"), +) diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt new file mode 100644 index 00000000..40008ba2 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleScreen.kt @@ -0,0 +1,103 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber + +@Composable +fun ScheduleScreen( + scheduleViewModel: ScheduleViewModel, + modifier: Modifier = Modifier, + onShowErrorSnackbar: (Throwable) -> Unit = {}, // TODO Fragment 제거 시 필수 파라미터로 변경 +) { + val scheduleUiState by scheduleViewModel.scheduleUiState.collectAsStateWithLifecycle() + val currentOnShowErrorSnackbar by rememberUpdatedState(onShowErrorSnackbar) + + LaunchedEffect(scheduleUiState.content) { + when (val scheduleUiStateContent = scheduleUiState.content) { + is ScheduleUiState.Content.Error -> { + currentOnShowErrorSnackbar(scheduleUiStateContent.throwable) + } + + else -> {} + } + } + + Scaffold( + topBar = { FestabookTopAppBar(title = stringResource(R.string.schedule_title)) }, + modifier = modifier, + ) { innerPadding -> + when (val scheduleContent = scheduleUiState.content) { + is ScheduleUiState.Content.Error -> { + Timber.w(scheduleContent.throwable.stackTraceToString()) + ErrorStateScreen() + } + + ScheduleUiState.Content.InitialLoading -> { + LoadingStateScreen() + } + + is ScheduleUiState.Content.Success -> { + val pageState = + rememberPagerState(initialPage = scheduleContent.currentDatePosition) { scheduleContent.dates.size } + val scope = rememberCoroutineScope() + LaunchedEffect(pageState.currentPage) { + scheduleViewModel.loadEventsInRange(currentPosition = pageState.currentPage) + } + + Column(modifier = Modifier.padding(top = innerPadding.calculateTopPadding())) { + ScheduleTabRow( + pageState = pageState, + scope = scope, + dates = scheduleContent.dates, + ) + Spacer(modifier = Modifier.height(festabookSpacing.paddingBody4)) + HorizontalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + ScheduleTabPage( + pagerState = pageState, + scheduleContent = scheduleContent, + onRefresh = { currentEventsContent -> + scheduleViewModel.loadSchedules( + scheduleUiState = ScheduleUiState(content = scheduleContent), + scheduleEventUiState = + ScheduleEventsUiState( + content = currentEventsContent, + isRefreshing = true, + ), + selectedDatePosition = pageState.currentPage, + preloadCount = 0, + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt new file mode 100644 index 00000000..133384de --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabPage.kt @@ -0,0 +1,187 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.ErrorStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.common.component.PullToRefreshContainer +import com.daedan.festabook.presentation.schedule.ScheduleEventsUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState +import com.daedan.festabook.presentation.schedule.ScheduleUiState.Companion.DEFAULT_POSITION +import com.daedan.festabook.presentation.schedule.ScheduleViewModel.Companion.PRELOAD_PAGE_COUNT +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiModel +import com.daedan.festabook.presentation.schedule.model.ScheduleEventUiStatus +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleTabPage( + pagerState: PagerState, + scheduleContent: ScheduleUiState.Content.Success, + onRefresh: (ScheduleEventsUiState.Content) -> Unit, + modifier: Modifier = Modifier, +) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.pulse_circle)) + val scrollState = rememberScrollState() + HorizontalPager( + state = pagerState, + modifier = modifier, + beyondViewportPageCount = PRELOAD_PAGE_COUNT, + ) { index -> + val scheduleEventsUiState = + scheduleContent.eventsUiStateByPosition[index] ?: return@HorizontalPager + + PullToRefreshContainer( + isRefreshing = scheduleEventsUiState.isRefreshing, + onRefresh = { onRefresh(scheduleEventsUiState.content) }, + ) { graphicsLayer -> + when (val content = scheduleEventsUiState.content) { + is ScheduleEventsUiState.Content.Error -> { + Timber.w(content.throwable.stackTraceToString()) + ErrorStateScreen( + modifier = + Modifier + .fillMaxSize() + .padding(end = festabookSpacing.paddingScreenGutter) + .then(graphicsLayer) + .verticalScroll(scrollState), + ) + } + + is ScheduleEventsUiState.Content.InitialLoading -> { + LoadingStateScreen() + } + + is ScheduleEventsUiState.Content.Success -> { + ScheduleTabContent( + scrollState = scrollState, + composition = composition, + scheduleEventsContent = content, + currentEventPosition = content.currentEventPosition, + modifier = + Modifier + .padding(end = festabookSpacing.paddingScreenGutter) + .then(graphicsLayer), + ) + } + } + } + } +} + +@Composable +private fun ScheduleTabContent( + scrollState: ScrollState, + composition: LottieComposition?, + scheduleEventsContent: ScheduleEventsUiState.Content.Success, + modifier: Modifier = Modifier, + currentEventPosition: Int = DEFAULT_POSITION, +) { + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + listState.animateScrollToItem(currentEventPosition) + } + if (scheduleEventsContent.isEventsEmpty) { + EmptyStateScreen( + modifier = + modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) + } else { + Box(modifier = modifier) { + VerticalDivider( + thickness = 1.dp, + color = FestabookColor.gray300, + modifier = + Modifier + .padding(start = festabookSpacing.paddingScreenGutter + festabookSpacing.paddingBody4), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(festabookSpacing.paddingBody5), + contentPadding = PaddingValues(vertical = festabookSpacing.paddingBody5), + state = listState, + ) { + items( + items = scheduleEventsContent.events, + key = { scheduleEvent -> scheduleEvent.id }, + ) { + ScheduleEventItem( + composition = composition, + scheduleEvent = it, + ) + } + } + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ScheduleTabContentPreview() { + FestabookTheme { + ScheduleTabContent( + scrollState = rememberScrollState(), + composition = null, + scheduleEventsContent = + ScheduleEventsUiState.Content.Success( + events = + listOf( + ScheduleEventUiModel( + id = 1, + status = ScheduleEventUiStatus.ONGOING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ScheduleEventUiModel( + id = 2, + status = ScheduleEventUiStatus.UPCOMING, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연 동아리 버스킹 공연 동아리 버스킹 공연", + location = "운동장", + ), + ScheduleEventUiModel( + id = 3, + status = ScheduleEventUiStatus.COMPLETED, + startTime = "9:00", + endTime = "18:00", + title = "동아리 버스킹 공연", + location = "운동장", + ), + ), + currentEventPosition = 0, + ), + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt new file mode 100644 index 00000000..80f6397a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/component/ScheduleTabRow.kt @@ -0,0 +1,87 @@ +package com.daedan.festabook.presentation.schedule.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.schedule.model.ScheduleDateUiModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ScheduleTabRow( + pageState: PagerState, + scope: CoroutineScope, + dates: List, + modifier: Modifier = Modifier, +) { + ScrollableTabRow( + edgePadding = festabookSpacing.paddingScreenGutter, + selectedTabIndex = pageState.currentPage, + containerColor = MaterialTheme.colorScheme.background, + indicator = { tabPositions -> + ScheduleTabIndicator(modifier = Modifier.tabIndicatorOffset(currentTabPosition = tabPositions[pageState.currentPage])) + }, + divider = {}, + modifier = modifier, + ) { + dates.forEachIndexed { index, scheduleDate -> + Tab( + selected = pageState.currentPage == index, + unselectedContentColor = FestabookColor.gray500, + selectedContentColor = MaterialTheme.colorScheme.background, + onClick = { scope.launch { pageState.animateScrollToPage(index) } }, + text = { Text(text = scheduleDate.date) }, + ) + } + } +} + +@Composable +private fun ScheduleTabIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(festabookSpacing.paddingBody1) + .fillMaxSize() + .cardBackground( + backgroundColor = FestabookColor.black, + borderStroke = 0.dp, + borderColor = FestabookColor.black, + shape = festabookShapes.radius4, + ).zIndex(-1f), + ) +} + +@Preview +@Composable +private fun ScheduleTabRowPreview() { + ScheduleTabRow( + pageState = rememberPagerState { 5 }, + scope = rememberCoroutineScope(), + dates = + listOf( + ScheduleDateUiModel(1, "11/12"), + ScheduleDateUiModel(2, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ScheduleDateUiModel(3, "11/13"), + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt index f22b60ca..482e5ff2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiModel.kt @@ -10,7 +10,6 @@ data class ScheduleEventUiModel( val endTime: String, val title: String, val location: String, - val isBookmarked: Boolean = false, ) fun ScheduleEvent.toUiModel(): ScheduleEventUiModel = diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt index 86d9eca6..9f0892dd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/model/ScheduleEventUiStatus.kt @@ -1,17 +1,14 @@ package com.daedan.festabook.presentation.schedule.model -import android.content.Context -import com.daedan.festabook.R - enum class ScheduleEventUiStatus { UPCOMING, ONGOING, COMPLETED, } -fun ScheduleEventUiStatus.toKoreanString(context: Context): String = - when (this) { - ScheduleEventUiStatus.UPCOMING -> context.getString(R.string.schedule_status_upcoming) - ScheduleEventUiStatus.ONGOING -> context.getString(R.string.schedule_status_ongoing) - ScheduleEventUiStatus.COMPLETED -> context.getString(R.string.schedule_status_completed) - } +// fun ScheduleEventUiStatus.toKoreanString(context: Context): String = +// when (this) { +// ScheduleEventUiStatus.UPCOMING -> context.getString(R.string.schedule_status_upcoming) +// ScheduleEventUiStatus.ONGOING -> context.getString(R.string.schedule_status_ongoing) +// ScheduleEventUiStatus.COMPLETED -> context.getString(R.string.schedule_status_completed) +// } diff --git a/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt new file mode 100644 index 00000000..6caad10f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/schedule/navigation/ScheduleNavigation.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.schedule.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.schedule.ScheduleViewModel +import com.daedan.festabook.presentation.schedule.component.ScheduleScreen + +fun NavGraphBuilder.scheduleNavGraph( + viewModel: ScheduleViewModel, + onShowErrorSnackbar: (Throwable) -> Unit, +) { + composable { + ScheduleScreen( + scheduleViewModel = viewModel, + onShowErrorSnackbar = onShowErrorSnackbar, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt deleted file mode 100644 index 05477317..00000000 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.daedan.festabook.presentation.setting - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.net.toUri -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import com.daedan.festabook.BuildConfig -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentSettingBinding -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.presentation.NotificationPermissionManager -import com.daedan.festabook.presentation.NotificationPermissionRequester -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar -import com.daedan.festabook.presentation.common.showSnackBar -import com.daedan.festabook.presentation.home.HomeViewModel -import com.daedan.festabook.presentation.home.adapter.FestivalUiState -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding -import timber.log.Timber - -@ContributesIntoMap( - scope = AppScope::class, - binding = binding(), -) -@FragmentKey(SettingFragment::class) -@Inject -class SettingFragment( - private val notificationPermissionManagerFactory: NotificationPermissionManager.Factory, -) : BaseFragment(), - NotificationPermissionRequester { - override val layoutId: Int = R.layout.fragment_setting - - @Inject - override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory - private val settingViewModel: SettingViewModel by viewModels({ requireActivity() }) - private val homeViewModel: HomeViewModel by viewModels({ requireActivity() }) - - private val notificationPermissionManager by lazy { - notificationPermissionManagerFactory.create( - requester = this, - onPermissionGranted = { onPermissionGranted() }, - onPermissionDenied = { onPermissionDenied() }, - ) - } - - override val permissionLauncher: ActivityResultLauncher = - registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted: Boolean -> - if (isGranted) { - Timber.d("Notification permission granted") - onPermissionGranted() - } else { - Timber.d("Notification permission denied") - showNotificationDeniedSnackbar(binding.root, requireContext()) - onPermissionDenied() - } - } - - override fun onPermissionGranted() { - settingViewModel.saveNotificationId() - } - - override fun onPermissionDenied() = Unit - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupBindings() - - setupNoticeAllowButtonClickListener() - setupServicePolicyClickListener() - setupContactUsButtonClickListener() - setupObservers() - } - - private fun setupBindings() { - val versionName = BuildConfig.VERSION_NAME - binding.tvSettingAppVersionName.text = versionName - } - - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) - - private fun setupObservers() { - settingViewModel.permissionCheckEvent.observe(viewLifecycleOwner) { - notificationPermissionManager.requestNotificationPermission( - requireContext(), - ) - } - settingViewModel.isAllowed.observe(viewLifecycleOwner) { - binding.btnNoticeAllow.isChecked = it - } - settingViewModel.success.observe(viewLifecycleOwner) { - requireActivity().showSnackBar(getString(R.string.setting_notice_enabled)) - } - settingViewModel.error.observe(viewLifecycleOwner) { throwable -> - showErrorSnackBar(throwable) - } - settingViewModel.isLoading.observe(viewLifecycleOwner) { loading -> - binding.btnNoticeAllow.isEnabled = !loading - } - - homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> - when (state) { - is FestivalUiState.Error -> { - showErrorSnackBar(state.throwable) - Timber.w( - state.throwable, - "${this::class.simpleName}: ${state.throwable.message}", - ) - } - - FestivalUiState.Loading -> { - binding.tvSettingCurrentUniversityNotice.text = "" - } - - is FestivalUiState.Success -> { - binding.tvSettingCurrentUniversity.text = state.organization.universityName - } - } - } - } - - private fun setupServicePolicyClickListener() { - binding.tvSettingServicePolicy.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, POLICY_URL.toUri()) - startActivity(intent) - } - } - - private fun setupContactUsButtonClickListener() { - binding.tvSettingContactUs.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, CONTACT_US_URL.toUri()) - startActivity(intent) - } - } - - private fun setupNoticeAllowButtonClickListener() { - binding.btnNoticeAllow.setOnClickListener { - // 기본적으로 클릭했을 때 checked되는 기능 무효화 - binding.btnNoticeAllow.isChecked = !binding.btnNoticeAllow.isChecked - settingViewModel.notificationAllowClick() - } - } - - companion object { - private const val POLICY_URL: String = - "https://www.notion.so/244a540dc0b780638e56e31c4bdb3c9f" - - private const val CONTACT_US_URL = - "https://forms.gle/XjqJFfQrTPgkZzGZ9" - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index e73dbeb6..dd9e7fdb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -1,45 +1,64 @@ package com.daedan.festabook.presentation.setting -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope import com.daedan.festabook.domain.repository.FestivalNotificationRepository -import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber @ContributesIntoMap(AppScope::class) @ViewModelKey(SettingViewModel::class) -class SettingViewModel @Inject constructor( +@Inject +class SettingViewModel( private val festivalNotificationRepository: FestivalNotificationRepository, ) : ViewModel() { - private val _permissionCheckEvent: SingleLiveData = SingleLiveData() - val permissionCheckEvent: LiveData get() = _permissionCheckEvent + private val _permissionCheckEvent: MutableSharedFlow = + MutableSharedFlow() + val permissionCheckEvent: SharedFlow = _permissionCheckEvent.asSharedFlow() private val _isAllowed = - MutableLiveData( + MutableStateFlow( festivalNotificationRepository.getFestivalNotificationIsAllow(), ) - val isAllowed: LiveData get() = _isAllowed + val isAllowed: StateFlow = _isAllowed.asStateFlow() - private val _error: SingleLiveData = SingleLiveData() - val error: LiveData get() = _error + private val _error: MutableSharedFlow = + MutableSharedFlow() + val error: SharedFlow = _error.asSharedFlow() - private val _isLoading: MutableLiveData = MutableLiveData(false) - val isLoading: LiveData get() = _isLoading + private val _isLoading: MutableStateFlow = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() - private val _success: SingleLiveData = SingleLiveData() - val success: LiveData get() = _success + private val _success: MutableSharedFlow = + MutableSharedFlow() + + val success = _success.asSharedFlow() + + init { + viewModelScope.launch { + festivalNotificationRepository + .syncFestivalNotificationIsAllow() + .onSuccess { + _isAllowed.emit(it) + } + } + } fun notificationAllowClick() { - if (_isAllowed.value == false) { - _permissionCheckEvent.setValue(Unit) + if (!_isAllowed.value) { + viewModelScope.launch { + _permissionCheckEvent.emit(Unit) + } } else { deleteNotificationId() } @@ -54,21 +73,22 @@ class SettingViewModel @Inject constructor( } fun saveNotificationId() { - if (_isLoading.value == true) return + if (_isLoading.value) return _isLoading.value = true // Optimistic UI 적용, 요청 실패 시 원복 saveNotificationIsAllowed(true) updateNotificationIsAllowed(true) - _success.setValue(Unit) viewModelScope.launch { + _success.emit(Unit) + val result = festivalNotificationRepository.saveFestivalNotification() result .onFailure { - _error.setValue(it) + _error.emit(it) saveNotificationIsAllowed(false) updateNotificationIsAllowed(false) Timber.e(it, "${this::class.java.simpleName} NotificationId 저장 실패") @@ -79,7 +99,7 @@ class SettingViewModel @Inject constructor( } private fun deleteNotificationId() { - if (_isLoading.value == true) return + if (_isLoading.value) return _isLoading.value = true // Optimistic UI 적용, 요청 실패 시 원복 @@ -92,7 +112,7 @@ class SettingViewModel @Inject constructor( result .onFailure { - _error.setValue(it) + _error.emit(it) saveNotificationIsAllowed(true) updateNotificationIsAllowed(true) Timber.e(it, "${this::class.java.simpleName} NotificationId 삭제 실패") diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt new file mode 100644 index 00000000..2daa6027 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -0,0 +1,358 @@ +package com.daedan.festabook.presentation.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.BuildConfig +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.common.ObserveAsEvents +import com.daedan.festabook.presentation.common.component.FestabookSwitch +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing +import timber.log.Timber +import java.time.LocalDate + +private const val POLICY_URL: String = + "https://www.notion.so/244a540dc0b780638e56e31c4bdb3c9f" + +private const val CONTACT_US_URL = + "https://forms.gle/XjqJFfQrTPgkZzGZ9" + +@Composable +fun SettingRoute( + homeViewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() + val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() + val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + notificationPermissionManager.requestNotificationPermission(context) + } + + ObserveAsEvents(flow = settingViewModel.success) { + onShowSnackBar(context.getString(R.string.setting_notice_enabled)) + } + + ObserveAsEvents(flow = settingViewModel.error) { + onShowErrorSnackBar(it) + } + SettingScreen( + modifier = modifier, + festivalUiState = festival, + isUniversitySubscribed = isUniversitySubscribed, + appVersion = BuildConfig.VERSION_NAME, + isSubscribeEnabled = !isSubscribedLoading, + onSubscribeClick = { settingViewModel.notificationAllowClick() }, + onPolicyClick = { uriHandler.openUri(POLICY_URL) }, + onContactUsClick = { uriHandler.openUri(CONTACT_US_URL) }, + onError = { + onShowErrorSnackBar(it.throwable) + Timber.w( + it.throwable, + "${"SettingRoute"}: ${it.throwable.message}", + ) + }, + ) +} + +@Composable +fun SettingScreen( + festivalUiState: FestivalUiState, + isUniversitySubscribed: Boolean, + appVersion: String, + isSubscribeEnabled: Boolean, + modifier: Modifier = Modifier, + onSubscribeClick: (Boolean) -> Unit = {}, + onPolicyClick: () -> Unit = {}, + onContactUsClick: () -> Unit = {}, + onError: (FestivalUiState.Error) -> Unit = {}, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + val currentOnError by rememberUpdatedState(onError) + + LaunchedEffect(festivalUiState) { + when (festivalUiState) { + is FestivalUiState.Error -> currentOnError(festivalUiState) + else -> Unit + } + } + + Scaffold( + topBar = { + FestabookTopAppBar( + title = stringResource(R.string.setting_title), + ) + }, + modifier = modifier, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding(innerPadding), + ) { + when (festivalUiState) { + is FestivalUiState.Success -> { + SubscriptionContent( + universityName = festivalUiState.organization.universityName, + isUniversitySubscribed = isUniversitySubscribed, + onSubscribeClick = onSubscribeClick, + isSubscribeEnabled = isSubscribeEnabled, + ) + } + + else -> { + Unit + } + } + + HorizontalDivider( + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = 20.dp), + color = FestabookColor.gray100, + thickness = festabookSpacing.paddingBody2, + ) + + AppInfoContent( + appVersion = appVersion, + onPolicyClick = onPolicyClick, + onContactUsClick = onContactUsClick, + ) + } + } +} + +@Composable +private fun SubscriptionContent( + universityName: String, + isUniversitySubscribed: Boolean, + isSubscribeEnabled: Boolean, + onSubscribeClick: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.setting_notice_title), + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(top = 20.dp), + ) + + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.setting_current_university_notice), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = FestabookTypography.titleMedium, + modifier = + Modifier.padding( + top = festabookSpacing.paddingBody3, + ), + ) + + Text( + text = universityName, + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = FestabookColor.gray500, + ) + } + + FestabookSwitch( + enabled = isSubscribeEnabled, + checked = isUniversitySubscribed, + onCheckedChange = onSubscribeClick, + ) + } + } +} + +@Composable +private fun AppInfoContent( + appVersion: String, + onPolicyClick: () -> Unit, + onContactUsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.setting_app_info_title), + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody3), + style = FestabookTypography.bodyMedium, + ) + + AppVersionInfo( + appVersion = appVersion, + ) + + AppInfoButton( + text = stringResource(R.string.setting_service_policy), + onClick = onPolicyClick, + ) + AppInfoButton( + text = stringResource(R.string.setting_contact_us), + onClick = onContactUsClick, + ) + } +} + +@Composable +private fun AppVersionInfo( + appVersion: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .padding(vertical = festabookSpacing.paddingBody3), + ) { + Text( + text = stringResource(R.string.setting_app_version), + style = FestabookTypography.titleMedium, + ) + + Text( + text = appVersion, + style = FestabookTypography.bodyMedium, + ) + } +} + +@Composable +private fun AppInfoButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .requiredWidth(screenWidthDp) + .clickable { + onClick() + }.padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody3, + ), + ) { + Text( + text = text, + style = FestabookTypography.titleMedium, + ) + + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_right), + contentDescription = stringResource(R.string.move), + tint = Color.Unspecified, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + FestabookTheme { + var isSubscribed by remember { mutableStateOf(false) } + SettingScreen( + festivalUiState = + FestivalUiState.Success( + Organization( + id = 1, + universityName = "성균관대학교 인문사회과학철학문학자연캠퍼스 인문사회과학철학문학자연캠퍼스", + festival = + Festival( + festivalName = "성균관대학교 축제축제축제축제축제축제축제축제축제축제축제축제", + festivalImages = listOf(), + startDate = LocalDate.of(2026, 1, 1), + endDate = LocalDate.of(2026, 2, 1), + ), + ), + ), + isUniversitySubscribed = isSubscribed, + onSubscribeClick = { isSubscribed = !isSubscribed }, + appVersion = "v1.0.0", + isSubscribeEnabled = true, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt new file mode 100644 index 00000000..a7ab51fc --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/navigation/SettingNavigation.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.setting.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.presentation.NotificationPermissionManager +import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.main.MainTabRoute +import com.daedan.festabook.presentation.setting.SettingViewModel +import com.daedan.festabook.presentation.setting.component.SettingRoute + +fun NavGraphBuilder.settingNavGraph( + homeViewModel: HomeViewModel, + settingViewModel: SettingViewModel, + notificationPermissionManager: NotificationPermissionManager, + onShowSnackBar: (String) -> Unit, + onShowErrorSnackBar: (Throwable) -> Unit, +) { + composable { + SettingRoute( + homeViewModel = homeViewModel, + settingViewModel = settingViewModel, + notificationPermissionManager = notificationPermissionManager, + onShowSnackBar = onShowSnackBar, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt index 3ab5fd01..68906707 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashActivity.kt @@ -2,33 +2,37 @@ package com.daedan.festabook.presentation.splash import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.FestaBookApp -import com.daedan.festabook.R import com.daedan.festabook.presentation.explore.ExploreActivity import com.daedan.festabook.presentation.main.MainActivity +import com.daedan.festabook.presentation.splash.component.NetworkErrorDialog +import com.daedan.festabook.presentation.splash.component.UpdateDialog +import com.daedan.festabook.presentation.theme.FestabookTheme import dev.zacsweers.metro.Inject -import kotlinx.coroutines.launch class SplashActivity : AppCompatActivity() { private val viewModel: SplashViewModel by viewModels() - private val launcher by lazy { + + private val updateResultLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult(), ) { result -> if (result.resultCode == RESULT_OK) { - setupObserver() + viewModel.handleVersionCheckResult(Result.success(false)) } else { - exitDialog().show() + viewModel.handleVersionCheckResult(Result.failure(Exception("Update failed"))) } } - } @Inject override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory @@ -36,59 +40,64 @@ class SplashActivity : AppCompatActivity() { @Inject private lateinit var appVersionManagerFactory: AppVersionManager.Factory - private val appVersionManager by lazy { appVersionManagerFactory.create(launcher) } + private val appVersionManager by lazy { appVersionManagerFactory.create(updateResultLauncher) } override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen().setKeepOnScreenCondition { - viewModel.isValidationComplete.value != true - } - enableEdgeToEdge() - super.onCreate(savedInstanceState) (application as FestaBookApp).festaBookGraph.inject(this) - setContentView(R.layout.activity_splash) - checkIsAppUpdateAvailable { - setupObserver() - } - } - private fun checkIsAppUpdateAvailable(onSuccess: () -> Unit) { - if (!isNetworkConnected()) { - exitDialog().show() - return + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { + viewModel.uiState.value is SplashUiState.Loading } - lifecycleScope.launch { - appVersionManager - .getIsAppUpdateAvailable() - .onSuccess { isUpdateAvailable -> - if (isUpdateAvailable) { - updateDialog { - appVersionManager.updateApp() - }.show() - } else { - onSuccess() - } - }.onFailure { - exitDialog().show() + enableEdgeToEdge() + + setContent { + FestabookTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + // 앱 실행 시 즉시 앱 버전 업데이트의 필요 유무 확인 + val result = appVersionManager.getIsAppUpdateAvailable() + viewModel.handleVersionCheckResult(result) } - } - } - private fun setupObserver() { - viewModel.navigationState.observe(this) { state -> - when (state) { - is NavigationState.NavigateToExplore -> { - // ExploreActivity로 이동 - val intent = Intent(this@SplashActivity, ExploreActivity::class.java) - startActivity(intent) - finish() + LaunchedEffect(uiState) { + when (val state = uiState) { + is SplashUiState.NavigateToExplore -> { + startActivity(Intent(this@SplashActivity, ExploreActivity::class.java)) + finish() + } + + is SplashUiState.NavigateToMain -> { + val intent = + Intent(this@SplashActivity, MainActivity::class.java).apply { + putExtra("festivalId", state.festivalId) + } + startActivity(intent) + finish() + } + + else -> {} + } } - is NavigationState.NavigateToMain -> { - // MainActivity로 이동 - val intent = Intent(this@SplashActivity, MainActivity::class.java) - startActivity(intent) - finish() + when (uiState) { + is SplashUiState.ShowUpdateDialog -> { + UpdateDialog( + onConfirm = { appVersionManager.updateApp() }, + ) + } + + is SplashUiState.ShowNetworkErrorDialog -> { + NetworkErrorDialog( + onConfirm = { finish() }, + ) + } + + else -> {} } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt new file mode 100644 index 00000000..603ef5a5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashUiState.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.splash + +sealed interface SplashUiState { + data object Loading : SplashUiState + + data object ShowUpdateDialog : SplashUiState + + data object ShowNetworkErrorDialog : SplashUiState + + data class NavigateToMain( + val festivalId: Long, + ) : SplashUiState + + data object NavigateToExplore : SplashUiState +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt index 8f149472..371554fc 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/SplashViewModel.kt @@ -1,41 +1,53 @@ package com.daedan.festabook.presentation.splash -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.daedan.festabook.data.datasource.local.FestivalLocalDataSource import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.di.viewmodel.ViewModelScope -import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import timber.log.Timber @ContributesIntoMap(AppScope::class) @ViewModelKey(SplashViewModel::class) -class SplashViewModel @Inject constructor( +@Inject +class SplashViewModel( private val festivalLocalDataSource: FestivalLocalDataSource, + private val iODispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { - private val _navigationState = SingleLiveData() - val navigationState: LiveData = _navigationState + private val _uiState = MutableStateFlow(SplashUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() - private val _isValidationComplete = MutableLiveData(false) - val isValidationComplete: LiveData = _isValidationComplete - - init { - checkFestivalId() + fun handleVersionCheckResult(result: Result) { + result + .onSuccess { isUpdateAvailable -> + if (isUpdateAvailable) { + _uiState.value = SplashUiState.ShowUpdateDialog + } else { + checkFestivalId() + } + }.onFailure { + _uiState.value = SplashUiState.ShowNetworkErrorDialog + } } private fun checkFestivalId() { - val festivalId = festivalLocalDataSource.getFestivalId() - Timber.d("festival ID : $festivalId") - - if (festivalId == null) { - _navigationState.setValue(NavigationState.NavigateToExplore) - } else { - _navigationState.setValue(NavigationState.NavigateToMain(festivalId)) + viewModelScope.launch(iODispatcher) { + val festivalId = festivalLocalDataSource.getFestivalId() + Timber.d("현재 접속중인 festival ID : $festivalId") + _uiState.value = + if (festivalId == null) { + SplashUiState.NavigateToExplore + } else { + SplashUiState.NavigateToMain(festivalId) + } } - _isValidationComplete.value = true } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt new file mode 100644 index 00000000..179ba6f7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashDialogs.kt @@ -0,0 +1,140 @@ +package com.daedan.festabook.presentation.splash.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.daedan.festabook.R +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookShapes + +@Composable +fun UpdateDialog(onConfirm: () -> Unit) { + SplashInfoDialog( + title = stringResource(id = R.string.update_notice_title), + message = stringResource(id = R.string.update_notice_message), + buttonText = stringResource(id = R.string.update_notice_confirm), + iconResId = R.drawable.ic_alarm, + confirmButtonColor = FestabookColor.accentBlue, + onConfirm = onConfirm, + ) +} + +@Composable +fun NetworkErrorDialog(onConfirm: () -> Unit) { + SplashInfoDialog( + title = stringResource(id = R.string.update_failed_title), + message = stringResource(id = R.string.update_failed_message), + buttonText = stringResource(id = R.string.update_failed_confirm), + confirmButtonColor = FestabookColor.gray400, + onConfirm = onConfirm, + ) +} + +@Composable +private fun SplashInfoDialog( + title: String, + message: String, + buttonText: String, + confirmButtonColor: Color, + @DrawableRes iconResId: Int? = null, + onConfirm: () -> Unit, +) { + Dialog( + onDismissRequest = {}, + properties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) { + Column( + modifier = + Modifier + .background( + color = FestabookColor.white, + shape = festabookShapes.radius4, + ).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + iconResId?.let { + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = title, + style = FestabookTypography.displaySmall, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = message, + style = FestabookTypography.bodyMedium, + textAlign = TextAlign.Center, + color = FestabookColor.gray800, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onConfirm, + modifier = + Modifier + .fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = confirmButtonColor, + contentColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + ) { + Text(text = buttonText) + } + } + } +} + +@Preview +@Composable +private fun UpdateDialogPreview() { + FestabookTheme { + UpdateDialog(onConfirm = {}) + } +} + +@Preview +@Composable +private fun NetworkErrorDialogPreview() { + FestabookTheme { + NetworkErrorDialog(onConfirm = {}) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashScreen.kt new file mode 100644 index 00000000..f8dcd45d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/component/SplashScreen.kt @@ -0,0 +1,59 @@ +package com.daedan.festabook.presentation.splash.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.daedan.festabook.presentation.splash.AppVersionManager +import com.daedan.festabook.presentation.splash.SplashUiState +import com.daedan.festabook.presentation.splash.SplashViewModel + +@Composable +fun SplashScreen( + viewModel: SplashViewModel, + appVersionManager: AppVersionManager, + onNavigateToExplore: () -> Unit, + onNavigateToMain: (Long) -> Unit, + onFinishApp: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentOnNavigateToMain by rememberUpdatedState(onNavigateToMain) + val currentOnNavigateToExplore by rememberUpdatedState(onNavigateToExplore) + + LaunchedEffect(Unit) { + // 앱 실행 시 즉시 앱 버전 업데이트의 필요 유무 확인 + val result = appVersionManager.getIsAppUpdateAvailable() + viewModel.handleVersionCheckResult(result) + } + + LaunchedEffect(uiState) { + when (val state = uiState) { + is SplashUiState.NavigateToExplore -> { + currentOnNavigateToExplore() + } + + is SplashUiState.NavigateToMain -> { + currentOnNavigateToMain(state.festivalId) + } + + else -> {} + } + } + + when (uiState) { + is SplashUiState.ShowUpdateDialog -> { + UpdateDialog( + onConfirm = { appVersionManager.updateApp() }, + ) + } + + is SplashUiState.ShowNetworkErrorDialog -> { + NetworkErrorDialog( + onConfirm = onFinishApp, + ) + } + + else -> {} + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt b/app/src/main/java/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt new file mode 100644 index 00000000..03619e2c --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/splash/navigation/SplashNavigation.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.splash.navigation + +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.daedan.festabook.di.FestaBookAppGraph +import com.daedan.festabook.presentation.main.FestabookRoute +import com.daedan.festabook.presentation.splash.AppVersionManager +import com.daedan.festabook.presentation.splash.component.SplashScreen + +fun NavGraphBuilder.splashNavGraph( + appGraph: FestaBookAppGraph, + appVersionManager: AppVersionManager, + onNavigateToExplore: () -> Unit, + onNavigateToMain: (Long) -> Unit, + onFinishApp: () -> Unit, +) { + composable { + SplashScreen( + viewModel = viewModel(factory = appGraph.metroViewModelFactory), + appVersionManager = appVersionManager, + onNavigateToExplore = onNavigateToExplore, + onNavigateToMain = onNavigateToMain, + onFinishApp = onFinishApp, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt index 4f870a94..da3ce460 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookColor.kt @@ -1,21 +1,29 @@ package com.daedan.festabook.presentation.theme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -object FestabookColor { - val black = Color(0xFF1B1B1B) - val gray800 = Color(0xFF393939) - val gray700 = Color(0xFF555555) - val gray600 = Color(0xFF717171) - val gray500 = Color(0xFF8E8E8E) - val gray400 = Color(0xFFAAAAAA) - val gray300 = Color(0xFFC6C6C6) - val gray200 = Color(0xFFE3E3E3) - val gray100 = Color(0xFFF7F7F7) - val white = Color(0xFFFAFAFA) +data class FestabookColorPalette( + val black: Color = Color(0xFF1B1B1B), + val gray800: Color = Color(0xFF393939), + val gray700: Color = Color(0xFF555555), + val gray600: Color = Color(0xFF717171), + val gray500: Color = Color(0xFF8E8E8E), + val gray400: Color = Color(0xFFAAAAAA), + val gray300: Color = Color(0xFFC6C6C6), + val gray200: Color = Color(0xFFE3E3E3), + val gray100: Color = Color(0xFFF7F7F7), + val white: Color = Color(0xFFFAFAFA), + val accentBlue: Color = Color(0xFF0094FF), + val accentGreen: Color = Color(0xFF00AB40), + val error: Color = Color(0xFFFF4B3E), +) - val accentBlue = Color(0xFF0094FF) - val accentGreen = Color(0xFF00AB40) +val LocalColor = staticCompositionLocalOf { FestabookColorPalette() } - val error = Color(0xFFFF4B3E) -} +val FestabookColor: FestabookColorPalette + @Composable + @ReadOnlyComposable + get() = LocalColor.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt new file mode 100644 index 00000000..dda2359f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookShapes.kt @@ -0,0 +1,24 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +data class FestabookShapes( + val radius1: CornerBasedShape = RoundedCornerShape(6.dp), + val radius2: CornerBasedShape = RoundedCornerShape(10.dp), + val radius3: CornerBasedShape = RoundedCornerShape(16.dp), + val radius4: CornerBasedShape = RoundedCornerShape(20.dp), + val radius5: CornerBasedShape = RoundedCornerShape(24.dp), + val radiusFull: CornerBasedShape = RoundedCornerShape(999.dp), +) + +val LocalShapes = staticCompositionLocalOf { FestabookShapes() } + +val festabookShapes: FestabookShapes + @Composable + @ReadOnlyComposable + get() = LocalShapes.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt new file mode 100644 index 00000000..79e3ea4a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookSpacing.kt @@ -0,0 +1,24 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class FestabookSpacing( + val paddingScreenGutter: Dp = 16.dp, + val paddingTitleHorizontal: Dp = 40.dp, + val paddingBody1: Dp = 4.dp, + val paddingBody2: Dp = 8.dp, + val paddingBody3: Dp = 12.dp, + val paddingBody4: Dp = 16.dp, + val paddingBody5: Dp = 20.dp, +) + +val LocalSpacing = staticCompositionLocalOf { FestabookSpacing() } + +val festabookSpacing + @Composable + @ReadOnlyComposable + get() = LocalSpacing.current diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt new file mode 100644 index 00000000..69353cf5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTheme.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider + +private val LightColorScheme + @Composable + get() = + lightColorScheme( + background = FestabookColor.white, + ) + +@Composable +fun FestabookTheme(content: @Composable () -> Unit) { + val spacing = FestabookSpacing() + val shapes = FestabookShapes() + val color = FestabookColorPalette() + val typography = FestabookTypographies + + CompositionLocalProvider( + LocalSpacing provides spacing, + LocalShapes provides shapes, + LocalColor provides color, + LocalTypography provides typography, + ) { + MaterialTheme( + colorScheme = LightColorScheme, + typography = FestabookTypography, + content = content, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt index da30f20d..1f184887 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/theme/FestabookTypography.kt @@ -1,6 +1,9 @@ package com.daedan.festabook.presentation.theme import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -11,7 +14,7 @@ private val PretendardBold = FontFamily(Font(R.font.pretendard_bold)) private val PretendardMedium = FontFamily(Font(R.font.pretendard_medium)) private val PretendardRegular = FontFamily(Font(R.font.pretendard_regular)) -val FestabookTypography = +val FestabookTypographies = Typography( displayLarge = TextStyle( @@ -74,3 +77,10 @@ val FestabookTypography = fontSize = 10.sp, ), ) + +val LocalTypography = staticCompositionLocalOf { FestabookTypographies } + +val FestabookTypography: Typography + @Composable + @ReadOnlyComposable + get() = LocalTypography.current diff --git a/app/src/main/java/com/daedan/festabook/service/NotificationHelper.kt b/app/src/main/java/com/daedan/festabook/service/NotificationHelper.kt index fcfd9e9f..c4401c49 100644 --- a/app/src/main/java/com/daedan/festabook/service/NotificationHelper.kt +++ b/app/src/main/java/com/daedan/festabook/service/NotificationHelper.kt @@ -9,8 +9,7 @@ import androidx.core.content.ContextCompat import com.daedan.festabook.R import com.daedan.festabook.presentation.common.vectorToBitmap import com.daedan.festabook.presentation.main.MainActivity -import com.daedan.festabook.presentation.main.MainActivity.Companion.KEY_CAN_NAVIGATE_TO_NEWS -import com.daedan.festabook.presentation.main.MainActivity.Companion.KEY_NOTICE_ID_TO_EXPAND +import com.daedan.festabook.presentation.platform.DeepLinkKeys object NotificationHelper { private const val CHANNEL_ID = "notice_channel" @@ -41,8 +40,8 @@ object NotificationHelper { ) { val intent = MainActivity.newIntent(context).apply { - putExtra(KEY_CAN_NAVIGATE_TO_NEWS, true) - putExtra(KEY_NOTICE_ID_TO_EXPAND, announcementId.toLongOrNull()) + putExtra(DeepLinkKeys.KEY_CAN_NAVIGATE_TO_NEWS, true) + putExtra(DeepLinkKeys.KEY_NOTICE_ID_TO_EXPAND, announcementId.toLongOrNull()) } val pendingIntent = diff --git a/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt b/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt index 37469d21..b3cd2a45 100644 --- a/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/util/FestabookGlobalExceptionHandler.kt @@ -2,18 +2,20 @@ package com.daedan.festabook.util import android.app.Application import android.os.Process -import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.error.ErrorActivity import kotlin.system.exitProcess class FestabookGlobalExceptionHandler( private val application: Application, ) : Thread.UncaughtExceptionHandler { - override fun uncaughtException(t: Thread, e: Throwable) { + override fun uncaughtException( + t: Thread, + e: Throwable, + ) { application.startActivity( - ErrorActivity.newIntent(application, e) + ErrorActivity.newIntent(application, e), ) Process.killProcess(Process.myPid()) exitProcess(-1) } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/ic_fail_load.xml b/app/src/main/res/drawable/ic_fail_load.xml new file mode 100644 index 00000000..1a8009f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_fail_load.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/logo_festabook_icon.png b/app/src/main/res/drawable/logo_festabook_icon.png new file mode 100644 index 00000000..9685e44d Binary files /dev/null and b/app/src/main/res/drawable/logo_festabook_icon.png differ diff --git a/app/src/main/res/layout/activity_explore.xml b/app/src/main/res/layout/activity_explore.xml deleted file mode 100644 index b202d406..00000000 --- a/app/src/main/res/layout/activity_explore.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bb9dc516..f34945fb 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,56 +1,67 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto"> - + android:layout_height="0dp" /> - + - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/app/src/main/res/layout/activity_place_detail.xml b/app/src/main/res/layout/activity_place_detail.xml index 48cf7190..6a3835af 100644 --- a/app/src/main/res/layout/activity_place_detail.xml +++ b/app/src/main/res/layout/activity_place_detail.xml @@ -64,7 +64,7 @@ app:ci_animator="@animator/scale_with_alpha" app:layout_constraintBottom_toBottomOf="@id/vp_place_images" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent"/> + app:layout_constraintEnd_toEndOf="parent" /> + app:layout_constraintTop_toTopOf="@id/vp_place_images" /> - + app:layout_constraintTop_toBottomOf="@id/vp_place_images" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_detail_preview.xml b/app/src/main/res/layout/fragment_place_detail_preview.xml deleted file mode 100644 index 52844bbc..00000000 --- a/app/src/main/res/layout/fragment_place_detail_preview.xml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml b/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml deleted file mode 100644 index c25892c8..00000000 --- a/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_list.xml b/app/src/main/res/layout/fragment_place_list.xml deleted file mode 100644 index 812dedb7..00000000 --- a/app/src/main/res/layout/fragment_place_list.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_map.xml b/app/src/main/res/layout/fragment_place_map.xml index e1d7e7f6..6734ddfe 100644 --- a/app/src/main/res/layout/fragment_place_map.xml +++ b/app/src/main/res/layout/fragment_place_map.xml @@ -1,68 +1,50 @@ - - - - - - - - - - + app:layout_constraintTop_toTopOf="parent" /> - - + + + app:layout_constraintTop_toBottomOf="@id/cv_time_tag_spinner" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/fcv_place_category_container" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_place_list_skeleton.xml b/app/src/main/res/layout/item_place_list_skeleton.xml deleted file mode 100644 index 2af06106..00000000 --- a/app/src/main/res/layout/item_place_list_skeleton.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_result.xml b/app/src/main/res/layout/item_search_result.xml deleted file mode 100644 index 077cf8af..00000000 --- a/app/src/main/res/layout/item_search_result.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cca637b8..62ecd68f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 현재 표시할 항목이 없습니다. + %1$s ~ %2$s @@ -16,6 +17,11 @@ poster_image 오늘 + 일정 화면으로 이동하는 버튼 + 일정 확인하기 + 축제 라인업 + 탐색 화면으로 이동하는 버튼 + 한 눈에 보기 @@ -36,7 +42,7 @@ 어떤 축제로 떠나볼까요? - explore_festabook_logo + festabook_logo 철자가 맞는지 확인해주세요 오류가 발생했습니다: %1$s @@ -91,7 +97,11 @@ 공지 아이콘 고정핀 아이콘 위치 아이콘 + 카테고리 아이콘 부스 이미지 + 운영 시간 아이콘 + 호스트 아이콘 + 상세화면 나가기 아이콘 공지사항이 없습니다 새로고침 플로팅 지도 버튼 @@ -104,6 +114,7 @@ 북마크 festabook + 로딩 실패 아이콘 확인 @@ -111,7 +122,7 @@ 잘못된 요청입니다 현재 연결이 불안정합니다. 잠시 후 다시 시도해주세요 알 수 없는 에러가 발생했습니다 잠시 후 다시 시도해주세요 - 정보를 불러오는데 실패했습니다 + 축제를 불러올 수 없어요 알림 권한 필요 @@ -123,8 +134,7 @@ 알림 받기 다음에 item_lineup_image - 일정 확인하기 - 축제 라인업 + 뒤로가기를 한 번 더 누르면 종료됩니다. @@ -135,15 +145,14 @@ 알림 새로운 소식이 있습니다. - 탐색 화면으로 이동하는 버튼 탐색 화면 닫기 버튼 신규 버전 출시 안내 - 새로운 버전이 출시되었습니다.\n 더 나은 사용을 위해 앱을 업데이트해 주세요 + 새로운 버전이 출시되었어요.\n 더 나은 사용을 위해 앱을 업데이트해 주세요. 업데이트 서버 통신 실패 - 앱 서버에 접속할 수 없습니다. 다시 시도해주세요 + 앱 서버에 접속할 수 없습니다. 다시 시도해주세요. 종료 홈화면으로 돌아가기 축제 검색 @@ -152,4 +161,5 @@ 앗! 잠시 문제가 생겼어요 이용에 불편을 드려 정말 죄송합니다. \n 앱을 다시 시작해 주시면 대부분 해결되니, \n 잠시 후에 앱을 다시 실행해주세요 ! 앱 재시작하기 + 뒤로가기 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 43024fe6..91ab0121 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,14 +1,15 @@ -