diff --git a/.github/actions/add_labels/action.yml b/.github/actions/add_labels/action.yml
new file mode 100644
index 00000000..418d1786
--- /dev/null
+++ b/.github/actions/add_labels/action.yml
@@ -0,0 +1,81 @@
+name: Add Labels Action
+
+description: "Add labels to a pull request based on its title"
+
+inputs:
+ title:
+ description: "title of the pull request or issue"
+ required: true
+
+runs:
+ using: 'composite'
+ steps:
+
+ - name: Input title
+ shell: bash
+ run: |
+ echo "Input Title: ${{ inputs.title }}"
+
+ - name: add FEAT โจ labels
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(inputs.title, 'FEAT') }}
+ with:
+ labels: |
+ AN_FEAT โจ
+
+ - name: add UI ๐จ labels
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(inputs.title, 'UI') }}
+ with:
+ labels: |
+ AN_UI ๐จ
+
+ - name: add REFACTOR โ๏ธ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(inputs.title, 'REFACTOR') }}
+ with:
+ labels: |
+ AN_REFACTOR โ๏ธ
+
+ - name: add FIX ๐ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(inputs.title, 'FIX') }}
+ with:
+ labels: |
+ AN_FIX ๐
+
+ - name: add CI/CD ๐ค label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(inputs.title, 'CI') || contains(inputs.title, 'CD') }}
+ with:
+ labels: |
+ AN_CI/CD ๐ค
+
+ - name: add CONFIG ๐งญ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(inputs.title, 'CONFIG') }}
+ with:
+ labels: |
+ AN_CONFIG ๐งญ
+
+ - name: Extract Version Name
+ shell: bash
+ env:
+ title: ${{ inputs.title }}
+ run: |
+ version=$(echo '${{ env.title }}' | grep -oP '\d+\.\d+\.\d+')
+ if [ -z "$version" ]; then
+ echo "No version found in the title."
+ echo "version=none" >> $GITHUB_ENV
+ else
+ echo "version=v$version" >> $GITHUB_ENV
+ fi
+
+ - name: Add version ๐ท๏ธ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ env.version != 'none' }} # ๋ฒ์ ์ด ์กด์ฌํ ๋๋ง ์คํ
+ with:
+ labels: |
+ ${{ env.version }} ๐ท๏ธ
+
+
diff --git a/.github/actions/ktlint_check/action.yml b/.github/actions/ktlint_check/action.yml
new file mode 100644
index 00000000..ad430fa0
--- /dev/null
+++ b/.github/actions/ktlint_check/action.yml
@@ -0,0 +1,50 @@
+name: 'ktLint Check'
+
+description: 'Run ktLint Check using Gradle'
+
+inputs:
+ POKE_BASE_URL:
+ description: 'Base URL for local.properties'
+ required: true
+
+
+runs:
+ using: 'composite'
+ steps:
+
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ - name: Create Local Properties
+ shell: bash
+ run: touch local.properties
+ working-directory: ./android
+
+ - name: Access Local Properties
+ shell: bash
+ run: |
+ echo POKE_BASE_URL=\"${{ inputs.POKE_BASE_URL }}\" >> local.properties
+ working-directory: ./android
+
+ - name: Grant execute permission for gradlew
+ shell: bash
+ run: chmod +x gradlew
+ working-directory: ./android
+
+ - name: Lint Check
+ shell: bash
+ run: ./gradlew ktlintCheck
+ working-directory: ./android
diff --git a/.github/actions/reviewers.yml b/.github/actions/reviewers.yml
new file mode 100644
index 00000000..e52314ce
--- /dev/null
+++ b/.github/actions/reviewers.yml
@@ -0,0 +1,10 @@
+addReviewers: true
+addAssignees: author
+
+reviewers:
+ - kkosang
+ - sh1mj1
+ - murjune
+ - JoYehyun99
+
+numberOfReviewers: 3
\ No newline at end of file
diff --git a/.github/actions/unit_test/action.yml b/.github/actions/unit_test/action.yml
new file mode 100644
index 00000000..ddad5293
--- /dev/null
+++ b/.github/actions/unit_test/action.yml
@@ -0,0 +1,71 @@
+name: 'Test Alpha Unit Test'
+
+description: 'Run Alpha Unit Tests using Gradle'
+
+inputs:
+ POKE_BASE_URL:
+ description: 'Base URL for local.properties'
+ required: true
+ GOOGLE_SERVICES_ALPHA:
+ description: 'Google Services JSON for alpha build'
+ required: true
+ GOOGLE_SERVICES_BETA:
+ description: 'Google Services JSON for beta build'
+ required: true
+ GOOGLE_SERVICES:
+ description: 'Google Services JSON for release'
+ required: true
+
+runs:
+ using: 'composite'
+
+ steps:
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ - name: Create Google-Services.json
+ shell: bash
+ run: |
+ touch ./android/app/src/debug/google-services.json
+ touch ./android/app/src/alpha/google-services.json
+ touch ./android/app/src/beta/google-services.json
+ mkdir ./android/app/src/release
+ touch ./android/app/src/release/google-services.json
+ echo ${{ inputs.GOOGLE_SERVICES_ALPHA }} >> ./android/app/src/debug/google-services.json
+ echo ${{ inputs.GOOGLE_SERVICES_ALPHA }} >> ./android/app/src/alpha/google-services.json
+ echo ${{ inputs.GOOGLE_SERVICES_BETA }} >> ./android/app/src/beta/google-services.json
+ echo ${{ inputs.GOOGLE_SERVICES }} >> ./android/app/src/release/google-services.json
+
+ - name: Create Local Properties
+ shell: bash
+ run: touch local.properties
+ working-directory: ./android
+
+ - name: Access Local Properties
+ shell: bash
+ run: |
+ echo POKE_BASE_URL=\"${{ inputs.POKE_BASE_URL }}\" >> local.properties
+ working-directory: ./android
+
+ - name: Grant execute permission for gradlew
+ shell: bash
+ run: chmod +x gradlew
+ working-directory: ./android
+
+ - name: Run Alpha Unit Tests
+ shell: bash
+ run: ./gradlew testAlphaUnitTest
+ working-directory: ./android
\ No newline at end of file
diff --git a/.github/an_pr_template.md b/.github/an_pr_template.md
new file mode 100644
index 00000000..9112c1a2
--- /dev/null
+++ b/.github/an_pr_template.md
@@ -0,0 +1,10 @@
+- closed #์ด์๋๋ฒ
+## ์์
์์
+
+## ์์
ํ ๋ด์ฉ
+-
+
+## PR ํฌ์ธํธ
+-
+
+## ๐Next Feature
diff --git a/.github/workflows/Android_Develop_CD.yml b/.github/workflows/Android_Develop_CD.yml
new file mode 100644
index 00000000..48dedaeb
--- /dev/null
+++ b/.github/workflows/Android_Develop_CD.yml
@@ -0,0 +1,126 @@
+name: Android PR Builder
+
+on:
+ pull_request:
+ types:
+ - closed
+ branches: [ an/develop ]
+
+defaults:
+ run:
+ working-directory: ./android
+
+jobs:
+ CI_Android_Develop:
+ uses: ./.github/workflows/Android_Develop_CI.yml
+ secrets: inherit
+
+ Distribution_To_Discord:
+ name: Alpha APK to Discord
+ runs-on: ubuntu-latest
+ needs: [ CI_Android_Develop ]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ - name: Create Google-Services.json
+ env:
+ GOOGLE_SERVICES_ALPHA: ${{ secrets.GOOGLE_SERVICES_ALPHA }}
+ GOOGLE_SERVICES_BETA: ${{ secrets.GOOGLE_SERVICES_BETA }}
+ GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
+ run: |
+ touch ./app/src/debug/google-services.json
+ touch ./app/src/alpha/google-services.json
+ touch ./app/src/beta/google-services.json
+ mkdir ./app/src/release
+ touch ./app/src/release/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/debug/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/alpha/google-services.json
+ echo $GOOGLE_SERVICES_BETA >> ./app/src/beta/google-services.json
+ echo $GOOGLE_SERVICES >> ./app/src/release/google-services.json
+
+ - name: Check google-services.json content
+ run: cat ./app/src/debug/google-services.json
+
+ - name: Create Local Properties
+ run: touch local.properties
+
+ - name: Access Local Properties
+ env:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+ run: |
+ echo POKE_BASE_URL=\"${{ secrets.POKE_BASE_URL }}\" >> local.properties
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build alpha APK
+ run: ./gradlew assembleAlpha
+
+ - name: Upload alpha APK artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-artifact
+ path: android/app/build/outputs/apk/alpha/
+ if-no-files-found: error
+
+ - name: Check APK existence
+ run: ls -al app/build/outputs/apk/alpha/
+
+ - name: Extract Version Name
+ env:
+ title: ${{ github.event.pull_request.title }}
+ run: |
+ version=$(echo '${{ env.title }}' | grep -oP '\d+\.\d+\.\d+')
+ echo "version=v$version" >> $GITHUB_ENV
+
+ - name: Send alpha version APK to Discord with Embeds
+ env:
+ DISCORD_WEBHOOK_URL: ${{ secrets.AlPHA_APK_DISCORD_WEB_HOOK }}
+ VERSION: ${{ env.version }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ run: |
+ CONTENT="์ต์ ๊ฐ๋ฐ ๋ฒ์ APK ๊ฐ ๋์์ด์!๐
+ [๋ฐฐํฌ ๋ฒ์ ] : $VERSION!
+ [ํด๋น PR ์ ๋ชฉ] : $PR_TITLE"
+ EMBED=$(jq -n \
+ --arg title "PR Merged: $PR_TITLE" \
+ --arg url "$PR_URL" \
+ --arg description "Version: $VERSION ๐" \
+ '{
+ "title": $title,
+ "url": $url,
+ "description": $description,
+ "color": 3066993
+ }'
+ )
+
+ PAYLOAD=$(jq -n \
+ --arg content "$CONTENT" \
+ --argjson embeds "[$EMBED]" \
+ '{
+ "content": $content,
+ "embeds": $embeds
+ }'
+ )
+
+
+ curl -F "payload_json=$PAYLOAD" \
+ -F "file=@app/build/outputs/apk/alpha/app-alpha.apk" \
+ $DISCORD_WEBHOOK_URL
+
diff --git a/.github/workflows/Android_Develop_CI.yml b/.github/workflows/Android_Develop_CI.yml
new file mode 100644
index 00000000..63da1e1c
--- /dev/null
+++ b/.github/workflows/Android_Develop_CI.yml
@@ -0,0 +1,74 @@
+name: Android PR Builder
+
+on:
+ pull_request:
+ branches: [ an/develop ]
+ workflow_call:
+
+defaults:
+ run:
+ working-directory: ./android
+
+jobs:
+ ktlintCheck:
+ name: ktLint Check
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Run ktLint Check
+ uses: ./.github/actions/ktlint_check
+ with:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+
+ testAlphaUnitTest:
+ name: Test Alpha Unit Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ - name: Create Google-Services.json
+ env:
+ GOOGLE_SERVICES_ALPHA: ${{ secrets.GOOGLE_SERVICES_ALPHA }}
+ GOOGLE_SERVICES_BETA: ${{ secrets.GOOGLE_SERVICES_BETA }}
+ GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
+ run: |
+ touch ./app/src/debug/google-services.json
+ touch ./app/src/alpha/google-services.json
+ touch ./app/src/beta/google-services.json
+ mkdir ./app/src/release
+ touch ./app/src/release/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/debug/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/alpha/google-services.json
+ echo $GOOGLE_SERVICES_BETA >> ./app/src/beta/google-services.json
+ echo $GOOGLE_SERVICES >> ./app/src/release/google-services.json
+
+ - name: Create Local Properties
+ run: touch local.properties
+
+ - name: Access Local Properties
+ env:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+ run: |
+ echo POKE_BASE_URL=\"${{ secrets.POKE_BASE_URL }}\" >> local.properties
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: run alpha unit test
+ run: ./gradlew testAlphaUnitTest
\ No newline at end of file
diff --git a/.github/workflows/Android_PR_AUTO_ASSIGN.yml b/.github/workflows/Android_PR_AUTO_ASSIGN.yml
new file mode 100644
index 00000000..ac67694d
--- /dev/null
+++ b/.github/workflows/Android_PR_AUTO_ASSIGN.yml
@@ -0,0 +1,22 @@
+on:
+ pull_request:
+ branches: [ an/develop ]
+ types:
+ - opened
+
+jobs:
+ assign-reviewers-labels:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: auto-assign-reviews ๐
+ uses: kentaro-m/auto-assign-action@v2.0.0
+ with:
+ configuration-path: '.github/actions/reviewers.yml'
+
+ - name: auto-add-labels โจ
+ uses: ./.github/actions/add_labels
+ with:
+ title: ${{ github.event.pull_request.title }}
\ No newline at end of file
diff --git a/.github/workflows/Android_PR_Builder.yml b/.github/workflows/Android_PR_Builder.yml
deleted file mode 100644
index de39d226..00000000
--- a/.github/workflows/Android_PR_Builder.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-name: Android PR Builder
-
-on:
- pull_request:
- branches: [ an/develop ]
-
-jobs:
- build:
- name: PR Checker
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Gradle cache
- uses: actions/cache@v3
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - name: set up JDK 17
- uses: actions/setup-java@v3
- with:
- distribution: 'temurin'
- java-version: 17
-
- # - name: Create Google-Services.json
- # env:
- # GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
- # run: |
- # touch ./app/google-services.json
- # echo $GOOGLE_SERVICES >> ./app/google-services.json
- # cat ./app/google-services.json
- #
- - name: Create Local Properties
- run: touch local.properties
-
-# - name: Access Local Properties
-# env:
-# FUNCH_DEBUG_BASE_URL: ${{ secrets.FUNCH_DEBUG_BASE_URL }}
-# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
-# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
-# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
-# STORE_FILE: ${{ secrets.STORE_FILE }}
-# run: |
-# echo FUNCH_DEBUG_BASE_URL=\"FUNCH_DEBUG_BASE_URL\" >> local.properties
-# echo STORE_PASSWORD= $STORE_PASSWORD >> local.properties
-# echo KEY_PASSWORD= $KEY_PASSWORD >> local.properties
-# echo KEY_ALIAS= $KEY_ALIAS >> local.properties
-# echo STORE_FILE= $STORE_FILE >> local.properties
-
-# - name: Create Key Store
-# env:
-# KEY_STORE_BASE_64: ${{secrets.KEY_STORE_BASE_64}}
-# run: |
-# echo "$KEY_STORE_BASE_64" | base64 -d > ./funch_key_store.jks
-
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
-
- - name: Lint Check
- run: ./gradlew ktlintCheck
-
- - name: run rest
- run: ./gradlew test
-
- - name: Build with Gradle
- run: ./gradlew build
diff --git a/.github/workflows/Android_Release_CD.yml b/.github/workflows/Android_Release_CD.yml
new file mode 100644
index 00000000..3032843d
--- /dev/null
+++ b/.github/workflows/Android_Release_CD.yml
@@ -0,0 +1,115 @@
+name: Android Release New Version ๐
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+jobs:
+ CI_Android_Release:
+ uses: ./.github/workflows/Android_Release_CI.yml
+ secrets: inherit
+
+ Distribution_To_PlayStore:
+ name: CD Release Builder
+ runs-on: ubuntu-latest
+ needs: [ CI_Android_Release ]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: show github pull request
+ run: echo ${{ github.event.pull_request.title }}
+
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ - name: Create Google-Services.json
+ env:
+ GOOGLE_SERVICES_ALPHA: ${{ secrets.GOOGLE_SERVICES_ALPHA }}
+ GOOGLE_SERVICES_BETA: ${{ secrets.GOOGLE_SERVICES_BETA }}
+ GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
+ run: |
+ touch ./app/src/debug/google-services.json
+ touch ./app/src/alpha/google-services.json
+ touch ./app/src/beta/google-services.json
+ mkdir ./app/src/release
+ touch ./app/src/release/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/debug/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/alpha/google-services.json
+ echo $GOOGLE_SERVICES_BETA >> ./app/src/beta/google-services.json
+ echo $GOOGLE_SERVICES >> ./app/src/release/google-services.json
+ cat ./app/src/debug/google-services.json
+ working-directory: android
+
+ - name: Create Local Properties
+ run: touch local.properties
+ working-directory: android
+
+ - name: Access Local Properties
+ env:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+ # POKE_DEV_BASE_URL: ${{ secrets.HOST_RELEASE_URI }}
+ STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+ KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ run: |
+ echo POKE_BASE_URL=\"${{ secrets.POKE_BASE_URL }}\" >> local.properties
+ echo STORE_PASSWORD= $STORE_PASSWORD >> local.properties
+ echo KEY_PASSWORD= $KEY_PASSWORD >> local.properties
+ echo KEY_ALIAS= $KEY_ALIAS >> local.properties
+ working-directory: android
+
+ - name: Create RELEASE Key Store
+ env:
+ KEY_STORE: ${{secrets.RELEASE_KEY_STORE}}
+ run: |
+ touch ./keystore/poke_key.keystore
+ echo "$KEY_STORE" | base64 -d > ./keystore/poke_key.keystore
+ working-directory: android
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ working-directory: android
+
+ - name: Build Release APK
+ run: ./gradlew assembleRelease
+ working-directory: android
+
+ - name: Upload Release Build to Artifacts
+ uses: actions/upload-artifact@v3
+ with:
+ name: release-artifacts
+ path: android/app/build/outputs/apk/release/
+ if-no-files-found: error
+
+ - name: Create Github Release
+ uses: softprops/action-gh-release@v1
+ with:
+ generate_release_notes: true
+ files: |
+ android/app/build/outputs/apk/release/app-release.apk
+
+ - name: Build release aab
+ run: ./gradlew bundleRelease
+ working-directory: android
+
+ - name: Upload artifact to Google Play Store
+ uses: r0adkll/upload-google-play@v1
+ with:
+ serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
+ releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
+ packageName: poke.rogue.helper
diff --git a/.github/workflows/Android_Release_CI.yml b/.github/workflows/Android_Release_CI.yml
new file mode 100644
index 00000000..0ec5f44d
--- /dev/null
+++ b/.github/workflows/Android_Release_CI.yml
@@ -0,0 +1,93 @@
+name: Android PR Builder
+
+on:
+ push:
+ branches: [ "an/release*" ]
+ workflow_call:
+
+jobs:
+ ktlintCheck:
+ name: ktLint Check
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Run ktLint Check
+ uses: ./.github/actions/ktlint_check
+ with:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+
+ testReleaseUnitTest:
+ name: CI Release Builder
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ - name: Create Google-Services.json
+ env:
+ GOOGLE_SERVICES_ALPHA: ${{ secrets.GOOGLE_SERVICES_ALPHA }}
+ GOOGLE_SERVICES_BETA: ${{ secrets.GOOGLE_SERVICES_BETA }}
+ GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
+ run: |
+ touch ./app/src/debug/google-services.json
+ touch ./app/src/alpha/google-services.json
+ touch ./app/src/beta/google-services.json
+ mkdir ./app/src/release
+ touch ./app/src/release/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/debug/google-services.json
+ echo $GOOGLE_SERVICES_ALPHA >> ./app/src/alpha/google-services.json
+ echo $GOOGLE_SERVICES_BETA >> ./app/src/beta/google-services.json
+ echo $GOOGLE_SERVICES >> ./app/src/release/google-services.json
+ cat ./app/src/debug/google-services.json
+ working-directory: android
+
+ - name: Create Local Properties
+ run: touch local.properties
+ working-directory: android
+
+ - name: Access Local Properties
+ env:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+ # POKE_DEV_BASE_URL: ${{ secrets.HOST_RELEASE_URI }}
+ STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+ KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ run: |
+ echo POKE_BASE_URL=\"${{ secrets.POKE_BASE_URL }}\" >> local.properties
+ echo STORE_PASSWORD= $STORE_PASSWORD >> local.properties
+ echo KEY_PASSWORD= $KEY_PASSWORD >> local.properties
+ echo KEY_ALIAS= $KEY_ALIAS >> local.properties
+ working-directory: android
+
+ - name: Create RELEASE Key Store
+ env:
+ KEY_STORE: ${{secrets.RELEASE_KEY_STORE}}
+ run: |
+ touch ./keystore/poke_key.jks
+ echo "$KEY_STORE" | base64 -d > ./keystore/poke_key.jks
+ working-directory: android
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ working-directory: android
+
+ - name: Unit Test Release
+ run: ./gradlew testReleaseUnitTest
+ working-directory: android
diff --git a/.gitignore b/.gitignore
index df309478..58c84cc6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,13 @@
.idea/
+.DS_Store
+
+android/keystore/keystore_poke
+
+android/app/release/output-metadata.json
+
+android/app/release/baselineProfiles/
+
+android/keystore/encryption_public_key.pem
+
+android/keystore/pepk.jar
diff --git a/android/.gitignore b/android/.gitignore
index d658d9f9..0a649fea 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -13,6 +13,7 @@ captures/
.externalNativeBuild/
.cxx/
*.apk
+*.aab
output.json
# IntelliJ
@@ -25,6 +26,9 @@ render.experimental.xml
# Keystore files
*.jks
*.keystore
+!debug.keystore
+*.dm
+output-metadata.json
# Google Services (e.g. APIs or Firebase)
google-services.json
@@ -34,3 +38,6 @@ google-services.json
# Mac OS
.DS_Store
+
+# docker scripts
+docker
\ No newline at end of file
diff --git a/android/analytics/.gitignore b/android/analytics/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/android/analytics/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/analytics/build.gradle.kts b/android/analytics/build.gradle.kts
new file mode 100644
index 00000000..fe1f15d6
--- /dev/null
+++ b/android/analytics/build.gradle.kts
@@ -0,0 +1,54 @@
+plugins {
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "poke.rogue.helper.analytics"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ create("alpha") {
+ initWith(getByName("debug"))
+ }
+ create("beta") {
+ initWith(getByName("debug"))
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+ packaging {
+ resources {
+ excludes += "META-INF/**"
+ excludes += "win32-x86*/**"
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.timber)
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
+ implementation(platform(libs.koin.bom))
+ implementation(libs.koin.core)
+}
diff --git a/android/analytics/consumer-rules.pro b/android/analytics/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/android/analytics/proguard-rules.pro b/android/analytics/proguard-rules.pro
new file mode 100644
index 00000000..ff59496d
--- /dev/null
+++ b/android/analytics/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/AlphaAnalyticsLogger.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/AlphaAnalyticsLogger.kt
new file mode 100644
index 00000000..39bc58cf
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/AlphaAnalyticsLogger.kt
@@ -0,0 +1,20 @@
+package poke.rogue.helper.analytics
+
+import com.google.firebase.crashlytics.ktx.crashlytics
+import com.google.firebase.ktx.Firebase
+import timber.log.Timber
+
+internal object AlphaAnalyticsLogger : AnalyticsLogger {
+ override fun logEvent(event: AnalyticsEvent) {
+ Timber.d("Event: $event")
+ }
+
+ override fun logError(
+ throwable: Throwable,
+ message: String?,
+ ) {
+ Timber.e(throwable, message)
+ message ?: Firebase.crashlytics.log("Error: $message")
+ Firebase.crashlytics.recordException(throwable)
+ }
+}
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsEvent.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsEvent.kt
new file mode 100644
index 00000000..7fa4c794
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsEvent.kt
@@ -0,0 +1,37 @@
+package poke.rogue.helper.analytics
+
+/**
+ * ๋ถ์ ์ด๋ฒคํธ๋ฅผ ๋ํ๋
๋๋ค.
+ *
+ * @param type - ์ด๋ฒคํธ ์ ํ. ๊ฐ๋ฅํ ๊ฒฝ์ฐ ํ์ค ์ด๋ฒคํธ `Types` ์ค ํ๋๋ฅผ ์ฌ์ฉํ์ธ์.
+ * ์ฌ์ฉ์ ์ ์ ์ด๋ฒคํธ๋ฅผ ์ ์ํ ์ ์์ต๋๋ค (์: Firebase Analytics ์ฌ์ฉ์ ์ ์ ์ด๋ฒคํธ ์์ฑ).
+ *
+ * @param extras - ์ด๋ฒคํธ์ ์ถ๊ฐ์ ์ธ ์ปจํ
์คํธ๋ฅผ ์ ๊ณตํ๋ ๋งค๊ฐ๋ณ์ ๋ชฉ๋ก. (์ ํ ์ฌํญ)
+ *
+ * ref: https://github.com/android/nowinandroid/blob/main/core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt
+ */
+data class AnalyticsEvent(
+ val type: String,
+ val extras: List = emptyList(),
+) {
+ data object Types {
+ const val SCREEN_VIEW = "screen_view"
+ const val ACTION = "select_content"
+ }
+
+ /**
+ * ๋ถ์ ์ด๋ฒคํธ์ ์ถ๊ฐ ์ปจํ
์คํธ๋ฅผ ์ ๊ณตํ๊ธฐ ์ํด ์ฌ์ฉ๋๋ ํค-๊ฐ ์.
+ *
+ * @param key - ๋งค๊ฐ๋ณ์ ํค. ๊ฐ๋ฅํ ๊ฒฝ์ฐ ํ์ค `ParamKeys` ์ค ํ๋๋ฅผ ์ฌ์ฉํ์ธ์.
+ * ๊ทธ๋ฌ๋, ์ ํฉํ ํค๊ฐ ์์ ๊ฒฝ์ฐ ๋ฐฑ์๋ ๋ถ์ ์์คํ
์ ๊ตฌ์ฑ๋ ํค๋ฅผ ์ฌ์ฉํ์ฌ ์ง์ ์ ์ํ ์ ์์ต๋๋ค.
+ * (์: Firebase Analytics ์ฌ์ฉ์ ์ ์ ๋งค๊ฐ๋ณ์ ์์ฑ).
+ *
+ * @param value - ๋งค๊ฐ๋ณ์ ๊ฐ.
+ */
+ data class Param(val key: String, val value: String)
+
+ data object ParamKeys {
+ const val SCREEN_NAME = "screen_name"
+ const val ACTION_NAME = "action_name"
+ }
+}
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsInitializer.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsInitializer.kt
new file mode 100644
index 00000000..eab04f16
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsInitializer.kt
@@ -0,0 +1,28 @@
+package poke.rogue.helper.analytics
+
+import com.google.firebase.analytics.ktx.analytics
+import com.google.firebase.crashlytics.ktx.crashlytics
+import com.google.firebase.ktx.Firebase
+
+object AnalyticsInitializer {
+ fun init() {
+ when (BuildConfig.BUILD_TYPE) {
+ DEBUG_MODE -> {
+ Firebase.analytics.setAnalyticsCollectionEnabled(false)
+ Firebase.crashlytics.setCrashlyticsCollectionEnabled(false)
+ }
+
+ ALPHA_MODE -> {
+ Firebase.analytics.setAnalyticsCollectionEnabled(false)
+ Firebase.crashlytics.setCrashlyticsCollectionEnabled(true)
+ }
+
+ BETA_MODE, RELEASE_MODE -> {
+ Firebase.analytics.setAnalyticsCollectionEnabled(true)
+ Firebase.crashlytics.setCrashlyticsCollectionEnabled(true)
+ }
+
+ else -> error("Unknown build type")
+ }
+ }
+}
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsLogger.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsLogger.kt
new file mode 100644
index 00000000..791c99dc
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsLogger.kt
@@ -0,0 +1,38 @@
+package poke.rogue.helper.analytics
+
+internal const val DEBUG_MODE = "debug"
+internal const val ALPHA_MODE = "alpha"
+internal const val BETA_MODE = "beta"
+internal const val RELEASE_MODE = "release"
+
+/** Analytics API surface */
+interface AnalyticsLogger {
+ fun logEvent(event: AnalyticsEvent)
+
+ fun logError(
+ throwable: Throwable,
+ message: String? = null,
+ )
+
+ companion object {
+ val Stub =
+ object : AnalyticsLogger {
+ override fun logEvent(event: AnalyticsEvent) = Unit
+
+ override fun logError(
+ throwable: Throwable,
+ message: String?,
+ ) = Unit
+ }
+ }
+}
+
+fun analyticsLogger(): AnalyticsLogger {
+ return when (BuildConfig.BUILD_TYPE) {
+ DEBUG_MODE -> DebugAnalyticsLogger
+ ALPHA_MODE -> AlphaAnalyticsLogger
+ BETA_MODE -> FireBaseAnalyticsLogger
+ RELEASE_MODE -> FireBaseAnalyticsLogger
+ else -> error("Unknown build type")
+ }
+}
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsScope.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsScope.kt
new file mode 100644
index 00000000..20fba162
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsScope.kt
@@ -0,0 +1,23 @@
+package poke.rogue.helper.analytics
+
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import timber.log.Timber
+
+/**
+ * ๋น๋๊ธฐ์ ์ผ๋ก ๋ฐ์ดํฐ ์์ง, ๋ถ์, ๋ก๊น
๋ฑ์ ์์
์ ์ฒ๋ฆฌํ๋ ์ฉ๋๋ก ์ฌ์ฉํ๋ CoroutineScope
+ *
+ * Firebase logEvent ๋ ๋ด๋ถ Executor ์์ ๋์ํ๊ธฐ ๋๋ฌธ์ ์ฝ๋ฃจํด์ ํ์ฉํ ํ์๋ ์์ง๋ง
+ * ํด๋น ์ฝ๋๋ฅผ ํธ์ถํ๋ ํ๋ก๋์
์ฝ๋์์ ํด๋น ํจ์์์ ์๋ฌ๊ฐ ํฐ์ง๋ฉด ์์ธ๊ฐ ์ ํ๋์ด
+ * ํ๋ก๋์
์ฝ๋์ ๋ฌธ์ ๋ฅผ ์ผ์ผํฌ ์ ์๊ธฐ ๋๋ฌธ์ ๋ณ๋์ ์ฝ๋ฃจํด์ ์ฌ์ฉํ์ฌ ์ฒ๋ฆฌํ๋ค.
+ *
+ * ref: https://velog.io/@murjune/kotlin-Coroutine-supervisorScope-vs-SupervisorJob-%EC%96%B4%EB%96%A4%EA%B1%B8-%EC%82%AC%EC%9A%A9%ED%95%98%EB%9D%BC%EB%8A%94%EA%B1%B0%EC%A7%80
+ */
+private val analyticsExcpetionHandler =
+ CoroutineExceptionHandler { coroutineContext, throwable ->
+ Timber.e(throwable)
+ }
+internal val analyticsScope =
+ CoroutineScope(SupervisorJob() + Dispatchers.IO + analyticsExcpetionHandler)
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/DebugAnalyticsLogger.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/DebugAnalyticsLogger.kt
new file mode 100644
index 00000000..1cf784e5
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/DebugAnalyticsLogger.kt
@@ -0,0 +1,16 @@
+package poke.rogue.helper.analytics
+
+import timber.log.Timber
+
+internal object DebugAnalyticsLogger : AnalyticsLogger {
+ override fun logEvent(event: AnalyticsEvent) {
+ Timber.d("Event: $event")
+ }
+
+ override fun logError(
+ throwable: Throwable,
+ message: String?,
+ ) {
+ Timber.e(throwable, message)
+ }
+}
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/FireBaseAnalyticsLogger.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/FireBaseAnalyticsLogger.kt
new file mode 100644
index 00000000..97166829
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/FireBaseAnalyticsLogger.kt
@@ -0,0 +1,32 @@
+package poke.rogue.helper.analytics
+
+import com.google.firebase.analytics.ktx.analytics
+import com.google.firebase.analytics.logEvent
+import com.google.firebase.crashlytics.ktx.crashlytics
+import com.google.firebase.ktx.Firebase
+import kotlinx.coroutines.launch
+
+internal object FireBaseAnalyticsLogger : AnalyticsLogger {
+ override fun logEvent(event: AnalyticsEvent) {
+ analyticsScope.launch {
+ Firebase.analytics.logEvent(event.type) {
+ for (extra in event.extras) {
+ param(
+ key = extra.key.take(40),
+ value = extra.value.take(100),
+ )
+ }
+ }
+ }
+ }
+
+ override fun logError(
+ throwable: Throwable,
+ message: String?,
+ ) {
+ analyticsScope.launch {
+ message ?: Firebase.crashlytics.log("Error: $message")
+ Firebase.crashlytics.recordException(throwable)
+ }
+ }
+}
diff --git a/android/analytics/src/main/java/poke/rogue/helper/analytics/di/AnalyticsModule.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/di/AnalyticsModule.kt
new file mode 100644
index 00000000..ca2f7bdc
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/di/AnalyticsModule.kt
@@ -0,0 +1,13 @@
+package poke.rogue.helper.analytics.di
+
+import org.koin.dsl.module
+import poke.rogue.helper.analytics.AnalyticsInitializer
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+
+val analyticsModule
+ get() =
+ module {
+ AnalyticsInitializer.init()
+ single { analyticsLogger() }
+ }
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 373bcbe6..d6b93228 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,48 +1,182 @@
+import org.jetbrains.kotlin.konan.properties.Properties
+
plugins {
- alias(libs.plugins.androidApplication)
- alias(libs.plugins.jetbrainsKotlinAndroid)
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.kapt)
+ alias(libs.plugins.kotlinx.serialization)
+ alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.android.junit5)
+ alias(libs.plugins.google.services)
+ alias(libs.plugins.firebase.crashlytics.plugin)
}
-android {
- namespace = "poke.rogue.helper"
- compileSdk = 34
+val properties =
+ Properties().apply {
+ load(rootProject.file("local.properties").inputStream())
+ }
+android {
+ namespace = libs.versions.applicationId.get()
+ compileSdk = libs.versions.compileSdk.get().toInt()
+ signingConfigs {
+ getByName("debug") {
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ storeFile = File("${project.rootDir.absolutePath}/keystore/debug.keystore")
+ storePassword = "android"
+ }
+ create("release") {
+ keyAlias = properties.getProperty("KEY_ALIAS")
+ keyPassword = properties.getProperty("KEY_PASSWORD")
+ storeFile = file("${project.rootDir.absolutePath}/keystore/poke_key.keystore")
+ storePassword = properties.getProperty("STORE_PASSWORD")
+ }
+ }
defaultConfig {
- applicationId = "poke.rogue.helper"
- minSdk = 26
- targetSdk = 34
- versionCode = 1
- versionName = "1.0"
-
+ applicationId = libs.versions.applicationId.get()
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionName = libs.versions.appVersion.get()
+ versionCode = libs.versions.versionCode.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["runnerBuilder"] =
+ "de.mannodermaus.junit5.AndroidJUnit5Builder"
+
+ buildConfigField(
+ "String",
+ "POKE_BASE_URL",
+ properties.getProperty("POKE_BASE_URL"),
+ )
}
buildTypes {
+ debug {
+ applicationIdSuffix = ".dev"
+ signingConfig = signingConfigs.getByName("debug")
+ }
+
+ create("beta") {
+ initWith(getByName("debug"))
+ versionNameSuffix = "-beta"
+ applicationIdSuffix = ".beta"
+ signingConfig = signingConfigs.getByName("debug")
+// firebaseAppDistribution {
+// artifactType = "APK"
+// releaseNotesFile = "firebase/releaseNote.txt"
+// groupsFile = "firebase/testers.txt"
+// }
+ }
+
+ create("alpha") {
+ initWith(getByName("debug"))
+ versionNameSuffix = "-alpha"
+ applicationIdSuffix = ".alpha"
+ signingConfig = signingConfigs.getByName("debug")
+// firebaseAppDistribution {
+// artifactType = "APK"
+// releaseNotesFile = "firebase/releaseNote.txt"
+// groupsFile = "firebase/testers.txt"
+// }
+ }
+
release {
- isMinifyEnabled = false
+ isMinifyEnabled = true
+ isShrinkResources = true
+ signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ "proguard-rules.pro",
)
+ signingConfig = signingConfigs.getByName("release")
}
}
+
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+ packaging {
+ resources {
+ excludes += "META-INF/**"
+ excludes += "win32-x86*/**"
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ dataBinding = true
+ }
+ testOptions {
+ animationsDisabled = true
+ // ref: https://github.com/robolectric/robolectric/pull/4736
+ unitTests.isIncludeAndroidResources = true
+ managedDevices {
+ val deviceApis = (27..34)
+ localDevices {
+ deviceApis.forEach { api ->
+ create("pixel4api$api") {
+ device = "Pixel 4"
+ apiLevel = api
+ systemImageSource = "aosp"
+ }
+ }
+ }
+ groups {
+ create("phones") {
+ deviceApis.forEach { api ->
+ targetDevices.add(devices["pixel4api$api"])
+ }
+ }
+ }
+ }
}
}
dependencies {
-
+ // module
+ implementation(project(":data"))
+ implementation(project(":local"))
+ implementation(project(":stringmatcher"))
+ implementation(project(":analytics"))
+ testImplementation(project(":testing"))
+ androidTestImplementation(project(":testing"))
+ // androidx
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
- testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
-}
\ No newline at end of file
+ implementation(libs.androidx.startup)
+ implementation(libs.androidx.lifecycle)
+ implementation(libs.androidx.lifecycle.viewmodel)
+ implementation(libs.kotlin.coroutines.android)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.flexbox)
+ implementation(libs.timber)
+ implementation(libs.coil.core)
+ implementation(libs.coil.svg)
+ implementation(libs.glide)
+ kapt(libs.glide.compiler)
+ implementation(libs.splash.screen)
+ // google & firebase
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.crashlytics.buildtools)
+ implementation(libs.bundles.firebase)
+ implementation(libs.app.update.ktx)
+ // Koin
+ implementation(platform(libs.koin.bom))
+ implementation(libs.koin.android)
+ androidTestImplementation(libs.koin.android.test)
+ // android test
+ androidTestImplementation(libs.bundles.android.test)
+ debugImplementation(libs.bundles.android.test)
+ testRuntimeOnly(libs.junit.vintage.engine)
+ androidTestRuntimeOnly(libs.junit5.android.test.runner)
+ testImplementation(libs.android.test.fragment)
+ debugImplementation(libs.android.test.fragment.manifest)
+
+ implementation(libs.balloon)
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 481bb434..38a554e6 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -18,4 +18,137 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+
+##---------------Begin: proguard configuration for Glide ----------
+-keep public class * implements com.bumptech.glide.module.GlideModule
+-keep class * extends com.bumptech.glide.module.AppGlideModule {
+ (...);
+}
+-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
+ **[] $VALUES;
+ public *;
+}
+-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
+ *** rewind();
+}
+
+# Uncomment for DexGuard only
+#-keepresourcexmlelements manifest/application/meta-data@value=GlideModule
+##---------------End: proguard configuration for Glide ----------
+
+##---------------Begin: Okio ----------
+# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
+-dontwarn org.codehaus.mojo.animal_sniffer.*
+
+##---------------End: Okio ----------
+
+##---------------Begin: proguard configuration for Retrofit2 ----------
+# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
+# EnclosingMethod is required to use InnerClasses.
+-keepattributes Signature, InnerClasses, EnclosingMethod
+
+# Retrofit does reflection on method and parameter annotations.
+-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
+
+# Keep annotation default values (e.g., retrofit2.http.Field.encoded).
+-keepattributes AnnotationDefault
+
+# Retain service method parameters when optimizing.
+-keepclassmembers,allowshrinking,allowobfuscation interface * {
+ @retrofit2.http.* ;
+}
+
+# Ignore annotation used for build tooling.
+-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
+
+# Ignore JSR 305 annotations for embedding nullability information.
+-dontwarn javax.annotation.**
+
+# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
+-dontwarn kotlin.Unit
+
+# Top-level functions that can only be used by Kotlin.
+-dontwarn retrofit2.KotlinExtensions
+-dontwarn retrofit2.KotlinExtensions$*
+
+# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
+# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
+-if interface * { @retrofit2.http.* ; }
+-keep,allowobfuscation interface <1>
+
+# Keep inherited services.
+-if interface * { @retrofit2.http.* ; }
+-keep,allowobfuscation interface * extends <1>
+
+# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
+-keep,allowobfuscation,allowshrinking interface retrofit2.Call
+-keep,allowobfuscation,allowshrinking class retrofit2.Response
+
+# With R8 full mode generic signatures are stripped for classes that are not
+# kept. Suspend functions are wrapped in continuations where the type argument
+# is used.
+-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
+##---------------End: proguard configuration for Retrofit2 ----------
+
+##---------------Begin: proguard configuration for okhttp3 ----------
+# JSR 305 annotations are for embedding nullability information.
+-dontwarn javax.annotation.**
+
+# A resource is loaded with a relative path so the package of this class must be preserved.
+-adaptresourcefilenames okhttp3/internal/publicsuffix/PublicSuffixDatabase.gz
+
+# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
+-dontwarn org.codehaus.mojo.animal_sniffer.*
+
+# OkHttp platform used only on JVM and when Conscrypt and other security providers are available.
+-dontwarn okhttp3.internal.platform.**
+-dontwarn org.conscrypt.**
+-dontwarn org.bouncycastle.**
+-dontwarn org.openjsse.**
+##---------------End: proguard configuration for okhttp3 ----------
+
+# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
+-dontwarn org.codehaus.mojo.animal_sniffer.*
+
+
+##---------------Begin: kotlin serialization ----------
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
+
+# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
+-keepclassmembers class kotlinx.serialization.json.** {
+ *** Companion;
+}
+-keepclasseswithmembers class kotlinx.serialization.json.** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+# Application rules
+
+# Change here com.yourcompany.yourpackage
+-keepclassmembers @kotlinx.serialization.Serializable class poke.rogue.helper.** {
+ # lookup for plugin generated serializable classes
+ *** Companion;
+ # lookup for serializable objects
+ *** INSTANCE;
+ kotlinx.serialization.KSerializer serializer(...);
+}
+# lookup for plugin generated serializable classes
+-if @kotlinx.serialization.Serializable class poke.rogue.helper.**
+-keepclassmembers class com.teampophory.<1>$Companion {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+# Serialization supports named companions but for such classes it is necessary to add an additional rule.
+# This rule keeps serializer and serializable class from obfuscation. Therefore, it is recommended not to use wildcards in it, but to write rules for each such class.
+-keep class poke.rogue.helper.SerializableClassWithNamedCompanion$$serializer {
+ *** INSTANCE;
+}
+
+-keep class poke.rogue.helper.** {
+ @kotlinx.serialization.SerialName ;
+}
+
+##---------------END: kotlin serialization ----------
\ No newline at end of file
diff --git a/android/app/release/baselineProfiles/0/app-release.dm b/android/app/release/baselineProfiles/0/app-release.dm
new file mode 100644
index 00000000..53f8e32e
Binary files /dev/null and b/android/app/release/baselineProfiles/0/app-release.dm differ
diff --git a/android/app/release/baselineProfiles/1/app-release.dm b/android/app/release/baselineProfiles/1/app-release.dm
new file mode 100644
index 00000000..c4bbbe95
Binary files /dev/null and b/android/app/release/baselineProfiles/1/app-release.dm differ
diff --git a/android/app/release/output-metadata.json b/android/app/release/output-metadata.json
new file mode 100644
index 00000000..5a820f45
--- /dev/null
+++ b/android/app/release/output-metadata.json
@@ -0,0 +1,37 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "poke.rogue.helper",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 130,
+ "versionName": "0.1.30",
+ "outputFile": "app-release.apk"
+ }
+ ],
+ "elementType": "File",
+ "baselineProfiles": [
+ {
+ "minApi": 28,
+ "maxApi": 30,
+ "baselineProfiles": [
+ "baselineProfiles/1/app-release.dm"
+ ]
+ },
+ {
+ "minApi": 31,
+ "maxApi": 2147483647,
+ "baselineProfiles": [
+ "baselineProfiles/0/app-release.dm"
+ ]
+ }
+ ],
+ "minSdkVersionForDexing": 26
+}
\ No newline at end of file
diff --git a/android/app/src/alpha/res/values/strings.xml b/android/app/src/alpha/res/values/strings.xml
new file mode 100644
index 00000000..88bf4c42
--- /dev/null
+++ b/android/app/src/alpha/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Alpha PokรฉRogue Helper
+
diff --git a/android/app/src/androidTest/java/poke/rogue/helper/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/poke/rogue/helper/ExampleInstrumentedTest.kt
index 100c12f5..091b4506 100644
--- a/android/app/src/androidTest/java/poke/rogue/helper/ExampleInstrumentedTest.kt
+++ b/android/app/src/androidTest/java/poke/rogue/helper/ExampleInstrumentedTest.kt
@@ -1,24 +1,17 @@
package poke.rogue.helper
-import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
-
+import io.kotest.matchers.string.shouldContain
import org.junit.Test
import org.junit.runner.RunWith
+import poke.rogue.helper.presentation.util.testContext
-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("poke.rogue.helper", appContext.packageName)
+ val appContext = testContext
+ appContext.packageName shouldContain "poke.rogue.helper"
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/androidTest/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt b/android/app/src/androidTest/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt
new file mode 100644
index 00000000..df43a193
--- /dev/null
+++ b/android/app/src/androidTest/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt
@@ -0,0 +1,57 @@
+package poke.rogue.helper.presentation.home
+
+import android.content.pm.ActivityInfo
+import androidx.test.espresso.Espresso.onIdle
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.jupiter.api.DisplayName
+import org.junit.runner.RunWith
+import poke.rogue.helper.R
+
+@RunWith(AndroidJUnit4::class)
+class HomeActivityTest {
+ @get:Rule
+ val activityRule = ActivityScenarioRule(HomeActivity::class.java)
+
+ @Test
+ @DisplayName("์ฑ์ด ์คํ๋๋ฉด ํฌ์ผ๋ก๊ทธ ๋ก๊ณ ๊ฐ ๋ณด์ธ๋ค")
+ fun test1() {
+ // then
+ onView(withId(R.id.iv_home_logo))
+ .check(matches(isDisplayed()))
+ }
+
+ @Test
+ @DisplayName("ํ๋ฉด ํ์ ์์๋ ํฌ์ผ๋ก๊ทธ ๋ก๊ณ ๊ฐ ๋ณด์ธ๋ค")
+ fun test2() {
+ // when
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ onIdle()
+
+ // then
+ onView(withId(R.id.iv_home_land_logo))
+ .check(matches(isDisplayed()))
+ }
+
+ @Test
+ @DisplayName("ํ๋ฉด ํ์ ์์๋ ํ์
๋ฉ๋ด ๋ฒํผ์ด ๋ณด์ธ๋ค")
+ fun test3() {
+ // when
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ onIdle()
+
+ // then
+ onView(withId(R.id.cv_home_land_type))
+ .check(matches(isDisplayed()))
+ }
+}
diff --git a/android/app/src/androidTest/java/poke/rogue/helper/presentation/type/TypeActivityTest.kt b/android/app/src/androidTest/java/poke/rogue/helper/presentation/type/TypeActivityTest.kt
new file mode 100644
index 00000000..10cda58b
--- /dev/null
+++ b/android/app/src/androidTest/java/poke/rogue/helper/presentation/type/TypeActivityTest.kt
@@ -0,0 +1,43 @@
+package poke.rogue.helper.presentation.type
+
+import android.content.pm.ActivityInfo
+import androidx.test.espresso.Espresso.onIdle
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.jupiter.api.DisplayName
+import org.junit.runner.RunWith
+import poke.rogue.helper.R
+
+@RunWith(AndroidJUnit4::class)
+class TypeActivityTest {
+ @get:Rule
+ val activityRule = ActivityScenarioRule(TypeActivity::class.java)
+
+ @Test
+ @DisplayName("์ฌ์ฉ์๊ฐ ์๋ฌด๊ฒ๋ ์ ํํ์ง ์์ ๊ฒฝ์ฐ์๋ ์ ํ ์๋ด ์ด๋ฏธ์ง๊ฐ ๋ณด์ธ๋ค")
+ fun test1() {
+ // then
+ onView(ViewMatchers.withId(R.id.iv_no_selection))
+ .check(matches(isDisplayed()))
+ }
+
+ @Test
+ @DisplayName("ํ๋ฉด ํ์ ์์๋ ์ฌ์ฉ์๊ฐ ์๋ฌด๊ฒ๋ ์ ํํ์ง ์์ ๊ฒฝ์ฐ์๋ ์ ํ ์๋ด ์ด๋ฏธ์ง๊ฐ ๋ณด์ธ๋ค")
+ fun test2() {
+ // when
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ onIdle()
+
+ // then
+ onView(ViewMatchers.withId(R.id.iv_no_selection))
+ .check(matches(isDisplayed()))
+ }
+}
diff --git a/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/ContextUtils.kt b/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/ContextUtils.kt
new file mode 100644
index 00000000..845b7ce0
--- /dev/null
+++ b/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/ContextUtils.kt
@@ -0,0 +1,6 @@
+package poke.rogue.helper.presentation.util
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+
+val testContext: Context get() = ApplicationProvider.getApplicationContext()
diff --git a/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/RecyclerViewUtils.kt b/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/RecyclerViewUtils.kt
new file mode 100644
index 00000000..bea15b3b
--- /dev/null
+++ b/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/RecyclerViewUtils.kt
@@ -0,0 +1,103 @@
+package poke.rogue.helper.presentation.util
+
+import android.view.View
+import androidx.annotation.IdRes
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.espresso.NoMatchingViewException
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.ViewAssertion
+import androidx.test.espresso.ViewInteraction
+import androidx.test.espresso.contrib.RecyclerViewActions
+import io.kotest.matchers.nulls.shouldNotBeNull
+import io.kotest.matchers.shouldBe
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matcher
+
+/**
+ * ๋ฆฌ์ฌ์ดํด๋ฌ๋ทฐ์ expectedCount๋งํผ ์์ดํ
์ด ์๋์ง ํ์ธํ๋ ViewAssertion
+ */
+class RecyclerViewItemCountAssertion(private val expectedCount: Int) : ViewAssertion {
+ override fun check(
+ view: View?,
+ noViewFoundException: NoMatchingViewException?,
+ ) {
+ if (noViewFoundException != null) {
+ throw noViewFoundException
+ }
+
+ val recyclerView = view as? RecyclerView
+ recyclerView.shouldNotBeNull()
+ val adapter = recyclerView.adapter
+ adapter.shouldNotBeNull()
+ adapter.itemCount shouldBe expectedCount
+ }
+}
+
+/**
+ * ๋ฆฌ์ฌ์ดํด๋ฌ๋ทฐ์ ํน์ ์์น์ ์๋ ์์ดํ
์ ํด๋ฆญํ๋ ViewAction
+ *
+ * reference: https://gist.github.com/quentin7b/9c5669fd940865cf2e89
+ *
+ * sample
+ * ```kotlin
+ * onView(withId(R.id.rv_shopping_cart)).perform(
+ * RecyclerViewActions.actionOnItemAtPosition(
+ * 3, // 3๋ฒ์งธ ์์ดํ
+ * clickChildViewWithId(R.id.iv_shooping_cart_delete), // id๊ฐ iv_shooping_cart_delete์ธ ๋ทฐ ํด๋ฆญ
+ * ),
+ * )
+
+ * ```
+ */
+fun clickChildViewWithId(id: Int): ViewAction {
+ return object : ViewAction {
+ override fun getConstraints(): Matcher? {
+ return null
+ }
+
+ override fun getDescription(): String {
+ return "View id: $id - Click on specific button"
+ }
+
+ override fun perform(
+ uiController: UiController,
+ view: View,
+ ) {
+ val v = view.findViewById(id)
+ v.performClick()
+ }
+ }
+}
+
+/**
+ * ๋ฆฌ์ฌ์ดํด๋ฌ๋ทฐ์ ์์ดํ
๊ฐ์๋ฅผ ํ์ธํ๋ ViewAssertion์ ๋ฐํํ๋ ํจ์
+ *
+ * sample
+ * ```kotlin
+ * // ๋ฆฌ์ฌ์ดํด๋ฌ๋ทฐ์ ์์ดํ
๊ฐ์๊ฐ 3๊ฐ์ธ์ง ํ์ธ
+ * onView(withId(R.id.rv_shopping_cart)).check(withItemCount(3))
+ * ```
+ */
+fun withItemCount(expectedCount: Int): ViewAssertion {
+ return RecyclerViewItemCountAssertion(expectedCount)
+}
+
+inline fun ViewInteraction.performScrollToHolder(position: Int = 0): ViewInteraction {
+ return perform(
+ RecyclerViewActions.scrollToHolder(CoreMatchers.instanceOf(T::class.java))
+ .atPosition(position),
+ )
+}
+
+fun ViewInteraction.performClickHolderAt(
+ absolutePosition: Int = 0,
+ @IdRes childViewId: Int,
+): ViewInteraction {
+ return perform(
+ RecyclerViewActions.actionOnItemAtPosition(
+ absolutePosition,
+ clickChildViewWithId(childViewId),
+ ),
+ )
+}
diff --git a/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/ViewAssertionUtils.kt b/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/ViewAssertionUtils.kt
new file mode 100644
index 00000000..b4c82a1d
--- /dev/null
+++ b/android/app/src/androidTest/java/poke/rogue/helper/presentation/util/ViewAssertionUtils.kt
@@ -0,0 +1,25 @@
+package poke.rogue.helper.presentation.util
+
+import androidx.test.espresso.ViewAssertion
+import androidx.test.espresso.assertion.ViewAssertions
+import androidx.test.espresso.matcher.ViewMatchers
+import org.hamcrest.CoreMatchers
+
+/**
+ * ์์ ๋ทฐ์ ํ
์คํธ๋ฅผ ํฌํจํ๋ ๋ทฐ๊ฐ ์๋์ง ํ์ธํ๋ ViewAssertion์ ๋ฐํํ๋ ํจ์
+ *
+ * sample
+ * ```kotlin
+ * // ๋ฆฌ์ฌ์ดํด๋ฌ๋ทฐ์ ์์ ๋ทฐ ์ค ํ
์คํธ์ "ํ
์คํธ"๊ฐ ํฌํจ๋ ๋ทฐ๊ฐ ์๋์ง ํ์ธ
+ * onView(withId(R.id.rv_shopping_cart)).check(matchDescendantWithText("ํ
์คํธ"))
+ * ```
+ */
+fun matchDescendantWithText(text: String): ViewAssertion {
+ return ViewAssertions.matches(
+ ViewMatchers.hasDescendant(
+ ViewMatchers.withText(
+ CoreMatchers.containsString(text),
+ ),
+ ),
+ )
+}
diff --git a/android/app/src/beta/res/values/strings.xml b/android/app/src/beta/res/values/strings.xml
new file mode 100644
index 00000000..e55b78e8
--- /dev/null
+++ b/android/app/src/beta/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Beta PokรฉRogue Helper
+
diff --git a/android/app/src/debug/res/values/strings.xml b/android/app/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..f20f44c1
--- /dev/null
+++ b/android/app/src/debug/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Dev PokรฉRogue Helper
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f0258431..d84af3cb 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,25 +2,74 @@
+
+
+
+ android:name=".presentation.splash.PokemonIntroActivity"
+ android:exported="true"
+ android:theme="@style/Theme.PokeRogueHelper.Splash">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 00000000..1ed31c9b
Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ
diff --git a/android/app/src/main/java/poke/rogue/helper/MainActivity.kt b/android/app/src/main/java/poke/rogue/helper/MainActivity.kt
deleted file mode 100644
index 1a3ac30b..00000000
--- a/android/app/src/main/java/poke/rogue/helper/MainActivity.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package poke.rogue.helper
-
-import android.os.Bundle
-import androidx.appcompat.app.AppCompatActivity
-
-class MainActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/poke/rogue/helper/PokeRogueHelperApp.kt b/android/app/src/main/java/poke/rogue/helper/PokeRogueHelperApp.kt
new file mode 100644
index 00000000..7dd67cc8
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/PokeRogueHelperApp.kt
@@ -0,0 +1,33 @@
+package poke.rogue.helper
+
+import android.app.Application
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.core.context.startKoin
+import org.koin.core.logger.Level
+import poke.rogue.helper.di.appModule
+import timber.log.Timber
+
+class PokeRogueHelperApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ initTimber()
+ startKoin {
+ androidLogger(level = Level.DEBUG)
+ androidContext(applicationContext)
+ modules(appModule)
+ }
+ }
+
+ private fun initTimber() {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(
+ object : Timber.DebugTree() {
+ override fun createStackElementTag(element: StackTraceElement): String {
+ return "${element.fileName} : ${element.lineNumber} - ${element.methodName}"
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/di/AppModule.kt b/android/app/src/main/java/poke/rogue/helper/di/AppModule.kt
new file mode 100644
index 00000000..2f73e979
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/di/AppModule.kt
@@ -0,0 +1,11 @@
+package poke.rogue.helper.di
+
+import org.koin.dsl.module
+import poke.rogue.helper.analytics.di.analyticsModule
+import poke.rogue.helper.data.di.dataModule
+
+val appModule
+ get() =
+ module {
+ includes(dataModule, analyticsModule, viewModelModule)
+ }
diff --git a/android/app/src/main/java/poke/rogue/helper/di/ViewModelModule.kt b/android/app/src/main/java/poke/rogue/helper/di/ViewModelModule.kt
new file mode 100644
index 00000000..3de2b3cb
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/di/ViewModelModule.kt
@@ -0,0 +1,44 @@
+package poke.rogue.helper.di
+
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.dsl.module
+import poke.rogue.helper.presentation.ability.AbilityViewModel
+import poke.rogue.helper.presentation.ability.detail.AbilityDetailViewModel
+import poke.rogue.helper.presentation.battle.BattleViewModel
+import poke.rogue.helper.presentation.battle.selection.BattleSelectionViewModel
+import poke.rogue.helper.presentation.battle.selection.pokemon.PokemonSelectionViewModel
+import poke.rogue.helper.presentation.battle.selection.skill.SkillSelectionViewModel
+import poke.rogue.helper.presentation.biome.BiomeViewModel
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailViewModel
+import poke.rogue.helper.presentation.dex.PokemonListViewModel
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel
+import poke.rogue.helper.presentation.home.HomeViewModel
+import poke.rogue.helper.presentation.splash.PokemonIntroViewModel
+import poke.rogue.helper.presentation.type.TypeViewModel
+
+val viewModelModule
+ get() =
+ module {
+ viewModelOf(::PokemonIntroViewModel)
+ viewModelOf(::PokemonListViewModel)
+ viewModelOf(::BiomeViewModel)
+ viewModelOf(::BiomeDetailViewModel)
+ viewModelOf(::HomeViewModel)
+ viewModelOf(::AbilityViewModel)
+ viewModelOf(::AbilityDetailViewModel)
+ viewModel { params ->
+ BattleViewModel(get(), get(), get(), params.getOrNull(), params.getOrNull())
+ }
+ viewModel { params ->
+ BattleSelectionViewModel(params.get(), params.get(), get())
+ }
+ viewModel { params ->
+ PokemonSelectionViewModel(get(), params.getOrNull(), get())
+ }
+ viewModel { params ->
+ SkillSelectionViewModel(get(), params.getOrNull(), get())
+ }
+ viewModelOf(::TypeViewModel)
+ viewModelOf(::PokemonDetailViewModel)
+ }
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityActivity.kt
new file mode 100644
index 00000000..e56b14b4
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityActivity.kt
@@ -0,0 +1,53 @@
+package poke.rogue.helper.presentation.ability
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.fragment.app.commit
+import androidx.fragment.app.replace
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityAbilityBinding
+import poke.rogue.helper.presentation.ability.detail.AbilityDetailFragment
+import poke.rogue.helper.presentation.base.BindingActivity
+
+class AbilityActivity : BindingActivity(R.layout.activity_ability) {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (savedInstanceState == null) {
+ val abilityId = intent.getStringExtra(ABILITY_ID) ?: ""
+ if (abilityId.isBlank()) {
+ navigateToAbilityList()
+ return
+ }
+
+ navigateToAbilityDetail(abilityId)
+ }
+ }
+
+ private fun navigateToAbilityList() {
+ supportFragmentManager.commit {
+ replace(R.id.fragment_container_ability)
+ }
+ }
+
+ private fun navigateToAbilityDetail(abilityId: String) {
+ supportFragmentManager.commit {
+ replace(
+ containerViewId = R.id.fragment_container_ability,
+ args = AbilityDetailFragment.bundleOf(abilityId),
+ )
+ }
+ }
+
+ companion object {
+ private const val ABILITY_ID = "abilityId"
+
+ fun intent(
+ context: Context,
+ abilityId: String,
+ ): Intent =
+ Intent(context, AbilityActivity::class.java).apply {
+ putExtra(ABILITY_ID, abilityId)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityAdapter.kt
new file mode 100644
index 00000000..ecd7967f
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityAdapter.kt
@@ -0,0 +1,40 @@
+package poke.rogue.helper.presentation.ability
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemAbilityDescriptionBinding
+import poke.rogue.helper.presentation.ability.model.AbilityUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class AbilityAdapter(private val onClickAbilityItem: AbilityUiEventHandler) :
+ ListAdapter(abilityComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): AbilityViewHolder {
+ return AbilityViewHolder(
+ ItemAbilityDescriptionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickAbilityItem,
+ )
+ }
+
+ override fun onBindViewHolder(
+ holder: AbilityViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ private val abilityComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityFragment.kt
new file mode 100644
index 00000000..3f6b8f92
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityFragment.kt
@@ -0,0 +1,94 @@
+package poke.rogue.helper.presentation.ability
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.commit
+import androidx.fragment.app.replace
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentAbilityBinding
+import poke.rogue.helper.presentation.ability.detail.AbilityDetailFragment
+import poke.rogue.helper.presentation.base.error.ErrorEvent
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.base.error.NetworkErrorActivity
+import poke.rogue.helper.presentation.util.fragment.startActivity
+import poke.rogue.helper.presentation.util.fragment.toast
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class AbilityFragment : ErrorHandleFragment(R.layout.fragment_ability) {
+ private val viewModel by viewModel()
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+
+ private val adapter: AbilityAdapter by lazy { AbilityAdapter(viewModel) }
+
+ override val toolbar: Toolbar
+ get() = binding.toolbarAbility
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initAdapter() {
+ val decoration =
+ LinearSpacingItemDecoration(spacing = 11.dp, true)
+ binding.rvAbilityDescription.adapter = adapter
+ binding.rvAbilityDescription.addItemDecoration(decoration)
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiState.collect { abilities ->
+ when (abilities) {
+ is AbilityUiState.Loading -> {}
+ is AbilityUiState.Success -> {
+ adapter.submitList(abilities.data)
+ }
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.navigationToDetailEvent.collect { abilityId ->
+ parentFragmentManager.commit {
+ val containerId = R.id.fragment_container_ability
+ replace(
+ containerId,
+ args =
+ AbilityDetailFragment.bundleOf(
+ abilityId,
+ ),
+ )
+ addToBackStack(TAG)
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.commonErrorEvent.collect {
+ when (it) {
+ is ErrorEvent.NetworkException -> startActivity()
+ is ErrorEvent.UnknownError, is ErrorEvent.HttpException -> {
+ toast(it.msg ?: getString(R.string.error_IO_Exception))
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val TAG: String = AbilityFragment::class.java.simpleName
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityQueryHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityQueryHandler.kt
new file mode 100644
index 00000000..b5ea95dc
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityQueryHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.ability
+
+fun interface AbilityQueryHandler {
+ fun queryName(name: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilitySearchViewBindingAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilitySearchViewBindingAdapter.kt
new file mode 100644
index 00000000..1bcab1f0
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilitySearchViewBindingAdapter.kt
@@ -0,0 +1,21 @@
+package poke.rogue.helper.presentation.ability
+
+import androidx.appcompat.widget.SearchView
+import androidx.databinding.BindingAdapter
+
+@BindingAdapter("onQueryTextChange")
+fun setOnQueryTextListener(
+ searchView: SearchView,
+ onQueryTextChangeListener: AbilityQueryHandler,
+) {
+ searchView.setOnQueryTextListener(
+ object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ onQueryTextChangeListener.queryName(newText.toString())
+ return true
+ }
+ },
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityUiEventHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityUiEventHandler.kt
new file mode 100644
index 00000000..83a8dec5
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityUiEventHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.ability
+
+interface AbilityUiEventHandler {
+ fun navigateToDetail(abilityId: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityUiState.kt
new file mode 100644
index 00000000..c074c12a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityUiState.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.ability
+
+sealed interface AbilityUiState {
+ data object Loading : AbilityUiState
+
+ data class Success(val data: T) : AbilityUiState
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityViewHolder.kt
new file mode 100644
index 00000000..2b38471b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityViewHolder.kt
@@ -0,0 +1,16 @@
+package poke.rogue.helper.presentation.ability
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemAbilityDescriptionBinding
+import poke.rogue.helper.presentation.ability.model.AbilityUiModel
+
+class AbilityViewHolder(
+ private val binding: ItemAbilityDescriptionBinding,
+ private val onClickAbilityItem: AbilityUiEventHandler,
+) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(abilityUiModel: AbilityUiModel) {
+ binding.ability = abilityUiModel
+ binding.uiEventHandler = onClickAbilityItem
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityViewModel.kt
new file mode 100644
index 00000000..b12f53da
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityViewModel.kt
@@ -0,0 +1,81 @@
+package poke.rogue.helper.presentation.ability
+
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.AbilityRepository
+import poke.rogue.helper.presentation.ability.model.AbilityUiModel
+import poke.rogue.helper.presentation.ability.model.toUi
+import poke.rogue.helper.presentation.base.BaseViewModelFactory
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+
+class AbilityViewModel(
+ private val abilityRepository: AbilityRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger),
+ AbilityQueryHandler,
+ AbilityUiEventHandler {
+ private val _navigationToDetailEvent = MutableSharedFlow()
+ val navigationToDetailEvent: SharedFlow = _navigationToDetailEvent.asSharedFlow()
+
+ private val searchQuery = MutableStateFlow("")
+
+ @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
+ val uiState: StateFlow>> =
+ refreshEvent.onStart {
+ if (searchQuery.value.isEmpty()) {
+ emit(Unit)
+ }
+ }.flatMapLatest {
+ searchQuery
+ .debounce(300)
+ .mapLatest { query ->
+ val abilities = queriedAbilities(query)
+ AbilityUiState.Success(abilities)
+ }.catch { e ->
+ handlePokemonError(e)
+ }
+ }.stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000L),
+ AbilityUiState.Loading,
+ )
+
+ override fun queryName(name: String) {
+ viewModelScope.launch {
+ searchQuery.emit(name)
+ }
+ }
+
+ private suspend fun queriedAbilities(query: String): List = abilityRepository.abilities(query).map { it.toUi() }
+
+ override fun navigateToDetail(abilityId: String) {
+ viewModelScope.launch {
+ _navigationToDetailEvent.emit(abilityId)
+ }
+ }
+
+ companion object {
+ fun factory(abilityRepository: AbilityRepository): ViewModelProvider.Factory =
+ BaseViewModelFactory {
+ AbilityViewModel(abilityRepository)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailAdapter.kt
new file mode 100644
index 00000000..cab33949
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailAdapter.kt
@@ -0,0 +1,39 @@
+package poke.rogue.helper.presentation.ability.detail
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemAbilityDetailPokemonBinding
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class AbilityDetailAdapter(private val onClickPokemonItem: AbilityDetailUiEventHandler) :
+ ListAdapter(poketmonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): AbilityDetailViewHolder =
+ AbilityDetailViewHolder(
+ ItemAbilityDetailPokemonBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickPokemonItem,
+ )
+
+ override fun onBindViewHolder(
+ holder: AbilityDetailViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val poketmonComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragment.kt
new file mode 100644
index 00000000..4f93bf66
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragment.kt
@@ -0,0 +1,111 @@
+package poke.rogue.helper.presentation.ability.detail
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentAbilityDetailBinding
+import poke.rogue.helper.presentation.ability.model.toUi
+import poke.rogue.helper.presentation.base.error.ErrorEvent
+import poke.rogue.helper.presentation.base.error.NetworkErrorActivity
+import poke.rogue.helper.presentation.base.toolbar.ToolbarFragment
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailActivity
+import poke.rogue.helper.presentation.home.HomeActivity
+import poke.rogue.helper.presentation.util.fragment.startActivity
+import poke.rogue.helper.presentation.util.fragment.toast
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class AbilityDetailFragment :
+ ToolbarFragment(R.layout.fragment_ability_detail) {
+ private val viewModel by viewModel()
+
+ private val adapter: AbilityDetailAdapter by lazy { AbilityDetailAdapter(viewModel) }
+
+ override val toolbar: Toolbar
+ get() = binding.toolbarAbilityDetail
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val abilityId = arguments?.getString(ABILITY_ID) ?: ""
+ viewModel.updateAbilityDetail(abilityId)
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initView()
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initView() {
+ binding.lifecycleOwner = viewLifecycleOwner
+ binding.vm = viewModel
+ }
+
+ private fun initAdapter() {
+ val decoration = GridSpacingItemDecoration(3, 9.dp, false)
+ binding.rvAbilityDetailPokemon.adapter = adapter
+ binding.rvAbilityDetailPokemon.addItemDecoration(decoration)
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.abilityDetail.collect { abilityDetail ->
+ when (abilityDetail) {
+ is AbilityDetailUiState.Loading -> {}
+ is AbilityDetailUiState.Success -> {
+ binding.abilityUiModel = abilityDetail.data.toUi()
+ adapter.submitList(abilityDetail.data.pokemons)
+ }
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.commonErrorEvent.collect {
+ when (it) {
+ is ErrorEvent.NetworkException -> startActivity()
+ is ErrorEvent.UnknownError, is ErrorEvent.HttpException -> {
+ toast(it.msg ?: getString(R.string.error_IO_Exception))
+ }
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.errorEvent.collect {
+ toast(R.string.ability_detail_error_abilityId)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.navigationToPokemonDetailEvent.collect { pokemonId ->
+ PokemonDetailActivity.intent(requireContext(), pokemonId).let(::startActivity)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.navigateToHomeEvent.collect {
+ if (it) {
+ startActivity(HomeActivity.intent(requireContext()))
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val ABILITY_ID = "abilityId"
+ private val TAG = AbilityDetailFragment::class.java.simpleName
+
+ fun bundleOf(abilityId: String) =
+ Bundle().apply {
+ putString(ABILITY_ID, abilityId)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailUiEventHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailUiEventHandler.kt
new file mode 100644
index 00000000..0b787531
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailUiEventHandler.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.ability.detail
+
+interface AbilityDetailUiEventHandler {
+ fun navigateToPokemonDetail(pokemonId: String)
+
+ fun navigateToHome()
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailUiState.kt
new file mode 100644
index 00000000..4a46d725
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailUiState.kt
@@ -0,0 +1,9 @@
+package poke.rogue.helper.presentation.ability.detail
+
+interface AbilityDetailUiState {
+ data object Loading : AbilityDetailUiState
+
+ data object Empty : AbilityDetailUiState
+
+ data class Success(val data: T) : AbilityDetailUiState
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewHolder.kt
new file mode 100644
index 00000000..dd98d8fb
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewHolder.kt
@@ -0,0 +1,43 @@
+package poke.rogue.helper.presentation.ability.detail
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemAbilityDetailPokemonBinding
+import poke.rogue.helper.presentation.dex.PokemonTypesAdapter
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.type.view.TypeChip
+import poke.rogue.helper.presentation.util.view.dp
+
+class AbilityDetailViewHolder(
+ private val binding: ItemAbilityDetailPokemonBinding,
+ private val onClickPokemonItem: AbilityDetailUiEventHandler,
+) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(pokemonUiModel: PokemonUiModel) {
+ binding.pokemon = pokemonUiModel
+ binding.uiEventHandler = onClickPokemonItem
+
+ val typesLayout = binding.layoutAbilityDetailPokemonTypes
+
+ val pokemonTypesAdapter =
+ PokemonTypesAdapter(
+ context = binding.root.context,
+ viewGroup = typesLayout,
+ )
+
+ pokemonTypesAdapter.addTypes(
+ types = pokemonUiModel.types,
+ config = typesUiConfig,
+ spacingBetweenTypes = 0.dp,
+ )
+ }
+
+ companion object {
+ private val typesUiConfig =
+ TypeChip.PokemonTypeViewConfiguration(
+ hasBackGround = true,
+ nameSize = 8.dp,
+ iconSize = 18.dp,
+ spacing = 0.dp,
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewModel.kt
new file mode 100644
index 00000000..85f004d8
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewModel.kt
@@ -0,0 +1,74 @@
+package poke.rogue.helper.presentation.ability.detail
+
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.AbilityRepository
+import poke.rogue.helper.presentation.ability.model.AbilityDetailUiModel
+import poke.rogue.helper.presentation.ability.model.toUi
+import poke.rogue.helper.presentation.base.BaseViewModelFactory
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+
+class AbilityDetailViewModel(
+ private val abilityRepository: AbilityRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), AbilityDetailUiEventHandler {
+ private val _abilityDetail =
+ MutableStateFlow>(AbilityDetailUiState.Loading)
+ val abilityDetail = _abilityDetail.asStateFlow()
+
+ private val _navigationToPokemonDetailEvent = MutableSharedFlow()
+ val navigationToPokemonDetailEvent: SharedFlow =
+ _navigationToPokemonDetailEvent.asSharedFlow()
+
+ private val _navigateToHomeEvent = MutableSharedFlow()
+ val navigateToHomeEvent: SharedFlow = _navigateToHomeEvent.asSharedFlow()
+
+ private val _errorEvent: MutableSharedFlow = MutableSharedFlow()
+ val errorEvent = _errorEvent.asSharedFlow()
+
+ val isEmpty: StateFlow =
+ abilityDetail.map { it is AbilityDetailUiState.Success && it.data.pokemons.isEmpty() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
+
+ override fun navigateToPokemonDetail(pokemonId: String) {
+ viewModelScope.launch {
+ _navigationToPokemonDetailEvent.emit(pokemonId)
+ }
+ }
+
+ override fun navigateToHome() {
+ viewModelScope.launch {
+ _navigateToHomeEvent.emit(true)
+ }
+ }
+
+ fun updateAbilityDetail(abilityId: String) {
+ if (abilityId.isBlank()) {
+ _errorEvent.tryEmit(Unit)
+ return
+ }
+ viewModelScope.launch(errorHandler) {
+ val abilityDetail = abilityRepository.abilityDetail(abilityId).toUi()
+ _abilityDetail.value = AbilityDetailUiState.Success(abilityDetail)
+ }
+ }
+
+ companion object {
+ fun factory(abilityRepository: AbilityRepository): ViewModelProvider.Factory =
+ BaseViewModelFactory {
+ AbilityDetailViewModel(abilityRepository)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/type/AbilityDetailTypeAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/type/AbilityDetailTypeAdapter.kt
new file mode 100644
index 00000000..52c77494
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/type/AbilityDetailTypeAdapter.kt
@@ -0,0 +1,46 @@
+package poke.rogue.helper.presentation.ability.detail.type
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemTypeRightNameBinding
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class AbilityDetailTypeAdapter :
+ ListAdapter(typeComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): PokemonTypeViewHolder =
+ PokemonTypeViewHolder(
+ ItemTypeRightNameBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ )
+
+ override fun onBindViewHolder(
+ viewHolder: PokemonTypeViewHolder,
+ position: Int,
+ ) {
+ viewHolder.bind(getItem(position))
+ }
+
+ class PokemonTypeViewHolder(private val binding: ItemTypeRightNameBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(type: TypeUiModel) {
+ binding.type = type
+ }
+ }
+
+ companion object {
+ private val typeComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/model/AbilityDetailUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/model/AbilityDetailUiModel.kt
new file mode 100644
index 00000000..dffc7a54
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/model/AbilityDetailUiModel.kt
@@ -0,0 +1,30 @@
+package poke.rogue.helper.presentation.ability.model
+
+import poke.rogue.helper.data.model.AbilityDetail
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.dex.model.toUi
+
+class AbilityDetailUiModel(
+ val title: String,
+ val description: String,
+ val pokemons: List,
+) {
+ companion object {
+ private const val DUMMY_ABILITY_NAME = "์
์ทจ"
+ private const val DUMMY_ABILITY_DESCRIPTION = "์
์ทจ๋ฅผ ํ๊ฒจ ์๋๋ฐฉ์ ํน์ฑ์ ๋ฌดํจํ ์ํต๋๋ค."
+
+ val DUMMY =
+ AbilityDetailUiModel(
+ title = DUMMY_ABILITY_NAME,
+ description = DUMMY_ABILITY_DESCRIPTION,
+ pokemons = emptyList(),
+ )
+ }
+}
+
+fun AbilityDetail.toUi(): AbilityDetailUiModel =
+ AbilityDetailUiModel(
+ title = this.title,
+ description = this.description,
+ pokemons = pokemons.toUi(),
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/ability/model/AbilityUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/ability/model/AbilityUiModel.kt
new file mode 100644
index 00000000..2a1ec3d6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/model/AbilityUiModel.kt
@@ -0,0 +1,64 @@
+package poke.rogue.helper.presentation.ability.model
+
+import poke.rogue.helper.data.model.Ability
+
+data class AbilityUiModel(
+ val id: String,
+ val title: String,
+ val description: String,
+ val shortening: Boolean = true,
+) {
+ companion object {
+ private const val DUMMY_ABILITY_NAME = "์
์ทจ"
+ private const val DUMMY_ABILITY_DESCRIPTION = "์๋๋ฐฉ์ ํน์ฑ์ ๋ฌดํจํํ๋ค."
+
+ val DUMMY =
+ AbilityUiModel(
+ id = "-1",
+ title = DUMMY_ABILITY_NAME,
+ description = DUMMY_ABILITY_DESCRIPTION,
+ )
+ val dummys: List =
+ listOf(
+ AbilityUiModel("1", "์
์ทจ", "์
์ทจ๋ฅผ ํ๊ฒจ์ ๊ณต๊ฒฉํ์ ๋ ์๋๊ฐ ํ์ฃฝ์ ๋๊ฐ ์๋ค."),
+ AbilityUiModel("2", "์๋น", "๋ฑ์ฅํ์ ๋ ๋ ์จ๋ฅผ ๋น๋ก ๋ง๋ ๋ค."),
+ AbilityUiModel("3", "๊ฐ์", "๋งค ํด ์คํผ๋๊ฐ ์ฌ๋ผ๊ฐ๋ค."),
+ AbilityUiModel("4", "์ ํฌ๋ฌด์ฅ", "๋จ๋จํ ๊ป์ง์ ๋ณดํธ๋ฐ์ ์๋์ ๊ณต๊ฒฉ์ด ๊ธ์์ ๋ง์ง ์๋๋ค."),
+ AbilityUiModel("5", "์น๊ณจ์ฐธ", "์๋ ๊ธฐ์ ์ ๋ฐ์๋ ์ผ๊ฒฉ์ผ๋ก ์ฐ๋ฌ์ง์ง ์๋๋ค. ์ผ๊ฒฉํ์ด ๊ธฐ์ ๋ ํจ๊ณผ ์๋ค."),
+ AbilityUiModel("6", "์ ์ฐ", "์ฃผ๋ณ์ ์ตํ๊ฒ ํจ์ผ๋ก์จ ์ํญ ๋ฑ ํญ๋ฐํ๋ ๊ธฐ์ ์ ์๋ฌด๋ ๋ชป ์ฐ๊ฒ ํ๋ค."),
+ AbilityUiModel("7", "๋ชจ๋์จ๊ธฐ", "๋ชจ๋๋ฐ๋์ผ ๋ ํํผ์จ์ด ์ฌ๋ผ๊ฐ๋ค."),
+ AbilityUiModel("8", "์ ์ ๊ธฐ", "์ ์ ๊ธฐ๋ฅผ ๋ชธ์ ๋๋ฌ ์ ์ดํ ์๋๋ฅผ ๋ง๋น์ํฌ ๋๊ฐ ์๋ค."),
+ AbilityUiModel("9", "์ถ์ (P)", "์ ๊ธฐํ์
์ ๊ธฐ์ ์ ๋ฐ์ผ๋ฉด ๋ฐ๋ฏธ์ง๋ฅผ ๋ฐ์ง ์๊ณ ํ๋ณตํ๋ค."),
+ AbilityUiModel("10", "์ ์ (P)", "๋ฌผํ์
์ ๊ธฐ์ ์ ๋ฐ์ผ๋ฉด ๋ฐ๋ฏธ์ง๋ฅผ ๋ฐ์ง ์๊ณ ํ๋ณตํ๋ค."),
+ AbilityUiModel("11", "๋๊ฐ", "๋๊ฐํด์ ํค๋กฑํค๋กฑ์ด๋ ๋๋ฐ ์ํ๊ฐ ๋์ง ์๋๋ค."),
+ AbilityUiModel("12", "๋ ์จ๋ถ์ ", "๋ชจ๋ ๋ ์จ์ ์ํฅ์ด ์์ด์ง๋ค."),
+ AbilityUiModel("13", "๋ณต์", "๋ณต์์ ๊ฐ์ง๊ณ ์์ด ๊ธฐ์ ์ ๋ช
์ค๋ฅ ์ด ์ฌ๋ผ๊ฐ๋ค."),
+ AbilityUiModel("14", "๋ถ๋ฉด", "์ ๋ค์ง ๋ชปํ๋ ์ฒด์ง์ด๋ผ ์ ๋ฆ ์ํ๊ฐ ๋์ง ์๋๋ค."),
+ AbilityUiModel("15", "๋ณ์", "์๋์๊ฒ ๋ฐ์ ๊ธฐ์ ์ ํ์
์ผ๋ก ์์ ์ ํ์
์ด ๋ณํํ๋ค."),
+ AbilityUiModel("16", "๋ฉด์ญ", "์ฒด๋ด์ ๋ฉด์ญ์ ๊ฐ์ง๊ณ ์์ด ๋
์ํ๊ฐ ๋์ง ์๋๋ค."),
+ AbilityUiModel("17", "ํ์ค๋ฅด๋๋ถ๊ฝ", "๋ถ๊ฝํ์
์ ๊ธฐ์ ์ ๋ฐ์ผ๋ฉด ๋ถ๊ฝ์ ๋ฐ์์ ์์ ์ด ์ฌ์ฉํ๋ ๋ถ๊ฝํ์
์ ๊ธฐ์ ์ด ๊ฐํด์ง๋ค."),
+ AbilityUiModel("18", "์ธ๋ถ (P)", "์ธ๋ถ์ ๋ณดํธ๋ฐ์ ๊ธฐ์ ์ ์ถ๊ฐ ํจ๊ณผ๋ฅผ ๋ฐ์ง ์๊ฒ ๋๋ค."),
+ AbilityUiModel("19", "๋ง์ดํ์ด์ค", "๋ง์ดํ์ด์ค๋ผ์ ํผ๋ ์ํ๊ฐ ๋์ง ์๋๋ค."),
+ AbilityUiModel("20", "ํ๋ฐ", "ํก๋ฐ์ผ๋ก ์ง๋ฉด์ ๋ฌ๋ผ๋ถ์ด ํฌ์ผ๋ชฌ์ ๊ต์ฒด์ํค๋ ๊ธฐ์ ์ด๋ ๋๊ตฌ์ ํจ๊ณผ๋ฅผ ๋ฐํํ์ง ๋ชปํ๊ฒ ํ๋ค."),
+ AbilityUiModel("21", "์ํ", "๋ฑ์ฅํ์ ๋ ์ํํด์ ์๋๋ฅผ ์์ถ์์ผ ์๋์ ๊ณต๊ฒฉ์ ๋จ์ด๋จ๋ฆฐ๋ค."),
+ AbilityUiModel("22", "๊ทธ๋ฆผ์๋ฐ๊ธฐ", "์๋์ ๊ทธ๋ฆผ์๋ฅผ ๋ฐ์ ๋๋ง์น๊ฑฐ๋ ๊ต์ฒดํ ์ ์๊ฒ ํ๋ค."),
+ AbilityUiModel("23", "๊น์น ํํผ๋ถ", "๊ณต๊ฒฉ์ ๋ฐ์์ ๋ ์์ ์๊ฒ ์ ์ดํ ์๋๋ฅผ ๊น์น ๊น์น ํ ํผ๋ถ๋ก ์์ฒ๋ฅผ ์
ํ๋ค."),
+ AbilityUiModel("24", "๋ถ๊ฐ์ฌ์๋ถ์ ", "ํจ๊ณผ๊ฐ ๊ต์ฅํ ๊ธฐ์ ๋ง ๋ง๋ ๋ถ๊ฐ์ฌ์ํ ํ."),
+ )
+ }
+}
+
+fun Ability.toUi(): AbilityUiModel =
+ AbilityUiModel(
+ id = id,
+ title = title,
+ description = description,
+ )
+
+fun AbilityDetailUiModel.toUi(): AbilityUiModel =
+ AbilityUiModel(
+ id = "0",
+ title = title,
+ description = description,
+ shortening = false,
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/BaseViewModelFactory.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/BaseViewModelFactory.kt
new file mode 100644
index 00000000..35b11d7b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/BaseViewModelFactory.kt
@@ -0,0 +1,24 @@
+package poke.rogue.helper.presentation.base
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+
+/**
+ * ViewModel์ ์์ฑํ๋ Factory
+ *
+ * sample
+ * ```kotlin
+ * class MyViewModel : ViewModel() {
+ * ...
+ * companion object {
+ * fun factory() = BaseViewModelFactory { MyViewModel() }
+ * }
+ * }
+ * ```
+ */
+class BaseViewModelFactory(
+ private val creator: () -> VM,
+) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T = creator() as T
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/BindingActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/BindingActivity.kt
new file mode 100644
index 00000000..fcdf8545
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/BindingActivity.kt
@@ -0,0 +1,18 @@
+package poke.rogue.helper.presentation.base
+
+import android.os.Bundle
+import androidx.annotation.LayoutRes
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+
+abstract class BindingActivity(
+ @LayoutRes private val layoutRes: Int,
+) : AppCompatActivity() {
+ protected lateinit var binding: T
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = DataBindingUtil.setContentView(this, layoutRes)
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/BindingFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/BindingFragment.kt
new file mode 100644
index 00000000..f519a360
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/BindingFragment.kt
@@ -0,0 +1,38 @@
+package poke.rogue.helper.presentation.base
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+import androidx.fragment.app.Fragment
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.presentation.util.logScreenView
+
+abstract class BindingFragment(
+ @LayoutRes private val layoutRes: Int,
+) : Fragment() {
+ private var _binding: T? = null
+ protected val binding get() = _binding ?: error("Binding is not initialized")
+ private val logger: AnalyticsLogger = analyticsLogger()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View? {
+ _binding = DataBindingUtil.inflate(inflater, layoutRes, container, false)
+ if (savedInstanceState == null) {
+ logger.logScreenView(binding::class.java)
+ }
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorEvent.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorEvent.kt
new file mode 100644
index 00000000..6b0b2885
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorEvent.kt
@@ -0,0 +1,9 @@
+package poke.rogue.helper.presentation.base.error
+
+sealed class ErrorEvent(val msg: String? = null) {
+ data class HttpException(val error: Throwable) : ErrorEvent(error.message)
+
+ data object NetworkException : ErrorEvent()
+
+ data class UnknownError(val error: Throwable) : ErrorEvent(error.message)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleActivity.kt
new file mode 100644
index 00000000..15b06990
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleActivity.kt
@@ -0,0 +1,39 @@
+package poke.rogue.helper.presentation.base.error
+
+import android.os.Bundle
+import androidx.annotation.LayoutRes
+import androidx.databinding.ViewDataBinding
+import poke.rogue.helper.R
+import poke.rogue.helper.presentation.base.toolbar.ToolbarActivity
+import poke.rogue.helper.presentation.util.context.startActivity
+import poke.rogue.helper.presentation.util.context.toast
+import poke.rogue.helper.presentation.util.repeatOnStarted
+
+abstract class ErrorHandleActivity(
+ @LayoutRes layoutRes: Int,
+) :
+ ToolbarActivity(layoutRes) {
+ abstract val errorViewModel: ErrorHandleViewModel
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ observeErrorEvent()
+ }
+
+ protected open fun handleErrorEvent(event: ErrorEvent) {
+ when (event) {
+ is ErrorEvent.NetworkException -> startActivity()
+ is ErrorEvent.UnknownError, is ErrorEvent.HttpException -> {
+ toast(event.msg ?: getString(R.string.error_IO_Exception))
+ }
+ }
+ }
+
+ private fun observeErrorEvent() {
+ repeatOnStarted {
+ errorViewModel.commonErrorEvent.collect {
+ handleErrorEvent(it)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleFragment.kt
new file mode 100644
index 00000000..5ad9215b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleFragment.kt
@@ -0,0 +1,46 @@
+package poke.rogue.helper.presentation.base.error
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.LayoutRes
+import androidx.databinding.ViewDataBinding
+import poke.rogue.helper.R
+import poke.rogue.helper.presentation.base.toolbar.ToolbarFragment
+import poke.rogue.helper.presentation.util.fragment.startActivity
+import poke.rogue.helper.presentation.util.fragment.toast
+import poke.rogue.helper.presentation.util.repeatOnStarted
+
+abstract class ErrorHandleFragment(
+ @LayoutRes layoutRes: Int,
+) : ToolbarFragment(layoutRes) {
+ abstract val errorViewModel: ErrorHandleViewModel
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ observeErrorEvent()
+ }
+
+ protected open fun handleErrorEvent(event: ErrorEvent) {
+ when (event) {
+ is ErrorEvent.NetworkException ->
+ startActivity {
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ is ErrorEvent.UnknownError, is ErrorEvent.HttpException -> {
+ toast(event.msg ?: getString(R.string.error_IO_Exception))
+ }
+ }
+ }
+
+ private fun observeErrorEvent() {
+ repeatOnStarted {
+ errorViewModel.commonErrorEvent.collect {
+ handleErrorEvent(it)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleViewModel.kt
new file mode 100644
index 00000000..61d7b3d4
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/ErrorHandleViewModel.kt
@@ -0,0 +1,48 @@
+package poke.rogue.helper.presentation.base.error
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.data.exception.HttpException
+import poke.rogue.helper.data.exception.NetworkException
+import poke.rogue.helper.data.exception.PokeException
+import poke.rogue.helper.data.exception.UnknownException
+import poke.rogue.helper.presentation.util.event.RefreshEventBus
+
+abstract class ErrorHandleViewModel(private val logger: AnalyticsLogger) : ViewModel() {
+ private val _commonErrorEvent = MutableSharedFlow()
+ val commonErrorEvent: SharedFlow = _commonErrorEvent.asSharedFlow()
+
+ val refreshEvent: Flow = RefreshEventBus.event
+
+ protected open val errorHandler =
+ CoroutineExceptionHandler { _, throwable ->
+ handlePokemonError(throwable)
+ }
+
+ protected fun handlePokemonError(throwable: Throwable) {
+ if (throwable !is PokeException) {
+ logger.logError(throwable, "Poke Exception ์ด ์๋ ์๋ฌ ๋ฐ์")
+ emitErrorEvent(ErrorEvent.UnknownError(throwable))
+ return
+ }
+ logger.logError(throwable, throwable.message)
+ when (throwable) {
+ is NetworkException -> emitErrorEvent(ErrorEvent.NetworkException)
+ is HttpException -> emitErrorEvent(ErrorEvent.HttpException(throwable))
+ is UnknownException -> emitErrorEvent(ErrorEvent.UnknownError(throwable))
+ }
+ }
+
+ private fun emitErrorEvent(errorEvent: ErrorEvent) {
+ viewModelScope.launch {
+ _commonErrorEvent.emit(errorEvent)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/error/NetworkErrorActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/NetworkErrorActivity.kt
new file mode 100644
index 00000000..2a81b75a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/error/NetworkErrorActivity.kt
@@ -0,0 +1,29 @@
+package poke.rogue.helper.presentation.base.error
+
+import android.os.Bundle
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityNetworkErrorBinding
+import poke.rogue.helper.presentation.base.BindingActivity
+import poke.rogue.helper.presentation.util.context.isNetworkConnected
+import poke.rogue.helper.presentation.util.context.toast
+import poke.rogue.helper.presentation.util.event.RefreshEventBus
+import poke.rogue.helper.presentation.util.view.setOnSingleClickListener
+
+class NetworkErrorActivity :
+ BindingActivity(R.layout.activity_network_error) {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ checkNetworkConnect()
+ }
+
+ private fun checkNetworkConnect() {
+ binding.btnNetworkErrorRetry.setOnSingleClickListener {
+ if (isNetworkConnected()) {
+ RefreshEventBus.refresh()
+ finish()
+ return@setOnSingleClickListener
+ }
+ toast(getString(R.string.network_error_toast))
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/toolbar/ToolbarActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/toolbar/ToolbarActivity.kt
new file mode 100644
index 00000000..d1dd1cb6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/toolbar/ToolbarActivity.kt
@@ -0,0 +1,82 @@
+package poke.rogue.helper.presentation.base.toolbar
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.annotation.LayoutRes
+import androidx.appcompat.widget.Toolbar
+import androidx.databinding.ViewDataBinding
+import org.koin.android.ext.android.inject
+import poke.rogue.helper.R
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.presentation.base.BindingActivity
+import poke.rogue.helper.presentation.util.context.drawableOf
+import poke.rogue.helper.presentation.util.context.stringOf
+import poke.rogue.helper.presentation.util.logClickEvent
+
+abstract class ToolbarActivity(
+ @LayoutRes layoutRes: Int,
+) : BindingActivity(layoutRes) {
+ protected abstract val toolbar: Toolbar?
+ protected val logger by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initToolbar()
+ }
+
+ private fun initToolbar() {
+ toolbar?.let {
+ setSupportActionBar(it)
+ it.overflowIcon = drawableOf(R.drawable.ic_menu)
+ supportActionBar?.setDisplayShowTitleEnabled(true)
+ }
+ }
+
+ override fun onMenuOpened(
+ featureId: Int,
+ menu: Menu,
+ ): Boolean {
+ logger.logClickEvent(CLICK_MENU)
+ return super.onMenuOpened(featureId, menu)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.menu_toolbar, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.item_toolbar_pokerogue -> {
+ logger.logClickEvent(NAVIGATE_TO_POKE_ROGUE)
+ navigateToLink(R.string.home_pokerogue_url)
+ }
+
+ R.id.item_toolbar_feedback -> {
+ logger.logClickEvent(NAVIGATE_TO_FEED_BACK)
+ navigateToLink(R.string.home_pokeroque_surey_url)
+ }
+
+ android.R.id.home -> {
+ onBackPressedDispatcher.onBackPressed()
+ }
+ }
+ return true
+ }
+
+ private fun navigateToLink(urlRes: Int) {
+ Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse(stringOf(urlRes)),
+ ).also { startActivity(it) }
+ }
+
+ companion object {
+ private const val CLICK_MENU = "Click_Menu_Button"
+ private const val NAVIGATE_TO_POKE_ROGUE = "Nav_Toolbar_To_PokeRogue_Game"
+ private const val NAVIGATE_TO_FEED_BACK = "Nav_FeedBack"
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/base/toolbar/ToolbarFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/base/toolbar/ToolbarFragment.kt
new file mode 100644
index 00000000..158089f0
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/base/toolbar/ToolbarFragment.kt
@@ -0,0 +1,64 @@
+package poke.rogue.helper.presentation.base.toolbar
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.LayoutRes
+import androidx.appcompat.widget.Toolbar
+import androidx.databinding.ViewDataBinding
+import poke.rogue.helper.R
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.presentation.base.BindingFragment
+import poke.rogue.helper.presentation.util.fragment.drawableOf
+import poke.rogue.helper.presentation.util.logClickEvent
+
+abstract class ToolbarFragment(
+ @LayoutRes layoutRes: Int,
+) : BindingFragment(layoutRes) {
+ protected abstract val toolbar: Toolbar?
+ private val logger: AnalyticsLogger = analyticsLogger()
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initToolbar()
+ }
+
+ private fun initToolbar() {
+ toolbar?.apply {
+ inflateMenu(R.menu.menu_toolbar)
+ overflowIcon = drawableOf(R.drawable.ic_menu)
+ setNavigationOnClickListener {
+ requireActivity().onBackPressedDispatcher.onBackPressed()
+ }
+ setOnMenuItemClickListener { menuItem ->
+ when (menuItem.itemId) {
+ R.id.item_toolbar_pokerogue -> {
+ logger.logClickEvent(NAVIGATE_TO_POKE_ROGUE)
+ navigateToLink(R.string.home_pokerogue_url)
+ }
+
+ R.id.item_toolbar_feedback -> {
+ logger.logClickEvent(NAVIGATE_TO_FEED_BACK)
+ navigateToLink(R.string.home_pokeroque_surey_url)
+ }
+ }
+ true
+ }
+ }
+ }
+
+ private fun navigateToLink(urlRes: Int) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(urlRes)))
+ startActivity(intent)
+ }
+
+ companion object {
+ private const val NAVIGATE_TO_POKE_ROGUE = "Nav_Toolbar_To_PokeRogue_Game"
+ private const val NAVIGATE_TO_FEED_BACK = "Nav_FeedBack"
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/AnalyticsExtensions.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/AnalyticsExtensions.kt
new file mode 100644
index 00000000..b1c8784b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/AnalyticsExtensions.kt
@@ -0,0 +1,41 @@
+package poke.rogue.helper.presentation.battle
+
+import poke.rogue.helper.analytics.AnalyticsEvent
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.presentation.battle.model.SelectionData
+
+fun AnalyticsLogger.logPokemonSkillSelection(selection: SelectionData.WithSkill) {
+ val eventType = "pokemon_battle_skill_selection"
+ logEvent(
+ AnalyticsEvent(
+ type = eventType,
+ extras = selection.toAnalyticsParams(),
+ ),
+ )
+}
+
+fun AnalyticsLogger.logBattlePokemonSelection(selection: SelectionData.WithoutSkill) {
+ val eventType = "pokemon_battle_selection"
+ logEvent(
+ AnalyticsEvent(
+ type = eventType,
+ extras = selection.toAnalyticsParams(),
+ ),
+ )
+}
+
+private fun SelectionData.WithoutSkill.toAnalyticsParams(): List {
+ return listOf(
+ AnalyticsEvent.Param(key = "pokemon_id", value = selectedPokemon.id),
+ AnalyticsEvent.Param(key = "pokemon_name", value = selectedPokemon.name),
+ )
+}
+
+private fun SelectionData.WithSkill.toAnalyticsParams(): List {
+ return listOf(
+ AnalyticsEvent.Param(key = "pokemon_id", value = selectedPokemon.id),
+ AnalyticsEvent.Param(key = "pokemon_name", value = selectedPokemon.name),
+ AnalyticsEvent.Param(key = "skill_id", value = selectedSkill.id),
+ AnalyticsEvent.Param(key = "skill_name", value = selectedSkill.name),
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleActivity.kt
new file mode 100644
index 00000000..0ec1139b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleActivity.kt
@@ -0,0 +1,192 @@
+package poke.rogue.helper.presentation.battle
+
+import WeatherSpinnerAdapter
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.widget.Toolbar
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityBattleBinding
+import poke.rogue.helper.presentation.base.toolbar.ToolbarActivity
+import poke.rogue.helper.presentation.battle.model.SelectionData
+import poke.rogue.helper.presentation.battle.model.WeatherUiModel
+import poke.rogue.helper.presentation.battle.selection.BattleSelectionActivity
+import poke.rogue.helper.presentation.battle.selection.pokemon.addPokemonTypes
+import poke.rogue.helper.presentation.battle.view.itemSelectListener
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailActivity
+import poke.rogue.helper.presentation.util.context.colorOf
+import poke.rogue.helper.presentation.util.context.startActivity
+import poke.rogue.helper.presentation.util.context.stringOf
+import poke.rogue.helper.presentation.util.parcelable
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.serializable
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.presentation.util.view.setImage
+import poke.rogue.helper.presentation.util.view.setOnSingleClickListener
+import timber.log.Timber
+
+class BattleActivity : ToolbarActivity(R.layout.activity_battle) {
+ private val viewModel by viewModel {
+ parametersOf(
+ intent.getStringExtra(POKEMON_ID),
+ intent.serializable(SELECTION_TYPE),
+ )
+ }
+ private val weatherAdapter by lazy {
+ WeatherSpinnerAdapter(this)
+ }
+
+ private val activityResultLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val data =
+ result.data?.parcelable(BattleSelectionActivity.KEY_SELECTION_RESULT)
+ if (data != null) {
+ viewModel.updatePokemonSelection(data)
+ }
+ }
+ }
+ override val toolbar: Toolbar
+ get() = binding.toolbarBattle
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initView()
+ initSpinner()
+ initObserver()
+ }
+
+ private fun initView() {
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ }
+
+ private fun initSpinner() {
+ binding.spinnerWeather.adapter = weatherAdapter
+ binding.spinnerWeather.onItemSelectedListener =
+ itemSelectListener {
+ viewModel.updateWeather(it)
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ viewModel.weathers.collect {
+ weatherAdapter.updateWeathers(it)
+ }
+ }
+ repeatOnStarted {
+ viewModel.weatherPos
+ .collect {
+ Timber.tag("weatherPos").d("weatherPos: $it")
+ binding.spinnerWeather.setSelection(it)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.selectedState.collect {
+ if (it.minePokemon is BattleSelectionUiState.Selected) {
+ val selected = it.minePokemon.content
+ binding.ivMinePokemon.setImage(selected.backImageUrl)
+ binding.tvMinePokemon.text = selected.name
+ binding.flexboxMineTypes.addPokemonTypes(
+ types = selected.types,
+ spacingBetweenTypes = 4.dp,
+ )
+ binding.btnMinePokemonDetail.setOnSingleClickListener {
+ viewModel.navigationToDetail(selected.id)
+ }
+ }
+
+ if (it.skill is BattleSelectionUiState.Selected) {
+ binding.tvSkillTitle.text = it.skill.content.name
+ }
+
+ if (it.opponentPokemon is BattleSelectionUiState.Selected) {
+ val selected = it.opponentPokemon.content
+ binding.ivOpponentPokemon.setImage(selected.frontImageUrl)
+ binding.tvOpponentPokemon.text = selected.name
+ binding.flexboxOpponentTypes.addPokemonTypes(
+ types = selected.types,
+ spacingBetweenTypes = 4.dp,
+ )
+ binding.btnOpponentPokemonDetail.setOnSingleClickListener {
+ viewModel.navigationToDetail(selected.id)
+ }
+ }
+
+ if (it.weather is BattleSelectionUiState.Selected) {
+ val selected = it.weather.content
+ binding.tvWeatherDescription.text = selected.effect
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.navigationEvent.collect { event ->
+ when (event) {
+ is NavigateToSelection -> navigateToSelection(event)
+ is NavigateToDetail -> navigateToDetail(event)
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.battleResult.collect {
+ if (it is BattleResultUiState.Success) {
+ val result = it.result
+ binding.tvPowerContent.text = result.power
+ binding.tvMultiplierContent.text = result.multiplier
+ binding.tvMultiplierContent.setTextColor(colorOf(result.colorRes))
+ binding.tvCalculatedPowerContent.text = result.calculatedResult
+ binding.tvAccuracyContent.text =
+ stringOf(R.string.battle_accuracy_title, result.accuracy)
+ }
+ }
+ }
+ }
+
+ private fun navigateToSelection(event: NavigateToSelection) {
+ val (selectionMode, previousSelection) = event
+ val intent =
+ BattleSelectionActivity.intent(
+ this@BattleActivity,
+ selectionMode,
+ previousSelection,
+ )
+ activityResultLauncher.launch(intent)
+ }
+
+ private fun navigateToDetail(event: NavigateToDetail) {
+ startActivity {
+ putExtras(
+ PokemonDetailActivity.intent(
+ this@BattleActivity,
+ event.pokemonId,
+ ),
+ )
+ }
+ }
+
+ companion object {
+ private const val POKEMON_ID = "pokemonId"
+ private const val SELECTION_TYPE = "selectionType"
+
+ fun intent(
+ context: Context,
+ pokemonId: String,
+ isMine: Boolean,
+ ): Intent =
+ Intent(context, BattleActivity::class.java).apply {
+ putExtra(POKEMON_ID, pokemonId)
+ val selectionType = if (isMine) SelectionType.MINE else SelectionType.OPPONENT
+ putExtra(SELECTION_TYPE, selectionType)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleNavigationHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleNavigationHandler.kt
new file mode 100644
index 00000000..6da7db78
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleNavigationHandler.kt
@@ -0,0 +1,9 @@
+package poke.rogue.helper.presentation.battle
+
+import poke.rogue.helper.presentation.battle.model.SelectionMode
+
+interface BattleNavigationHandler {
+ fun navigateToSelection(selectionMode: SelectionMode)
+
+ fun navigationToDetail(pokemonId: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleResultUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleResultUiState.kt
new file mode 100644
index 00000000..04447a1b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleResultUiState.kt
@@ -0,0 +1,13 @@
+package poke.rogue.helper.presentation.battle
+
+import poke.rogue.helper.presentation.battle.model.BattlePredictionUiModel
+
+sealed interface BattleResultUiState {
+ data object Idle : BattleResultUiState
+
+ data object Loading : BattleResultUiState
+
+ data class Success(val result: BattlePredictionUiModel) : BattleResultUiState
+}
+
+fun BattleResultUiState.isSuccess(): Boolean = this is BattleResultUiState.Success
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionUiState.kt
new file mode 100644
index 00000000..d4088425
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionUiState.kt
@@ -0,0 +1,11 @@
+package poke.rogue.helper.presentation.battle
+
+sealed interface BattleSelectionUiState {
+ data class Selected(val content: T) : BattleSelectionUiState
+
+ data object Empty : BattleSelectionUiState
+}
+
+fun BattleSelectionUiState.isSelected(): Boolean = this is BattleSelectionUiState.Selected
+
+fun BattleSelectionUiState.selectedData(): T? = (this as? BattleSelectionUiState.Selected)?.content
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionsState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionsState.kt
new file mode 100644
index 00000000..dab4b119
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionsState.kt
@@ -0,0 +1,32 @@
+package poke.rogue.helper.presentation.battle
+
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.WeatherUiModel
+
+data class BattleSelectionsState(
+ val weather: BattleSelectionUiState,
+ val minePokemon: BattleSelectionUiState,
+ val skill: BattleSelectionUiState,
+ val opponentPokemon: BattleSelectionUiState,
+) {
+ val allSelected: Boolean
+ get() =
+ listOf(minePokemon, skill, opponentPokemon, weather).all { it.isSelected() }
+
+ val isMinePokemonSelected: Boolean
+ get() = minePokemon.isSelected()
+
+ val isOpponentPokemonSelected: Boolean
+ get() = opponentPokemon.isSelected()
+
+ companion object {
+ val DEFAULT =
+ BattleSelectionsState(
+ BattleSelectionUiState.Empty,
+ BattleSelectionUiState.Empty,
+ BattleSelectionUiState.Empty,
+ BattleSelectionUiState.Empty,
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleViewModel.kt
new file mode 100644
index 00000000..83f9e001
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleViewModel.kt
@@ -0,0 +1,261 @@
+package poke.rogue.helper.presentation.battle
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.BattleRepository
+import poke.rogue.helper.data.repository.DexRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.model.BattlePredictionUiModel
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.SelectionData
+import poke.rogue.helper.presentation.battle.model.SelectionMode
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.WeatherUiModel
+import poke.rogue.helper.presentation.battle.model.toSelectionUi
+import poke.rogue.helper.presentation.battle.model.toUi
+
+class BattleViewModel(
+ private val battleRepository: BattleRepository,
+ private val pokemonRepository: DexRepository,
+ private val logger: AnalyticsLogger = analyticsLogger(),
+ pokemonId: String? = null,
+ selectionType: SelectionType? = null,
+) : ErrorHandleViewModel(logger), BattleNavigationHandler {
+ private val _weathers = MutableStateFlow(emptyList())
+ val weathers = _weathers.asStateFlow()
+
+ private val _selectedState = MutableStateFlow(BattleSelectionsState.DEFAULT)
+ val selectedState = _selectedState.asStateFlow()
+
+ val weatherPos: StateFlow =
+ combine(
+ battleRepository.weatherStream(),
+ weathers,
+ ) { weather, weathers ->
+ if (weathers.isEmpty()) return@combine null
+ val selectedWeather =
+ weather?.toUi()?.let { uiWeather -> weathers.find { it.id == uiWeather.id } }
+ ?: weathers.first()
+
+ _selectedState.value =
+ selectedState.value.copy(weather = BattleSelectionUiState.Selected(selectedWeather))
+
+ weathers.indexOfFirst { it.id == selectedWeather.id }
+ }.filterNotNull().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
+ private val _navigationEvent = MutableSharedFlow()
+ val navigationEvent = _navigationEvent.asSharedFlow()
+
+ val battleResult: StateFlow =
+ selectedState.map {
+ if (it.allSelected) {
+ val result = fetchBattlePredictionResult()
+ BattleResultUiState.Success(result)
+ } else {
+ BattleResultUiState.Idle
+ }
+ }.stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000),
+ BattleResultUiState.Idle,
+ )
+
+ val isBattleFetchSuccessful =
+ battleResult.map { it.isSuccess() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ init {
+ initWeathers()
+ handlePokemonSelection(pokemonId, selectionType)
+ }
+
+ private suspend fun fetchBattlePredictionResult(): BattlePredictionUiModel {
+ with(selectedState.value) {
+ val weatherId = requireNotNull(weather.selectedData()?.id) { "๋ ์จ๋ null์ผ ์ ์์ต๋๋ค." }
+ val myPokemonId =
+ requireNotNull(minePokemon.selectedData()?.id) { "๋ด ํฌ์ผ๋ชฌ์ null์ผ ์ ์์ต๋๋ค." }
+ val mySkillId = requireNotNull(skill.selectedData()?.id) { "๋ด ์คํฌ์ null์ผ ์ ์์ต๋๋ค." }
+ val opponentPokemonId =
+ requireNotNull(opponentPokemon.selectedData()?.id) { "์๋ ํฌ์ผ๋ชฌ์ null์ผ ์ ์์ต๋๋ค." }
+
+ return battleRepository.calculatedBattlePrediction(
+ weatherId = weatherId,
+ myPokemonId = myPokemonId,
+ mySkillId = mySkillId,
+ opponentPokemonId = opponentPokemonId,
+ ).toUi()
+ }
+ }
+
+ private fun initWeathers() {
+ viewModelScope.launch(errorHandler) {
+ val allWeathers = battleRepository.weathers().map { it.toUi() }
+ _weathers.value = allWeathers
+ }
+ }
+
+ private fun handlePokemonSelection(
+ pokemonId: String?,
+ selectionType: SelectionType?,
+ ) {
+ when {
+ pokemonId == null -> {
+ loadSavedMyPokemon()
+ loadSavedOpponentPokemon()
+ }
+
+ selectionType == SelectionType.MINE -> {
+ selectMyPokemon(pokemonId)
+ loadSavedOpponentPokemon()
+ }
+
+ selectionType == SelectionType.OPPONENT -> {
+ loadSavedMyPokemon()
+ selectOpponentPokemon(pokemonId)
+ }
+
+ else -> error("์ ํ ํ์
์ ๋ณด๋ฅผ ์ ์ ์์ต๋๋ค.")
+ }
+ }
+
+ private fun loadSavedMyPokemon() {
+ viewModelScope.launch {
+ battleRepository.pokemonWithSkillStream().first()?.let { (pokemon, skill) ->
+ updateMyPokemon(pokemon.toSelectionUi(), skill.toUi())
+ }
+ }
+ }
+
+ private fun loadSavedOpponentPokemon() {
+ viewModelScope.launch {
+ battleRepository.pokemonStream().first()?.let {
+ updateOpponentPokemon(it.toSelectionUi())
+ }
+ }
+ }
+
+ private fun selectMyPokemon(pokemonId: String) {
+ viewModelScope.launch {
+ val (pokemon, skill) = battleRepository.pokemonWithSkill(pokemonId)
+ val selectionData = SelectionData.WithSkill(pokemon.toSelectionUi(), skill.toUi())
+ updatePokemonSelection(selectionData)
+ }
+ }
+
+ private fun selectOpponentPokemon(pokemonId: String) {
+ viewModelScope.launch {
+ val pokemon = pokemonRepository.pokemon(pokemonId)
+ val selectionData = SelectionData.WithoutSkill(pokemon.toSelectionUi())
+ updatePokemonSelection(selectionData)
+ }
+ }
+
+ fun updateWeather(newWeather: WeatherUiModel) {
+ viewModelScope.launch {
+ val selectedWeather = BattleSelectionUiState.Selected(newWeather)
+ _selectedState.value = selectedState.value.copy(weather = selectedWeather)
+ battleRepository.saveWeather(newWeather.id)
+ }
+ }
+
+ fun updatePokemonSelection(selection: SelectionData) {
+ when (selection) {
+ is SelectionData.WithSkill -> {
+ val (selectedPokemon, selectedSkill) = selection
+ updateMyPokemon(selectedPokemon, selectedSkill)
+ viewModelScope.launch {
+ battleRepository.saveBattleSelection(selectedPokemon.id, selectedSkill.id)
+ }
+ logger.logPokemonSkillSelection(selection)
+ }
+
+ is SelectionData.WithoutSkill -> {
+ val selectedPokemon = selection.selectedPokemon
+ updateOpponentPokemon(selectedPokemon)
+ viewModelScope.launch {
+ battleRepository.saveBattleSelection(selectedPokemon.id)
+ }
+ logger.logBattlePokemonSelection(selection)
+ }
+
+ is SelectionData.NoSelection -> {}
+ }
+ }
+
+ private fun updateMyPokemon(
+ pokemon: PokemonSelectionUiModel,
+ skill: SkillSelectionUiModel,
+ ) {
+ val selectedPokemon = BattleSelectionUiState.Selected(pokemon)
+ val selectedSkill = BattleSelectionUiState.Selected(skill)
+ _selectedState.value =
+ selectedState.value.copy(minePokemon = selectedPokemon, skill = selectedSkill)
+ }
+
+ private fun updateOpponentPokemon(pokemon: PokemonSelectionUiModel) {
+ val selectedPokemon = BattleSelectionUiState.Selected(pokemon)
+ _selectedState.value = selectedState.value.copy(opponentPokemon = selectedPokemon)
+ }
+
+ override fun navigateToSelection(selectionMode: SelectionMode) {
+ viewModelScope.launch {
+ val selectedPokemon =
+ when (selectionMode) {
+ SelectionMode.POKEMON_ONLY -> selectedState.value.opponentPokemon.selectedData()
+ SelectionMode.POKEMON_AND_SKILL, SelectionMode.SKILL_FIRST -> selectedState.value.minePokemon.selectedData()
+ }
+
+ val data =
+ if (selectedPokemon == null) {
+ SelectionData.NoSelection
+ } else {
+ val hasSkillSelection = selectionMode != SelectionMode.POKEMON_ONLY
+ previousSelection(hasSkillSelection, selectedPokemon)
+ }
+
+ val navigationData = NavigateToSelection(selectionMode, data)
+ _navigationEvent.emit(navigationData)
+ }
+ }
+
+ private fun previousSelection(
+ hasSkillSelection: Boolean,
+ previousPokemonSelection: PokemonSelectionUiModel,
+ ): SelectionData =
+ if (hasSkillSelection) {
+ val skill =
+ requireNotNull(selectedState.value.skill.selectedData()) { "์คํฌ์ด ์ ํ๋์ด์ผ ํฉ๋๋ค." }
+ SelectionData.WithSkill(previousPokemonSelection, skill)
+ } else {
+ SelectionData.WithoutSkill(previousPokemonSelection)
+ }
+
+ override fun navigationToDetail(pokemonId: String) {
+ viewModelScope.launch {
+ _navigationEvent.emit(NavigateToDetail(pokemonId))
+ }
+ }
+}
+
+sealed interface BattleNavigationEvent
+
+data class NavigateToSelection(
+ val selectionMode: SelectionMode,
+ val previousSelectionData: SelectionData,
+) : BattleNavigationEvent
+
+data class NavigateToDetail(val pokemonId: String) : BattleNavigationEvent
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/SelectionType.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/SelectionType.kt
new file mode 100644
index 00000000..0120b617
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/SelectionType.kt
@@ -0,0 +1,6 @@
+package poke.rogue.helper.presentation.battle
+
+enum class SelectionType {
+ MINE,
+ OPPONENT,
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/WeatherSpinnerAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/WeatherSpinnerAdapter.kt
new file mode 100644
index 00000000..c8d06bb9
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/WeatherSpinnerAdapter.kt
@@ -0,0 +1,69 @@
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import androidx.databinding.DataBindingUtil
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ItemSpinnerWeatherBinding
+import poke.rogue.helper.presentation.battle.model.WeatherUiModel
+
+class WeatherSpinnerAdapter(
+ context: Context,
+ private val items: MutableList = mutableListOf(),
+) : ArrayAdapter(context, R.layout.item_spinner_weather, items) {
+ private class WeatherViewHolder(val binding: ItemSpinnerWeatherBinding)
+
+ fun updateWeathers(updated: List) {
+ items.clear()
+ items.addAll(updated)
+ notifyDataSetChanged()
+ }
+
+ override fun getCount(): Int = items.size
+
+ override fun getView(
+ position: Int,
+ convertView: View?,
+ parent: ViewGroup,
+ ): View =
+ bindView(convertView, parent, position).apply {
+ setBackgroundResource(R.drawable.bg_spinner)
+ }
+
+ override fun getDropDownView(
+ position: Int,
+ convertView: View?,
+ parent: ViewGroup,
+ ): View = bindView(convertView, parent, position)
+
+ private fun bindView(
+ convertView: View?,
+ parent: ViewGroup,
+ position: Int,
+ ): View {
+ val viewHolder: WeatherViewHolder
+ val view: View
+
+ if (convertView == null) {
+ val binding: ItemSpinnerWeatherBinding =
+ DataBindingUtil.inflate(
+ LayoutInflater.from(context),
+ R.layout.item_spinner_weather,
+ parent,
+ false,
+ )
+ view = binding.root
+ viewHolder = WeatherViewHolder(binding)
+ view.tag = viewHolder
+ } else {
+ view = convertView
+ viewHolder = view.tag as WeatherViewHolder
+ }
+ val weather = getItem(position)
+ if (weather != null) {
+ viewHolder.binding.weather = weather
+ }
+ return view
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/BattlePredictionUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/BattlePredictionUiModel.kt
new file mode 100644
index 00000000..4fc5bd9c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/BattlePredictionUiModel.kt
@@ -0,0 +1,46 @@
+package poke.rogue.helper.presentation.battle.model
+
+import androidx.annotation.ColorRes
+import poke.rogue.helper.R
+import poke.rogue.helper.data.model.BattlePrediction
+import poke.rogue.helper.presentation.battle.model.BattlePredictionUiModel.Companion.DEFAULT_NUMBER_FORMAT
+import poke.rogue.helper.presentation.battle.model.BattlePredictionUiModel.Companion.NO_EFFECT_VALUE
+
+data class BattlePredictionUiModel(
+ val power: String,
+ val accuracy: String,
+ val multiplier: String,
+ val calculatedResult: String,
+ @ColorRes val colorRes: Int,
+) {
+ companion object {
+ const val NO_EFFECT_VALUE = "-"
+ const val DEFAULT_NUMBER_FORMAT = "%.1f"
+ }
+}
+
+fun BattlePrediction.toUi(format: String = DEFAULT_NUMBER_FORMAT): BattlePredictionUiModel {
+ val formattedPower = if (power < 0) NO_EFFECT_VALUE else power.toString()
+ val formattedAccuracy = if (accuracy < 0) NO_EFFECT_VALUE else String.format(format, accuracy)
+ val formattedMultiplier = if (power < 0) NO_EFFECT_VALUE else String.format(format, multiplier)
+ val formattedResult =
+ if (calculatedResult < 0) NO_EFFECT_VALUE else String.format(format, calculatedResult)
+
+ val color = selectedColorResource(multiplier)
+
+ return BattlePredictionUiModel(
+ power = formattedPower,
+ accuracy = formattedAccuracy,
+ multiplier = formattedMultiplier,
+ calculatedResult = formattedResult,
+ colorRes = color,
+ )
+}
+
+private fun selectedColorResource(value: Double): Int =
+ when {
+ value < 1.0 -> R.color.poke_grey_60
+ value in 1.0..2.9 -> R.color.poke_red_20
+ value >= 3 -> R.color.poke_green_20
+ else -> R.color.poke_white
+ }
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/PokemonSelectionUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/PokemonSelectionUiModel.kt
new file mode 100644
index 00000000..73fca99a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/PokemonSelectionUiModel.kt
@@ -0,0 +1,77 @@
+package poke.rogue.helper.presentation.battle.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.data.model.Pokemon
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.type.model.toUi
+
+@Parcelize
+data class PokemonSelectionUiModel(
+ val id: String,
+ val dexNumber: Long,
+ val name: String,
+ val frontImageUrl: String,
+ val backImageUrl: String,
+ val types: List,
+) : Parcelable {
+ companion object {
+ val DUMMY =
+ listOf(
+ PokemonSelectionUiModel(
+ id = "bulbasaur",
+ dexNumber = 1,
+ name = "์ด์ํด์จ",
+ frontImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png",
+ backImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/1.png",
+ types =
+ listOf(
+ TypeUiModel.GRASS,
+ TypeUiModel.POISON,
+ ),
+ ),
+ PokemonSelectionUiModel(
+ id = "charmander",
+ dexNumber = 4,
+ name = "ํ์ด๋ฆฌ",
+ frontImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png",
+ backImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/4.png",
+ types = listOf(TypeUiModel.FIRE),
+ ),
+ PokemonSelectionUiModel(
+ id = "squirtle",
+ dexNumber = 7,
+ name = "๊ผฌ๋ถ์ด",
+ frontImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/7.png",
+ backImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/7.png",
+ types = listOf(TypeUiModel.WATER),
+ ),
+ PokemonSelectionUiModel(
+ id = "pikachu",
+ dexNumber = 25,
+ name = "ํผ์นด์ธ",
+ frontImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
+ backImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/25.png",
+ types = listOf(TypeUiModel.ELECTRIC),
+ ),
+ PokemonSelectionUiModel(
+ id = "Charizard",
+ dexNumber = 6,
+ name = "๋ฆฌ์๋ชฝ",
+ frontImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png",
+ backImageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/6.png",
+ types = listOf(TypeUiModel.FIRE, TypeUiModel.FLYING),
+ ),
+ )
+ }
+}
+
+fun Pokemon.toSelectionUi(): PokemonSelectionUiModel =
+ PokemonSelectionUiModel(
+ id = id,
+ dexNumber = dexNumber,
+ name = name,
+ frontImageUrl = imageUrl,
+ backImageUrl = backImageUrl,
+ types = types.map { it.toUi() },
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionData.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionData.kt
new file mode 100644
index 00000000..5374bc92
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionData.kt
@@ -0,0 +1,38 @@
+package poke.rogue.helper.presentation.battle.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.presentation.battle.model.SelectionData.WithSkill
+import poke.rogue.helper.presentation.battle.model.SelectionData.WithoutSkill
+
+sealed class SelectionData : Parcelable {
+ @Parcelize
+ data class WithSkill(
+ val selectedPokemon: PokemonSelectionUiModel,
+ val selectedSkill: SkillSelectionUiModel,
+ ) : SelectionData()
+
+ @Parcelize
+ data class WithoutSkill(
+ val selectedPokemon: PokemonSelectionUiModel,
+ ) : SelectionData()
+
+ @Parcelize
+ data object NoSelection : SelectionData()
+}
+
+fun SelectionData.selectedPokemon(): PokemonSelectionUiModel? {
+ return when (this) {
+ is SelectionData.NoSelection -> null
+ is SelectionData.WithSkill -> this.selectedPokemon
+ is SelectionData.WithoutSkill -> this.selectedPokemon
+ }
+}
+
+fun SelectionData.selectedSkill(): SkillSelectionUiModel? {
+ return when (this) {
+ is SelectionData.NoSelection -> null
+ is SelectionData.WithSkill -> this.selectedSkill
+ is SelectionData.WithoutSkill -> null
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionMode.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionMode.kt
new file mode 100644
index 00000000..2d4aa9b3
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionMode.kt
@@ -0,0 +1,9 @@
+package poke.rogue.helper.presentation.battle.model
+
+enum class SelectionMode {
+ POKEMON_ONLY,
+ POKEMON_AND_SKILL,
+ SKILL_FIRST,
+}
+
+fun SelectionMode.isSkillSelectionRequired(): Boolean = this != SelectionMode.POKEMON_ONLY
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SkillSelectionUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SkillSelectionUiModel.kt
new file mode 100644
index 00000000..7eb793a2
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SkillSelectionUiModel.kt
@@ -0,0 +1,24 @@
+package poke.rogue.helper.presentation.battle.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.data.model.BattleSkill
+import poke.rogue.helper.presentation.dex.model.toUi
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.type.model.toUi
+
+@Parcelize
+data class SkillSelectionUiModel(
+ val id: String,
+ val name: String,
+ val typeLogo: TypeUiModel,
+ val categoryLogo: String,
+) : Parcelable
+
+fun BattleSkill.toUi(): SkillSelectionUiModel =
+ SkillSelectionUiModel(
+ id = id,
+ name = name,
+ typeLogo = type.toUi(),
+ categoryLogo = categoryLogo,
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/WeatherUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/WeatherUiModel.kt
new file mode 100644
index 00000000..55cdc586
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/WeatherUiModel.kt
@@ -0,0 +1,56 @@
+package poke.rogue.helper.presentation.battle.model
+
+import android.os.Parcelable
+import androidx.annotation.DrawableRes
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.R
+import poke.rogue.helper.data.model.Weather
+
+@Parcelize
+data class WeatherUiModel(
+ val id: String,
+ val icon: WeatherIcon,
+ val description: String,
+ val effect: String,
+) : Parcelable
+
+enum class WeatherIcon(
+ @DrawableRes val iconResId: Int,
+) {
+ NONE(R.drawable.icon_close),
+ CLEAR(R.drawable.icon_sun),
+ RAIN(R.drawable.icon_rain),
+ SANDSTORM(R.drawable.icon_air),
+ HAIL(R.drawable.icon_hail),
+ SNOW(R.drawable.icon_snow),
+ FOG(R.drawable.icon_foggy),
+ HEAVY_RAIN(R.drawable.icon_rain),
+ STRONG_SUN(R.drawable.icon_sun),
+ TURBULENCE(R.drawable.icon_air),
+}
+
+fun Weather.toUi(): WeatherUiModel {
+ val weatherIcon =
+ when (id) {
+ "sunny" -> WeatherIcon.CLEAR
+ "rain" -> WeatherIcon.RAIN
+ "snow" -> WeatherIcon.SNOW
+ "sandstorm" -> WeatherIcon.SANDSTORM
+ "hail" -> WeatherIcon.HAIL
+ "fog" -> WeatherIcon.FOG
+ "heavy_rain" -> WeatherIcon.HEAVY_RAIN
+ "harsh_sun" -> WeatherIcon.STRONG_SUN
+ "strong_winds" -> WeatherIcon.TURBULENCE
+ else -> WeatherIcon.NONE
+ }
+
+ val effectString = if (id == "none") "" else effects.joinToString("\n")
+ val descriptionString = if (id == "none") "๋ ์จ๊ฐ ์๋ค" else description
+
+ return WeatherUiModel(
+ id = id,
+ icon = weatherIcon,
+ description = descriptionString,
+ effect = effectString,
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivity.kt
new file mode 100644
index 00000000..641782c1
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivity.kt
@@ -0,0 +1,111 @@
+package poke.rogue.helper.presentation.battle.selection
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityBattleSelectionBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleActivity
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.BattleSelectionUiState
+import poke.rogue.helper.presentation.battle.model.SelectionData
+import poke.rogue.helper.presentation.battle.model.SelectionMode
+import poke.rogue.helper.presentation.util.activity.hideKeyboard
+import poke.rogue.helper.presentation.util.parcelable
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.serializable
+import poke.rogue.helper.presentation.util.view.setImage
+
+class BattleSelectionActivity :
+ ErrorHandleActivity(R.layout.activity_battle_selection) {
+ private val viewModel by viewModel {
+ parametersOf(selectionMode, previousSelection)
+ }
+ private val previousSelection by lazy {
+ intent.parcelable(KEY_PREVIOUS_SELECTION) ?: error("์๋ชป๋ ์ ํ ๋ฐ์ดํฐ")
+ }
+ private val selectionMode by lazy {
+ intent.serializable(KEY_SELECTION_MODE) ?: error("์๋ชป๋ ์ ํ ๋ฐ์ดํฐ")
+ }
+ private val selectionPagerAdapter: BattleSelectionPagerAdapter by lazy {
+ BattleSelectionPagerAdapter(this)
+ }
+
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+
+ override val toolbar: Toolbar
+ get() = binding.toolbarBattleSelection
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initViews()
+ initListener()
+ initObserver()
+ }
+
+ private fun initViews() {
+ binding.lifecycleOwner = this
+ binding.vm = viewModel
+
+ with(binding.pagerBattleSelection) {
+ adapter = selectionPagerAdapter
+ isUserInputEnabled = false
+ }
+ }
+
+ private fun initListener() {
+ binding.root.setOnClickListener {
+ hideKeyboard()
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ viewModel.selectedPokemon.collect { selectionState ->
+ if (selectionState is BattleSelectionUiState.Selected) {
+ val selected = selectionState.content
+ binding.ivPokemon.setImage(selected.frontImageUrl)
+ binding.toolbarBattleSelection.title = selected.name
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.currentStep.collect {
+ binding.pagerBattleSelection.currentItem = it.ordinal
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.completeSelection.collect {
+ handleSelectionResult(it)
+ }
+ }
+ }
+
+ private fun handleSelectionResult(result: SelectionData) {
+ val intent = Intent().apply { putExtra(KEY_SELECTION_RESULT, result) }
+ setResult(RESULT_OK, intent)
+ finish()
+ }
+
+ companion object {
+ const val KEY_SELECTION_MODE = "selectionMode"
+ const val KEY_PREVIOUS_SELECTION = "previousSelection"
+ const val KEY_SELECTION_RESULT = "selectionResult"
+
+ fun intent(
+ context: Context,
+ selectionMode: SelectionMode,
+ previousSelection: SelectionData,
+ ): Intent =
+ Intent(context, BattleSelectionActivity::class.java).apply {
+ putExtra(KEY_SELECTION_MODE, selectionMode)
+ putExtra(KEY_PREVIOUS_SELECTION, previousSelection)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionDataUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionDataUiState.kt
new file mode 100644
index 00000000..07a00d20
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionDataUiState.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.battle.selection
+
+sealed interface BattleSelectionDataUiState {
+ data object Loading : BattleSelectionDataUiState
+
+ class Success(val data: T) : BattleSelectionDataUiState
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionPagerAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionPagerAdapter.kt
new file mode 100644
index 00000000..a3ade56d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionPagerAdapter.kt
@@ -0,0 +1,20 @@
+package poke.rogue.helper.presentation.battle.selection
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import poke.rogue.helper.presentation.battle.selection.pokemon.PokemonSelectionFragment
+import poke.rogue.helper.presentation.battle.selection.skill.SkillSelectionFragment
+
+class BattleSelectionPagerAdapter(fragmentActivity: FragmentActivity) :
+ FragmentStateAdapter(fragmentActivity) {
+ private val pages = SelectionStep.entries
+
+ override fun getItemCount(): Int = pages.size
+
+ override fun createFragment(position: Int): Fragment =
+ when (pages[position]) {
+ SelectionStep.POKEMON_SELECTION -> PokemonSelectionFragment()
+ SelectionStep.SKILL_SELECTION -> SkillSelectionFragment()
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionViewModel.kt
new file mode 100644
index 00000000..2847961a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionViewModel.kt
@@ -0,0 +1,150 @@
+package poke.rogue.helper.presentation.battle.selection
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.BattleSelectionUiState
+import poke.rogue.helper.presentation.battle.isSelected
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.SelectionData
+import poke.rogue.helper.presentation.battle.model.SelectionMode
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.isSkillSelectionRequired
+import poke.rogue.helper.presentation.battle.model.selectedPokemon
+import poke.rogue.helper.presentation.battle.model.selectedSkill
+import poke.rogue.helper.presentation.battle.selectedData
+
+class BattleSelectionViewModel(
+ private val selectionMode: SelectionMode,
+ val previousSelection: SelectionData,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), NavigationHandler {
+ private val _selectedPokemon: MutableStateFlow> =
+ MutableStateFlow(initializeSelectedPokemon())
+ val selectedPokemon = _selectedPokemon.asStateFlow()
+
+ private val _pokemonSelectionUpdate = MutableSharedFlow(replay = 1)
+ val pokemonSelectionUpdate = _pokemonSelectionUpdate.asSharedFlow()
+
+ private val _selectedSkill: MutableStateFlow> =
+ MutableStateFlow(initializeSelectedSkill())
+ val selectedSkill = _selectedSkill.asStateFlow()
+
+ private val _currentStep = MutableStateFlow(initialStep(selectionMode))
+ val currentStep: StateFlow = _currentStep.asStateFlow()
+
+ val isLastStep: StateFlow =
+ currentStep.map {
+ it.isLastStep(selectionMode.isSkillSelectionRequired())
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ private val _completeSelection = MutableSharedFlow()
+ val completeSelection = _completeSelection.asSharedFlow()
+
+ val canGoNextStep: StateFlow =
+ combine(
+ currentStep,
+ selectedPokemon,
+ selectedSkill,
+ ) { step, pokemonState, skillState ->
+ when (step) {
+ SelectionStep.POKEMON_SELECTION -> pokemonState.isSelected()
+ SelectionStep.SKILL_SELECTION -> skillState.isSelected()
+ }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ private fun initializeSelectedPokemon(): BattleSelectionUiState {
+ val selectedPokemon = previousSelection.selectedPokemon()
+ return if (selectedPokemon != null) {
+ BattleSelectionUiState.Selected(selectedPokemon)
+ } else {
+ BattleSelectionUiState.Empty
+ }
+ }
+
+ private fun initializeSelectedSkill(): BattleSelectionUiState {
+ val selectedSkill = previousSelection.selectedSkill()
+ return if (selectedSkill != null) {
+ BattleSelectionUiState.Selected(selectedSkill)
+ } else {
+ BattleSelectionUiState.Empty
+ }
+ }
+
+ private fun initialStep(selectionMode: SelectionMode): SelectionStep =
+ when {
+ selectionMode == SelectionMode.SKILL_FIRST && previousSelection != SelectionData.NoSelection -> {
+ val selected =
+ requireNotNull(previousSelection.selectedPokemon()) { "ํฌ์ผ๋ชฌ์ด ์ ํ๋์ง ์์์ต๋๋ค." }
+ updateDexNumberForSkills(selected.dexNumber)
+ SelectionStep.SKILL_SELECTION
+ }
+
+ else -> SelectionStep.POKEMON_SELECTION
+ }
+
+ fun selectPokemon(pokemon: PokemonSelectionUiModel) {
+ _selectedPokemon.value = BattleSelectionUiState.Selected(pokemon)
+ _selectedSkill.value = BattleSelectionUiState.Empty
+ }
+
+ fun selectSkill(skill: SkillSelectionUiModel) {
+ _selectedSkill.value = BattleSelectionUiState.Selected(skill)
+ }
+
+ override fun navigateToNextPage() {
+ if (isLastStep.value) {
+ handleSelectionResult()
+ return
+ }
+ val nextIndex = currentStep.value.ordinal + 1
+ val nextPage = SelectionStep.entries.getOrNull(nextIndex) ?: error("์๋ชป๋ ํ์ด์ง ์ ๊ทผ")
+ if (nextPage == SelectionStep.SKILL_SELECTION) {
+ val selected =
+ requireNotNull(selectedPokemon.value.selectedData()) { "ํฌ์ผ๋ชฌ์ด ์ ํ๋์ด์ผ ํฉ๋๋ค." }
+ updateDexNumberForSkills(selected.dexNumber)
+ }
+ _currentStep.value = nextPage
+ }
+
+ private fun updateDexNumberForSkills(dexNumber: Long) {
+ viewModelScope.launch {
+ _pokemonSelectionUpdate.emit(dexNumber)
+ }
+ }
+
+ private fun handleSelectionResult() {
+ val pokemon = requireNotNull(selectedPokemon.value.selectedData()) { "ํฌ์ผ๋ชฌ์ด ์ ํ๋์ด์ผ ํฉ๋๋ค." }
+ val result =
+ if (selectionMode.isSkillSelectionRequired()) {
+ val skill = requireNotNull(selectedSkill.value.selectedData()) { "์คํฌ์ด ์ ํ๋์ด์ผ ํฉ๋๋ค." }
+ SelectionData.WithSkill(pokemon, skill)
+ } else {
+ SelectionData.WithoutSkill(pokemon)
+ }
+
+ viewModelScope.launch {
+ _completeSelection.emit(result)
+ }
+ }
+
+ override fun navigateToPrevPage() {
+ val pageIndex = currentStep.value.ordinal
+ if (pageIndex == 0) return
+ val prevPage = SelectionStep.entries.getOrNull(pageIndex - 1)
+ if (prevPage != null) {
+ _currentStep.value = prevPage
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/NavigationHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/NavigationHandler.kt
new file mode 100644
index 00000000..94e19706
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/NavigationHandler.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.battle.selection
+
+interface NavigationHandler {
+ fun navigateToNextPage()
+
+ fun navigateToPrevPage()
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/QueryHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/QueryHandler.kt
new file mode 100644
index 00000000..b2dee86a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/QueryHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.battle.selection
+
+interface QueryHandler {
+ fun queryName(name: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/SelectionBindingAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/SelectionBindingAdapter.kt
new file mode 100644
index 00000000..5f6c3013
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/SelectionBindingAdapter.kt
@@ -0,0 +1,50 @@
+package poke.rogue.helper.presentation.battle.selection
+
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import android.widget.EditText
+import androidx.databinding.BindingAdapter
+import poke.rogue.helper.R
+
+@BindingAdapter("invisible")
+fun View.setInvisible(invisible: Boolean) {
+ visibility = if (invisible) View.INVISIBLE else View.VISIBLE
+}
+
+@BindingAdapter("selectedBackground")
+fun View.setBackground(isSelected: Boolean) {
+ if (isSelected) {
+ setBackgroundResource(R.drawable.bg_battle_selected_border)
+ } else {
+ setBackgroundResource(R.drawable.bg_battle_default)
+ }
+}
+
+@BindingAdapter("onTextChanged")
+fun setOnTextChangedListener(
+ editText: EditText,
+ handler: QueryHandler,
+) {
+ editText.addTextChangedListener(
+ object : TextWatcher {
+ override fun onTextChanged(
+ s: CharSequence?,
+ start: Int,
+ before: Int,
+ count: Int,
+ ) {
+ handler.queryName(s.toString())
+ }
+
+ override fun beforeTextChanged(
+ s: CharSequence?,
+ start: Int,
+ count: Int,
+ after: Int,
+ ) {}
+
+ override fun afterTextChanged(s: Editable?) {}
+ },
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/SelectionStep.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/SelectionStep.kt
new file mode 100644
index 00000000..72745598
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/SelectionStep.kt
@@ -0,0 +1,17 @@
+package poke.rogue.helper.presentation.battle.selection
+
+enum class SelectionStep {
+ POKEMON_SELECTION,
+ SKILL_SELECTION,
+ ;
+
+ fun isFirstStep(): Boolean = this.ordinal > 0
+
+ fun isLastStep(hasSkillSelection: Boolean): Boolean {
+ return if (hasSkillSelection) {
+ this == SKILL_SELECTION
+ } else {
+ this == POKEMON_SELECTION
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/BattlePokemonTypesAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/BattlePokemonTypesAdapter.kt
new file mode 100644
index 00000000..3e1242f6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/BattlePokemonTypesAdapter.kt
@@ -0,0 +1,27 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import android.widget.ImageView
+import com.google.android.flexbox.FlexboxLayout
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.util.view.dp
+
+fun FlexboxLayout.addPokemonTypes(
+ types: List,
+ spacingBetweenTypes: Int = 0.dp,
+ iconSize: Int = 18.dp,
+) {
+ removeAllViews()
+
+ types.forEach { type ->
+ val imageView =
+ ImageView(this.context).apply {
+ setImageResource(type.typeIconResId)
+
+ layoutParams =
+ FlexboxLayout.LayoutParams(iconSize, iconSize).apply {
+ setMargins(spacingBetweenTypes, 0, 0, 0)
+ }
+ }
+ addView(imageView)
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionAdapter.kt
new file mode 100644
index 00000000..22a762fd
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionAdapter.kt
@@ -0,0 +1,42 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBattlePokemonSelectionBinding
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+import poke.rogue.helper.presentation.dex.filter.SelectableUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class PokemonSelectionAdapter(
+ private val selectionHandler: PokemonSelectionHandler,
+) : ListAdapter, PokemonSelectionViewHolder>(pokemonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): PokemonSelectionViewHolder =
+ PokemonSelectionViewHolder(
+ ItemBattlePokemonSelectionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ selectionHandler,
+ )
+
+ override fun onBindViewHolder(
+ viewHolder: PokemonSelectionViewHolder,
+ position: Int,
+ ) {
+ val pokemon = getItem(position)
+ viewHolder.bind(pokemon.data, pokemon.isSelected)
+ }
+
+ companion object {
+ private val pokemonComparator =
+ ItemDiffCallback>(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionFragment.kt
new file mode 100644
index 00000000..100a2953
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionFragment.kt
@@ -0,0 +1,88 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentPokemonSelectionBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.model.selectedPokemon
+import poke.rogue.helper.presentation.battle.selection.BattleSelectionViewModel
+import poke.rogue.helper.presentation.util.fragment.hideKeyboard
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.presentation.util.view.setOnSearchAction
+
+class PokemonSelectionFragment :
+ ErrorHandleFragment(R.layout.fragment_pokemon_selection) {
+ private val sharedViewModel: BattleSelectionViewModel by activityViewModel()
+ private val viewModel: PokemonSelectionViewModel by viewModel {
+ parametersOf(sharedViewModel.previousSelection.selectedPokemon())
+ }
+ private val pokemonAdapter: PokemonSelectionAdapter by lazy {
+ PokemonSelectionAdapter(viewModel)
+ }
+
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar?
+ get() = null
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initViews()
+ initListener()
+ initObserver()
+ }
+
+ private fun initViews() {
+ binding.handler = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+
+ with(binding.rvPokemons) {
+ adapter = pokemonAdapter
+ addItemDecoration(
+ LinearSpacingItemDecoration(spacing = 4.dp, false),
+ )
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private fun initListener() {
+ binding.rvPokemons.setOnTouchListener { _, _ ->
+ hideKeyboard()
+ false
+ }
+
+ binding.etPokemonSelectionSearch.setOnSearchAction { hideKeyboard() }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ viewModel.filteredPokemon.collect {
+ pokemonAdapter.submitList(it) {
+ if (sharedViewModel.previousSelection.selectedPokemon() != null) {
+ val position = it.indexOfFirst { it.isSelected }
+ binding.rvPokemons.scrollToPosition(position)
+ }
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.pokemonSelectedEvent.collect {
+ hideKeyboard()
+ sharedViewModel.selectPokemon(it)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionHandler.kt
new file mode 100644
index 00000000..f1814091
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionHandler.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+
+interface PokemonSelectionHandler {
+ fun selectPokemon(selected: PokemonSelectionUiModel)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewHolder.kt
new file mode 100644
index 00000000..8646bd15
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewHolder.kt
@@ -0,0 +1,26 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBattlePokemonSelectionBinding
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonSelectionViewHolder(
+ private val binding: ItemBattlePokemonSelectionBinding,
+ private val selectionHandler: PokemonSelectionHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(
+ pokemonSelectionUiModel: PokemonSelectionUiModel,
+ isSelected: Boolean,
+ ) {
+ binding.pokemon = pokemonSelectionUiModel
+ binding.isSelected = isSelected
+ binding.selectionHandler = selectionHandler
+
+ binding.flexboxTypes.addPokemonTypes(
+ types = pokemonSelectionUiModel.types,
+ spacingBetweenTypes = 8.dp,
+ iconSize = 18.dp,
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewModel.kt
new file mode 100644
index 00000000..cb52417c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewModel.kt
@@ -0,0 +1,83 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.DexRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.model.PokemonSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.toSelectionUi
+import poke.rogue.helper.presentation.battle.selection.QueryHandler
+import poke.rogue.helper.presentation.dex.filter.SelectableUiModel
+import poke.rogue.helper.presentation.dex.filter.toSelectableModelsBy
+import poke.rogue.helper.stringmatcher.has
+
+class PokemonSelectionViewModel(
+ private val dexRepository: DexRepository,
+ previousSelection: PokemonSelectionUiModel?,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), PokemonSelectionHandler, QueryHandler {
+ private val _pokemonSelectedEvent = MutableSharedFlow()
+ val pokemonSelectedEvent = _pokemonSelectedEvent.asSharedFlow()
+
+ private val _pokemons =
+ MutableStateFlow>>(emptyList())
+ val pokemons = _pokemons.asStateFlow()
+
+ private val searchQuery = MutableStateFlow("")
+
+ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+ val filteredPokemon: StateFlow>> =
+ searchQuery
+ .debounce(300L)
+ .flatMapLatest { query ->
+ pokemons.map { pokemonsList ->
+ if (query.isBlank()) {
+ pokemonsList
+ } else {
+ pokemonsList.filter { it.data.name.has(query) }
+ }
+ }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), pokemons.value)
+
+ init {
+ viewModelScope.launch(errorHandler) {
+ val pokemonList =
+ dexRepository.pokemons()
+ .map { it.toSelectionUi() }
+ .toSelectableModelsBy { previousSelection?.id == it.id }
+ _pokemons.value = pokemonList
+ }
+ }
+
+ override fun selectPokemon(selected: PokemonSelectionUiModel) {
+ _pokemons.value =
+ pokemons.value.map {
+ val isSelected = it.data.id == selected.id
+ it.copy(isSelected = isSelected)
+ }
+ viewModelScope.launch {
+ _pokemonSelectedEvent.emit(selected)
+ }
+ }
+
+ override fun queryName(name: String) {
+ viewModelScope.launch {
+ searchQuery.emit(name)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionAdapter.kt
new file mode 100644
index 00000000..9c502116
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionAdapter.kt
@@ -0,0 +1,41 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBattleSkillSelectionBinding
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+import poke.rogue.helper.presentation.dex.filter.SelectableUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class SkillSelectionAdapter(private val selectionHandler: SkillSelectionHandler) :
+ ListAdapter, SkillSelectionViewHolder>(skillComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): SkillSelectionViewHolder =
+ SkillSelectionViewHolder(
+ ItemBattleSkillSelectionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ selectionHandler,
+ )
+
+ override fun onBindViewHolder(
+ viewHolder: SkillSelectionViewHolder,
+ position: Int,
+ ) {
+ val skill = getItem(position)
+ viewHolder.bind(skill.data, skill.isSelected)
+ }
+
+ companion object {
+ private val skillComparator =
+ ItemDiffCallback>(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionFragment.kt
new file mode 100644
index 00000000..b45b2856
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionFragment.kt
@@ -0,0 +1,96 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentSkillSelectionBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.model.SelectionData
+import poke.rogue.helper.presentation.battle.selection.BattleSelectionViewModel
+import poke.rogue.helper.presentation.util.fragment.hideKeyboard
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.presentation.util.view.setOnSearchAction
+
+class SkillSelectionFragment :
+ ErrorHandleFragment(R.layout.fragment_skill_selection) {
+ private val sharedViewModel: BattleSelectionViewModel by activityViewModel()
+ private val viewModel: SkillSelectionViewModel by viewModel {
+ parametersOf(sharedViewModel.previousSelection as? SelectionData.WithSkill)
+ }
+ private val skillAdapter: SkillSelectionAdapter by lazy {
+ SkillSelectionAdapter(viewModel)
+ }
+
+ override val errorViewModel: ErrorHandleViewModel
+ get() = sharedViewModel
+ override val toolbar: Toolbar?
+ get() = null
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initViews()
+ initListener()
+ initObserver()
+ }
+
+ private fun initViews() {
+ with(binding.rvSkills) {
+ adapter = skillAdapter
+ addItemDecoration(
+ LinearSpacingItemDecoration(spacing = 4.dp, false),
+ )
+ }
+ binding.handler = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private fun initListener() {
+ binding.rvSkills.setOnTouchListener { _, _ ->
+ hideKeyboard()
+ false
+ }
+ binding.etSkillSelectionSearch.setOnSearchAction { hideKeyboard() }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ sharedViewModel.pokemonSelectionUpdate.collect { newDexNumber ->
+ if (newDexNumber != viewModel.previousPokemonDexNumber) {
+ viewModel.updateSkills(newDexNumber)
+ } else {
+ viewModel.updatePreviousSkills(newDexNumber)
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.filteredSkills.collect {
+ skillAdapter.submitList(it) {
+ if (viewModel.previousSkillId != null) {
+ val position = it.indexOfFirst { it.isSelected }
+ binding.rvSkills.scrollToPosition(position)
+ }
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.skillSelectedEvent.collect {
+ hideKeyboard()
+ sharedViewModel.selectSkill(it)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionHandler.kt
new file mode 100644
index 00000000..612d913a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionHandler.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+
+interface SkillSelectionHandler {
+ fun selectSkill(selected: SkillSelectionUiModel)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewHolder.kt
new file mode 100644
index 00000000..695491be
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewHolder.kt
@@ -0,0 +1,19 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBattleSkillSelectionBinding
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+
+class SkillSelectionViewHolder(
+ private val binding: ItemBattleSkillSelectionBinding,
+ private val selectionHandler: SkillSelectionHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(
+ skillSelectionUiModel: SkillSelectionUiModel,
+ isSelected: Boolean,
+ ) {
+ binding.skill = skillSelectionUiModel
+ binding.isSelected = isSelected
+ binding.selectionHandler = selectionHandler
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewModel.kt
new file mode 100644
index 00000000..f1679890
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewModel.kt
@@ -0,0 +1,101 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.BattleRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.model.SelectionData
+import poke.rogue.helper.presentation.battle.model.SkillSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.toUi
+import poke.rogue.helper.presentation.battle.selection.QueryHandler
+import poke.rogue.helper.presentation.dex.filter.SelectableUiModel
+import poke.rogue.helper.presentation.dex.filter.initialized
+import poke.rogue.helper.presentation.dex.filter.toSelectableModelsBy
+import poke.rogue.helper.stringmatcher.has
+
+class SkillSelectionViewModel(
+ private val battleRepository: BattleRepository,
+ previousSelection: SelectionData.WithSkill?,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), SkillSelectionHandler, QueryHandler {
+ var previousPokemonDexNumber: Long? = previousSelection?.selectedPokemon?.dexNumber
+ private set
+
+ var previousSkillId: String? = previousSelection?.selectedSkill?.id
+ private set
+
+ private val _skillSelectedEvent = MutableSharedFlow()
+ val skillSelectedEvent = _skillSelectedEvent.asSharedFlow()
+
+ private val _skills = MutableStateFlow(listOf>())
+ val skills = _skills.asStateFlow()
+
+ private val searchQuery = MutableStateFlow("")
+
+ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+ val filteredSkills: StateFlow>> =
+ searchQuery
+ .debounce(300L)
+ .flatMapLatest { query ->
+ skills.mapLatest { skillsList ->
+ if (query.isBlank()) {
+ skillsList
+ } else {
+ skillsList.filter { it.data.name.has(query) }
+ }
+ }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), skills.value)
+
+ override fun selectSkill(selected: SkillSelectionUiModel) {
+ _skills.value =
+ skills.value.map {
+ val isSelected = it.data.id == selected.id
+ it.copy(isSelected = isSelected)
+ }
+ viewModelScope.launch {
+ _skillSelectedEvent.emit(selected)
+ }
+ previousSkillId = selected.id
+ }
+
+ fun updateSkills(pokemonDexNumber: Long) {
+ previousPokemonDexNumber = pokemonDexNumber
+ viewModelScope.launch(errorHandler) {
+ _skills.value = emptyList()
+ val availableSkills =
+ battleRepository.availableSkills(pokemonDexNumber).map { it.toUi() }
+ _skills.value = availableSkills.initialized()
+ }
+ }
+
+ fun updatePreviousSkills(pokemonDexNumber: Long) {
+ if (skills.value.isEmpty()) {
+ viewModelScope.launch(errorHandler) {
+ val availableSkills =
+ battleRepository.availableSkills(pokemonDexNumber).map { it.toUi() }
+ _skills.value = availableSkills.toSelectableModelsBy { it.id == previousSkillId }
+ }
+ }
+ }
+
+ override fun queryName(name: String) {
+ viewModelScope.launch {
+ searchQuery.emit(name)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/battle/view/WeatherItemSelectedListener.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/view/WeatherItemSelectedListener.kt
new file mode 100644
index 00000000..84aad3d6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/view/WeatherItemSelectedListener.kt
@@ -0,0 +1,27 @@
+package poke.rogue.helper.presentation.battle.view
+
+import android.view.View
+import android.widget.AdapterView
+
+inline fun itemSelectListener(crossinline onSelected: (T) -> Unit): AdapterView.OnItemSelectedListener {
+ var isSpinnerInitialized = false
+ return object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(
+ parent: AdapterView<*>?,
+ view: View?,
+ position: Int,
+ id: Long,
+ ) {
+ if (isSpinnerInitialized) {
+ val selectedData = parent?.getItemAtPosition(position)
+ val castedData =
+ requireNotNull(selectedData as? T) { "Selected data is not a ${T::class.simpleName}" }
+ onSelected(castedData)
+ } else {
+ isSpinnerInitialized = true
+ }
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {}
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeActivity.kt
new file mode 100644
index 00000000..a2291088
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeActivity.kt
@@ -0,0 +1,108 @@
+package poke.rogue.helper.presentation.biome
+
+import android.content.res.Configuration
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.GridLayoutManager
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityBiomeBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleActivity
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailActivity
+import poke.rogue.helper.presentation.biome.guide.BiomeGuideActivity
+import poke.rogue.helper.presentation.biome.model.toUi
+import poke.rogue.helper.presentation.util.activity.hideKeyboard
+import poke.rogue.helper.presentation.util.context.startActivity
+import poke.rogue.helper.presentation.util.logClickEvent
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeActivity : ErrorHandleActivity(R.layout.activity_biome) {
+ private val viewModel by viewModel()
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ private val biomeAdapter: BiomeAdapter by lazy { BiomeAdapter(viewModel) }
+ override val toolbar: Toolbar
+ get() = binding.toolbarBiome
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initListener()
+ initView()
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initView() {
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ }
+
+ private fun initListener() {
+ binding.rvBiomeList.setOnTouchListener { _, _ ->
+ hideKeyboard()
+ false
+ }
+ }
+
+ private fun initAdapter() {
+ binding.rvBiomeList.apply {
+ val spanCount =
+ if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) 3 else 2
+ adapter = biomeAdapter
+ layoutManager = GridLayoutManager(context, spanCount)
+ addItemDecoration(
+ GridSpacingItemDecoration(
+ spanCount,
+ 9.dp,
+ false,
+ ),
+ )
+ }
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.navigationToDetailEvent.collect { biomeId ->
+ startActivity {
+ putExtras(BiomeDetailActivity.intent(this@BiomeActivity, biomeId))
+ logger.logClickEvent(NAVIGATE_TO_BIOME_DETAIL)
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.navigateToGuideEvent.collect {
+ startActivity {
+ logger.logClickEvent(NAVIGATE_TO_BIOME_GUIDE)
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.biomes.collect { biome ->
+ when (biome) {
+ is BiomeUiState.Loading -> {
+ binding.biomeLoading.isVisible = true
+ }
+
+ is BiomeUiState.Success -> {
+ biomeAdapter.submitList(biome.data.toUi()) {
+ binding.rvBiomeList.scrollToPosition(0)
+ }
+ binding.biomeLoading.isVisible = false
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val NAVIGATE_TO_BIOME_DETAIL = "Nav_Biome_Detail"
+ private const val NAVIGATE_TO_BIOME_GUIDE = "Nav_Biome_Guide"
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeAdapter.kt
new file mode 100644
index 00000000..882bab41
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeAdapter.kt
@@ -0,0 +1,40 @@
+package poke.rogue.helper.presentation.biome
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBiomeBinding
+import poke.rogue.helper.presentation.biome.model.BiomeUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class BiomeAdapter(private val onClickBiomeItem: BiomeUiEventHandler) :
+ ListAdapter(biomeComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): BiomeViewHolder {
+ return BiomeViewHolder(
+ ItemBiomeBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickBiomeItem,
+ )
+ }
+
+ override fun onBindViewHolder(
+ holder: BiomeViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val biomeComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeQueryHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeQueryHandler.kt
new file mode 100644
index 00000000..ce236938
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeQueryHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.biome
+
+interface BiomeQueryHandler {
+ fun queryName(name: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeSearchViewBindingAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeSearchViewBindingAdapter.kt
new file mode 100644
index 00000000..71e2eaac
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeSearchViewBindingAdapter.kt
@@ -0,0 +1,22 @@
+package poke.rogue.helper.presentation.ability
+
+import androidx.appcompat.widget.SearchView
+import androidx.databinding.BindingAdapter
+import poke.rogue.helper.presentation.biome.BiomeQueryHandler
+
+@BindingAdapter("onQueryTextChange")
+fun setOnQueryTextListener(
+ searchView: SearchView,
+ onQueryTextChangeListener: BiomeQueryHandler,
+) {
+ searchView.setOnQueryTextListener(
+ object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ onQueryTextChangeListener.queryName(newText.toString())
+ return true
+ }
+ },
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeTypesAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeTypesAdapter.kt
new file mode 100644
index 00000000..60540f0b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeTypesAdapter.kt
@@ -0,0 +1,39 @@
+package poke.rogue.helper.presentation.biome
+
+import android.content.Context
+import android.widget.ImageView
+import com.google.android.flexbox.FlexboxLayout
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeTypesAdapter(private val context: Context, private val viewGroup: FlexboxLayout) {
+ fun addTypes(
+ types: List,
+ spacingBetweenTypes: Int = 0.dp,
+ iconSize: Int = 18.dp,
+ ) {
+ viewGroup.removeAllViews()
+
+ types.forEach { type ->
+ val imageView =
+ ImageView(context).apply {
+ setImageResource(type.typeIconResId)
+
+ layoutParams =
+ FlexboxLayout.LayoutParams(
+ iconSize,
+ iconSize,
+ ).apply {
+ setMargins(
+ spacingBetweenTypes,
+ 0,
+ 0,
+ 0,
+ )
+ }
+ }
+
+ viewGroup.addView(imageView)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiEventHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiEventHandler.kt
new file mode 100644
index 00000000..9114b796
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiEventHandler.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.biome
+
+interface BiomeUiEventHandler {
+ fun navigateToDetail(biomeId: String)
+
+ fun navigateToGuide()
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiState.kt
new file mode 100644
index 00000000..7acebd5d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiState.kt
@@ -0,0 +1,7 @@
+package poke.rogue.helper.presentation.biome
+
+interface BiomeUiState {
+ data object Loading : BiomeUiState
+
+ data class Success(val data: T) : BiomeUiState
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewHolder.kt
new file mode 100644
index 00000000..8ea1bc58
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewHolder.kt
@@ -0,0 +1,35 @@
+package poke.rogue.helper.presentation.biome
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBiomeBinding
+import poke.rogue.helper.presentation.biome.model.BiomeUiModel
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeViewHolder(
+ private val binding: ItemBiomeBinding,
+ private val onClickBiomeItem: BiomeUiEventHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(biomeUiModel: BiomeUiModel) {
+ binding.apply {
+ biome = biomeUiModel
+ uiEventHandler = onClickBiomeItem
+ }
+
+ val typesLayout = binding.flBiomeTypeIcons
+ val biomeTypesAdapter =
+ BiomeTypesAdapter(
+ context = binding.root.context,
+ viewGroup = typesLayout,
+ )
+ biomeTypesAdapter.addTypes(
+ types = biomeUiModel.types,
+ spacingBetweenTypes = TYPES_SPACING,
+ iconSize = TYPE_ICON_SIZE,
+ )
+ }
+
+ companion object {
+ private val TYPES_SPACING = 5.dp
+ private val TYPE_ICON_SIZE = 18.dp
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewModel.kt
new file mode 100644
index 00000000..d2703d0c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewModel.kt
@@ -0,0 +1,69 @@
+package poke.rogue.helper.presentation.biome
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.model.Biome
+import poke.rogue.helper.data.repository.BiomeRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+
+class BiomeViewModel(
+ private val biomeRepository: BiomeRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) :
+ ErrorHandleViewModel(logger),
+ BiomeUiEventHandler,
+ BiomeQueryHandler {
+ private val _navigationToDetailEvent = MutableSharedFlow()
+ val navigationToDetailEvent: SharedFlow = _navigationToDetailEvent.asSharedFlow()
+
+ private val _navigateToGuideEvent = MutableSharedFlow()
+ val navigateToGuideEvent: SharedFlow = _navigateToGuideEvent.asSharedFlow()
+
+ val searchQuery = MutableStateFlow("")
+
+ @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
+ val biomes: StateFlow>> =
+ searchQuery
+ .debounce(300L)
+ .mapLatest { query ->
+ val biomes = biomeRepository.biomes(query)
+ BiomeUiState.Success(biomes)
+ }
+ .stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000L),
+ BiomeUiState.Loading,
+ )
+
+ override fun queryName(name: String) {
+ viewModelScope.launch {
+ searchQuery.emit(name)
+ }
+ }
+
+ override fun navigateToDetail(biomeId: String) {
+ viewModelScope.launch {
+ _navigationToDetailEvent.emit(biomeId)
+ }
+ }
+
+ override fun navigateToGuide() {
+ viewModelScope.launch {
+ _navigateToGuideEvent.emit(Unit)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomPokemonAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomPokemonAdapter.kt
new file mode 100644
index 00000000..60e66e43
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomPokemonAdapter.kt
@@ -0,0 +1,40 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemPokemonListPokemonBinding
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class BiomPokemonAdapter(private val onClickPokemon: PokemonListNavigateHandler) :
+ ListAdapter(poketmonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): BiomePokemonViewHolder =
+ BiomePokemonViewHolder(
+ ItemPokemonListPokemonBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickPokemon,
+ )
+
+ override fun onBindViewHolder(
+ holder: BiomePokemonViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val poketmonComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.hashId == newItem.hashId },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivity.kt
new file mode 100644
index 00000000..0df55a24
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivity.kt
@@ -0,0 +1,137 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import com.google.android.material.tabs.TabLayoutMediator
+import com.skydoves.balloon.ArrowPositionRules
+import com.skydoves.balloon.BalloonAnimation
+import com.skydoves.balloon.BalloonSizeSpec
+import com.skydoves.balloon.createBalloon
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityBiomeDetailBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleActivity
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.battle.BattleActivity
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailActivity
+import poke.rogue.helper.presentation.util.context.startActivity
+import poke.rogue.helper.presentation.util.context.stringOf
+import poke.rogue.helper.presentation.util.logClickEvent
+import poke.rogue.helper.presentation.util.repeatOnStarted
+
+class BiomeDetailActivity :
+ ErrorHandleActivity(R.layout.activity_biome_detail) {
+ private lateinit var pagerAdapter: BiomeDetailPagerAdapter
+ private val viewModel by viewModel()
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar
+ get() = binding.toolbarBiomeDetail
+ private val tooltip by lazy {
+ createBalloon(this) {
+ setWidth(BalloonSizeSpec.WRAP)
+ setHeight(BalloonSizeSpec.WRAP)
+ setText(stringOf(R.string.biome_navigation_mode_info))
+ setTextColorResource(R.color.poke_white)
+ setTextSize(11f)
+ setArrowPositionRules(ArrowPositionRules.ALIGN_ANCHOR)
+ setArrowSize(10)
+ setArrowPosition(0.0f)
+ setPadding(12)
+ setCornerRadius(8f)
+ setBackgroundColorResource(R.color.poke_red_20)
+ setBalloonAnimation(BalloonAnimation.ELASTIC)
+ build()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (savedInstanceState == null) {
+ val biomeId = intent.getStringExtra(BIOME_ID).orEmpty()
+ viewModel.init(biomeId)
+ }
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ initAdapter()
+ initObservers()
+ initTooltip()
+ }
+
+ private fun initAdapter() {
+ pagerAdapter = BiomeDetailPagerAdapter(this)
+ binding.vpBiome.apply {
+ adapter = pagerAdapter
+ }
+
+ val tabTitles = resources.getStringArray(R.array.biome_tab_titles)
+ TabLayoutMediator(binding.tablayoutBiomeDetail, binding.vpBiome) { tab, position ->
+ tab.text = tabTitles[position]
+ }.attach()
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiEvent.collect { event ->
+ when (event) {
+ is BiomeDetailUiEvent.NavigateToNextBiomeDetail -> {
+ val biomeId = event.biomeId
+ startActivity {
+ putExtras(intent(this@BiomeDetailActivity, biomeId))
+ logger.logClickEvent(NAVIGATE_TO_NEXT_BIOME_DETAIL)
+ }
+ }
+
+ is BiomeDetailUiEvent.NavigateToPokemonDetail -> {
+ val pokemonId = event.pokemonId
+ startActivity {
+ putExtras(
+ PokemonDetailActivity.intent(
+ this@BiomeDetailActivity,
+ pokemonId,
+ ),
+ )
+ logger.logClickEvent(NAVIGATE_TO_POKEMON_DETAIL)
+ }
+ }
+
+ is BiomeDetailUiEvent.NavigateToBattle -> {
+ val pokemonId = event.pokemonId
+ startActivity {
+ putExtras(
+ BattleActivity.intent(
+ this@BiomeDetailActivity,
+ pokemonId,
+ isMine = false,
+ ),
+ )
+ logger.logClickEvent(NAVIGATE_TO_BATTLE)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun initTooltip() {
+ binding.tvNavigationMode.setOnClickListener {
+ tooltip.showAlignTop(it)
+ }
+ }
+
+ companion object {
+ private const val BIOME_ID = "biomeId"
+ private const val NAVIGATE_TO_NEXT_BIOME_DETAIL = "Nav_Next_Biome_Detail"
+ private const val NAVIGATE_TO_POKEMON_DETAIL = "Nav_Pokemon_Detail"
+ private const val NAVIGATE_TO_BATTLE = "Nav_Battle"
+
+ fun intent(
+ context: Context,
+ biomeId: String,
+ ): Intent =
+ Intent(context, BiomeDetailActivity::class.java)
+ .putExtra(BIOME_ID, biomeId)
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailPagerAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailPagerAdapter.kt
new file mode 100644
index 00000000..2d2b1b67
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailPagerAdapter.kt
@@ -0,0 +1,29 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import poke.rogue.helper.presentation.biome.detail.boss.BiomeBossFragment
+import poke.rogue.helper.presentation.biome.detail.gym.BiomeGymFragment
+import poke.rogue.helper.presentation.biome.detail.nextbiomes.BiomeNextBiomesFragment
+import poke.rogue.helper.presentation.biome.detail.wild.BiomeWildPokemonFragment
+
+class BiomeDetailPagerAdapter(fm: FragmentActivity) : FragmentStateAdapter(fm) {
+ private val fragment =
+ listOf(
+ BiomeGymFragment(),
+ BiomeBossFragment(),
+ BiomeWildPokemonFragment(),
+ BiomeNextBiomesFragment(),
+ )
+
+ override fun getItemCount(): Int = 4
+
+ override fun createFragment(position: Int) =
+ when (position) {
+ 0 -> BiomeGymFragment()
+ 1 -> BiomeBossFragment()
+ 2 -> BiomeWildPokemonFragment()
+ 3 -> BiomeNextBiomesFragment()
+ else -> error("๊ทธ๋ฐ๊ฑด ์๋จ๋ค - position : $position ์๋ ํด๋นํ๋ Fragment๊ฐ ์์ต๋๋ค.")
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailUiState.kt
new file mode 100644
index 00000000..adf914cf
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailUiState.kt
@@ -0,0 +1,204 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import poke.rogue.helper.data.model.BiomeDetail
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.biome.model.BiomeUiModel
+import poke.rogue.helper.presentation.biome.model.NextBiomeUiModel
+import poke.rogue.helper.presentation.biome.model.toUi
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+
+class BiomeDetailUiState(
+ val id: String,
+ val name: String,
+ val imageUrl: String,
+ val wildPokemons: List,
+ val bossPokemons: List,
+ val gymPokemons: List,
+ val nextBiomes: List,
+) {
+ companion object {
+ private const val DUMMY_IMAGE_URL =
+ "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/"
+
+ fun dummyUrl(id: Long) = "$DUMMY_IMAGE_URL$id.png"
+
+ val Default: BiomeDetailUiState =
+ BiomeDetailUiState(
+ id = "NO_ID",
+ name = "ํ์ฒ",
+ imageUrl = "",
+ wildPokemons = emptyList(),
+ bossPokemons = emptyList(),
+ gymPokemons = emptyList(),
+ nextBiomes = emptyList(),
+ )
+ val DUMMY: BiomeDetailUiState =
+ BiomeDetailUiState(
+ id = "1",
+ name = "ํ์ฒ",
+ imageUrl = "https://wiki.pokerogue.net/_media/ko:biomes:ko_grassy_fields_bg.png?w=200&tok=745c5b",
+ wildPokemons =
+ listOf(
+ BiomePokemonUiModel(
+ grade = "์ผ๋ฐ",
+ type = null,
+ gymLeaderUrl = null,
+ pokemons =
+ (1..9).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "์ผ๋ฐ $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.GRASS),
+ )
+ },
+ ),
+ BiomePokemonUiModel(
+ grade = "ํฌ๊ท",
+ type = null,
+ gymLeaderUrl = null,
+ pokemons =
+ (10..21).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "ํฌ๊ท $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.POISON),
+ )
+ },
+ ),
+ BiomePokemonUiModel(
+ grade = "์ ์ค",
+ type = null,
+ gymLeaderUrl = null,
+ pokemons =
+ (22..24).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "์ ์ค $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.GRASS, TypeUiModel.POISON),
+ )
+ },
+ ),
+ ),
+ bossPokemons =
+ listOf(
+ BiomePokemonUiModel(
+ grade = "์ผ๋ฐ",
+ type = null,
+ gymLeaderUrl = null,
+ pokemons =
+ (990..1005).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "์ผ๋ฐ ๋ณด์ค $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.GRASS, TypeUiModel.POISON),
+ )
+ },
+ ),
+ BiomePokemonUiModel(
+ grade = "ํฌ๊ท",
+ type = null,
+ gymLeaderUrl = null,
+ pokemons =
+ (1006..1011).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "ํฌ๊ท ๋ณด์ค $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.GRASS, TypeUiModel.POISON),
+ )
+ },
+ ),
+ BiomePokemonUiModel(
+ grade = "์ ์ค",
+ gymLeaderUrl = null,
+ type = null,
+ pokemons =
+ (1012..1015).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "์ ์ค ๋ณด์ค $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.GRASS, TypeUiModel.POISON),
+ )
+ },
+ ),
+ ),
+ gymPokemons =
+ listOf(
+ BiomePokemonUiModel(
+ grade = "์ฌ์ง๋ฐ์ฌ",
+ gymLeaderUrl = "https://wiki.pokerogue.net/_media/trainers:opal.png",
+ type = TypeUiModel.FAIRY,
+ pokemons =
+ (871..874).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "์ฌ์ง๋ชฌ $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.STEEL, TypeUiModel.FAIRY),
+ )
+ },
+ ),
+ BiomePokemonUiModel(
+ grade = "๊ผฌ์์กฐ๊ต",
+ gymLeaderUrl = "https://wiki.pokerogue.net/_media/trainers:bede.png",
+ type = TypeUiModel.FAIRY,
+ pokemons =
+ (901..905).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "๊ผฌ์๋ชฌ $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.DRAGON, TypeUiModel.FAIRY),
+ )
+ },
+ ),
+ BiomePokemonUiModel(
+ grade = "๋นํ ํ์",
+ gymLeaderUrl = "https://wiki.pokerogue.net/_media/trainers:valerie.png",
+ type = TypeUiModel.FAIRY,
+ pokemons =
+ (100..105).map {
+ PokemonUiModel(
+ dexNumber = it.toLong(),
+ name = "๋นํ ๋ชฌ $it",
+ imageUrl = dummyUrl(it.toLong()),
+ types = listOf(TypeUiModel.ICE, TypeUiModel.POISON),
+ )
+ },
+ ),
+ ),
+ nextBiomes =
+ listOf(
+ NextBiomeUiModel(
+ biome = BiomeUiModel.DUMMYS[1],
+ probability = 33.3,
+ ),
+ NextBiomeUiModel(
+ biome = BiomeUiModel.DUMMYS[2],
+ probability = 33.3,
+ ),
+ NextBiomeUiModel(
+ biome = BiomeUiModel.DUMMYS[3],
+ probability = 33.3,
+ ),
+ ),
+ )
+ }
+}
+
+fun BiomeDetail.toUiState(): BiomeDetailUiState =
+ BiomeDetailUiState(
+ id = id,
+ name = name,
+ imageUrl = image,
+ wildPokemons = wildPokemons.map { it.toUi() },
+ bossPokemons = bossPokemons.map { it.toUi() },
+ gymPokemons = gymPokemons.map { it.toUi() },
+ nextBiomes = nextBiomes.map { it.toUi() },
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailViewModel.kt
new file mode 100644
index 00000000..6cf574c6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailViewModel.kt
@@ -0,0 +1,121 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.data.repository.BiomeRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.event.MutableEventFlow
+import poke.rogue.helper.presentation.util.event.asEventFlow
+import timber.log.Timber
+
+class BiomeDetailViewModel(
+ private val biomeRepository: BiomeRepository,
+ analytics: AnalyticsLogger,
+) : ErrorHandleViewModel(analytics),
+ BiomeDetailHandler,
+ PokemonListNavigateHandler {
+ private val biomeId: MutableStateFlow = MutableStateFlow(IDLE_ID)
+
+ private val _isInBattleNavigationMode: MutableStateFlow = MutableStateFlow(false)
+ val isInBattleNavigationMode: StateFlow = _isInBattleNavigationMode.asStateFlow()
+
+ // TODO : ์์ง ์์
๋ค ์๋๋ฌ์
+ val uiState: StateFlow =
+ combine(
+ biomeId,
+ refreshEvent.onStart { emit(Unit) },
+ ) { id, _ ->
+ Timber.d("combine - biomeId: $id")
+ id
+ }.filter { id ->
+ Timber.d("filter - biomeId: $id")
+ id != IDLE_ID
+ }.map { id ->
+ Timber.d("map - biomeId: $id")
+ biomeRepository.biomeDetail(id).toUiState()
+ }.stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000),
+ BiomeDetailUiState.Default,
+ )
+
+ private val _uiEvent = MutableEventFlow()
+ val uiEvent = _uiEvent.asEventFlow()
+
+ val isLoading: StateFlow =
+ uiState.map {
+ it == BiomeDetailUiState.Default
+ }.stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000),
+ true,
+ )
+
+ init {
+ viewModelScope.launch {
+ biomeRepository
+ .isBattleNavigationModeStream()
+ .firstOrNull()
+ ?.let { _isInBattleNavigationMode.value = it }
+ }
+ }
+
+ fun init(id: String) {
+ if (id.isBlank()) return handlePokemonError(IllegalArgumentException("biomeId is blank"))
+ biomeId.value = id
+ }
+
+ fun changeNavigationMode(isBattleNavigationMode: Boolean) {
+ _isInBattleNavigationMode.value = isBattleNavigationMode
+ viewModelScope.launch {
+ biomeRepository.saveNavigationMode(isBattleNavigationMode)
+ }
+ }
+
+ override fun navigateToBiomeDetail(id: String) {
+ viewModelScope.launch {
+ _uiEvent.emit(BiomeDetailUiEvent.NavigateToNextBiomeDetail(id))
+ }
+ }
+
+ override fun navigateToPokemonDetail(pokemonId: String) {
+ val uiEvent =
+ if (isInBattleNavigationMode.value) {
+ BiomeDetailUiEvent.NavigateToBattle(pokemonId)
+ } else {
+ BiomeDetailUiEvent.NavigateToPokemonDetail(pokemonId)
+ }
+ viewModelScope.launch {
+ _uiEvent.emit(uiEvent)
+ }
+ }
+
+ companion object {
+ private const val IDLE_ID = "IDLE"
+ }
+}
+
+sealed interface BiomeDetailUiEvent {
+ data class NavigateToNextBiomeDetail(val biomeId: String) : BiomeDetailUiEvent
+
+ data class NavigateToPokemonDetail(val pokemonId: String) : BiomeDetailUiEvent
+
+ data class NavigateToBattle(val pokemonId: String) : BiomeDetailUiEvent
+}
+
+interface BiomeDetailHandler {
+ fun navigateToBiomeDetail(id: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomePokemonViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomePokemonViewHolder.kt
new file mode 100644
index 00000000..066d67f9
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomePokemonViewHolder.kt
@@ -0,0 +1,46 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemPokemonListPokemonBinding
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.dex.PokemonTypesAdapter
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.type.view.TypeChip
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.ui.component.PokeChip
+
+class BiomePokemonViewHolder(
+ private val binding: ItemPokemonListPokemonBinding,
+ private val onClickPokemon: PokemonListNavigateHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(pokemonItem: PokemonUiModel) {
+ binding.pokemon = pokemonItem
+ binding.listener = onClickPokemon
+ binding.spec = pokeChipSpec
+ val typesLayout = binding.layoutItemPokemonPokemonTypes
+
+ val pokemonTypesAdapter =
+ PokemonTypesAdapter(
+ context = binding.root.context,
+ viewGroup = typesLayout,
+ )
+
+ pokemonTypesAdapter.addTypes(
+ types = pokemonItem.types,
+ config = typesUiConfig,
+ spacingBetweenTypes = 0.dp,
+ )
+ }
+
+ companion object {
+ private val typesUiConfig =
+ TypeChip.PokemonTypeViewConfiguration(
+ hasBackGround = true,
+ nameSize = 7.dp,
+ iconSize = 14.dp,
+ spacing = 0.dp,
+ )
+
+ private val pokeChipSpec = PokeChip.Spec.EMPTY
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossAdapter.kt
new file mode 100644
index 00000000..23f722e5
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossAdapter.kt
@@ -0,0 +1,43 @@
+package poke.rogue.helper.presentation.biome.detail.boss
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBiomePokemonBinding
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class BiomeBossAdapter(
+ private val onClickPokemon: PokemonListNavigateHandler,
+) :
+ ListAdapter(wildPokemonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): BiomeBossViewHolder {
+ return BiomeBossViewHolder(
+ ItemBiomePokemonBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickPokemon,
+ )
+ }
+
+ override fun onBindViewHolder(
+ holder: BiomeBossViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val wildPokemonComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.grade == newItem.grade },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossFragment.kt
new file mode 100644
index 00000000..a8b5414c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossFragment.kt
@@ -0,0 +1,47 @@
+package poke.rogue.helper.presentation.biome.detail.boss
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.activityViewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentBiomeBossPokemonBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.setVisible
+
+class BiomeBossFragment :
+ ErrorHandleFragment(R.layout.fragment_biome_boss_pokemon) {
+ private val viewModel by activityViewModels()
+ private val bossPokemonAdapter: BiomeBossAdapter by lazy { BiomeBossAdapter(viewModel) }
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar? = null
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initAdapter() {
+ binding.rvBiomeBoss.adapter = bossPokemonAdapter
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiState.collect { state ->
+ state.bossPokemons.isEmpty().let {
+ binding.biomeDetailBossEmpty.setVisible(it)
+ }
+ bossPokemonAdapter.submitList(state.bossPokemons)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossViewHolder.kt
new file mode 100644
index 00000000..bfb7f26d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossViewHolder.kt
@@ -0,0 +1,26 @@
+package poke.rogue.helper.presentation.biome.detail.boss
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBiomePokemonBinding
+import poke.rogue.helper.presentation.biome.detail.BiomPokemonAdapter
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeBossViewHolder(
+ private val binding: ItemBiomePokemonBinding,
+ private val onClickPokemon: PokemonListNavigateHandler,
+) :
+ RecyclerView.ViewHolder(binding.root) {
+ private val pokemonAdapter: BiomPokemonAdapter by lazy { BiomPokemonAdapter(onClickPokemon) }
+
+ fun bind(bossPokemon: BiomePokemonUiModel) {
+ binding.biomePokemon = bossPokemon
+
+ val decoration = GridSpacingItemDecoration(3, 18.dp, false)
+ binding.rvBiomeWildPokemon.addItemDecoration(decoration)
+ bossPokemon.pokemons.let(pokemonAdapter::submitList)
+ binding.rvBiomeWildPokemon.adapter = pokemonAdapter
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymAdapter.kt
new file mode 100644
index 00000000..34477861
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymAdapter.kt
@@ -0,0 +1,42 @@
+package poke.rogue.helper.presentation.biome.detail.gym
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBiomeGymBinding
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class BiomeGymAdapter(
+ private val onClickPokemon: PokemonListNavigateHandler,
+) : ListAdapter(gymPokemonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): BiomeGymViewHolder {
+ return BiomeGymViewHolder(
+ ItemBiomeGymBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickPokemon,
+ )
+ }
+
+ override fun onBindViewHolder(
+ holder: BiomeGymViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val gymPokemonComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.grade == newItem.grade },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymFragment.kt
new file mode 100644
index 00000000..7f039a42
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymFragment.kt
@@ -0,0 +1,47 @@
+package poke.rogue.helper.presentation.biome.detail.gym
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.activityViewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentBiomeGymPokemonBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.setVisible
+
+class BiomeGymFragment :
+ ErrorHandleFragment(R.layout.fragment_biome_gym_pokemon) {
+ private val viewModel by activityViewModels()
+ private val gymPokemonAdapter: BiomeGymAdapter by lazy { BiomeGymAdapter(viewModel) }
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar? = null
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initAdapter() {
+ binding.rvBiomeGym.adapter = gymPokemonAdapter
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiState.collect { state ->
+ state.gymPokemons.isEmpty().let {
+ binding.biomeDetailGymEmpty.setVisible(it)
+ }
+ gymPokemonAdapter.submitList(state.gymPokemons)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymViewHolder.kt
new file mode 100644
index 00000000..bf2915df
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymViewHolder.kt
@@ -0,0 +1,28 @@
+package poke.rogue.helper.presentation.biome.detail.gym
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBiomeGymBinding
+import poke.rogue.helper.presentation.biome.detail.BiomPokemonAdapter
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeGymViewHolder(
+ private val binding: ItemBiomeGymBinding,
+ private val onClickPokemon: PokemonListNavigateHandler,
+) :
+ RecyclerView.ViewHolder(
+ binding.root,
+ ) {
+ private val pokemonAdapter: BiomPokemonAdapter by lazy { BiomPokemonAdapter(onClickPokemon) }
+
+ fun bind(gymPokemon: BiomePokemonUiModel) {
+ binding.gymLeader = gymPokemon
+
+ val decoration = GridSpacingItemDecoration(3, 9.dp, false)
+ binding.rvBiomeGymPokemon.addItemDecoration(decoration)
+ gymPokemon.pokemons.let(pokemonAdapter::submitList)
+ binding.rvBiomeGymPokemon.adapter = pokemonAdapter
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesAdapter.kt
new file mode 100644
index 00000000..2614cc8e
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesAdapter.kt
@@ -0,0 +1,41 @@
+package poke.rogue.helper.presentation.biome.detail.nextbiomes
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBiomeNextBiomesBinding
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailHandler
+import poke.rogue.helper.presentation.biome.model.NextBiomeUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class BiomeNextBiomesAdapter(private val onClickNextBiome: BiomeDetailHandler) :
+ ListAdapter(biomeComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): BiomeNextBiomesViewHolder {
+ return BiomeNextBiomesViewHolder(
+ ItemBiomeNextBiomesBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickNextBiome,
+ )
+ }
+
+ override fun onBindViewHolder(
+ holder: BiomeNextBiomesViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val biomeComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.biome.id == newItem.biome.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesFragment.kt
new file mode 100644
index 00000000..5af593ae
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesFragment.kt
@@ -0,0 +1,42 @@
+package poke.rogue.helper.presentation.biome.detail.nextbiomes
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.activityViewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentBiomeNextBiomeBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+
+class BiomeNextBiomesFragment :
+ ErrorHandleFragment(R.layout.fragment_biome_next_biome) {
+ private val viewModel by activityViewModels()
+ private val nextBiomeAdapter: BiomeNextBiomesAdapter by lazy { BiomeNextBiomesAdapter(viewModel) }
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar? = null
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initAdapter() {
+ binding.rvBiomeNextBiome.adapter = nextBiomeAdapter
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiState.collect { state ->
+ nextBiomeAdapter.submitList(state.nextBiomes)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesViewHolder.kt
new file mode 100644
index 00000000..2b47d738
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/nextbiomes/BiomeNextBiomesViewHolder.kt
@@ -0,0 +1,37 @@
+package poke.rogue.helper.presentation.biome.detail.nextbiomes
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBiomeNextBiomesBinding
+import poke.rogue.helper.presentation.biome.BiomeTypesAdapter
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailHandler
+import poke.rogue.helper.presentation.biome.model.NextBiomeUiModel
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeNextBiomesViewHolder(
+ private val binding: ItemBiomeNextBiomesBinding,
+ private val onClickNextBiome: BiomeDetailHandler,
+) :
+ RecyclerView.ViewHolder(
+ binding.root,
+ ) {
+ fun bind(nextBiome: NextBiomeUiModel) {
+ binding.nextBiome = nextBiome
+ binding.handler = onClickNextBiome
+ val typesLayout = binding.flBiomeTypeIcons
+ val biomeTypesAdapter =
+ BiomeTypesAdapter(
+ context = binding.root.context,
+ viewGroup = typesLayout,
+ )
+ biomeTypesAdapter.addTypes(
+ types = nextBiome.biome.types,
+ spacingBetweenTypes = TYPES_SPACING,
+ iconSize = TYPE_ICON_SIZE,
+ )
+ }
+
+ companion object {
+ private val TYPES_SPACING = 5.dp
+ private val TYPE_ICON_SIZE = 18.dp
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildAdapter.kt
new file mode 100644
index 00000000..64fdc370
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildAdapter.kt
@@ -0,0 +1,43 @@
+package poke.rogue.helper.presentation.biome.detail.wild
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemBiomePokemonBinding
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class BiomeWildAdapter(
+ private val onClickPokemon: PokemonListNavigateHandler,
+) :
+ ListAdapter(wildPokemonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): BiomeWildViewHolder {
+ return BiomeWildViewHolder(
+ ItemBiomePokemonBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickPokemon,
+ )
+ }
+
+ override fun onBindViewHolder(
+ holder: BiomeWildViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val wildPokemonComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.grade == newItem.grade },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildPokemonFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildPokemonFragment.kt
new file mode 100644
index 00000000..78d37df9
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildPokemonFragment.kt
@@ -0,0 +1,42 @@
+package poke.rogue.helper.presentation.biome.detail.wild
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.activityViewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentBiomeWildPokemonBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleFragment
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+
+class BiomeWildPokemonFragment() :
+ ErrorHandleFragment(R.layout.fragment_biome_wild_pokemon) {
+ private val viewModel by activityViewModels()
+ private val wildPokemonAdapter: BiomeWildAdapter by lazy { BiomeWildAdapter(viewModel) }
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar? = null
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initAdapter() {
+ binding.rvBiomeWild.adapter = wildPokemonAdapter
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiState.collect { state ->
+ wildPokemonAdapter.submitList(state.wildPokemons)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildViewHolder.kt
new file mode 100644
index 00000000..9a11e3be
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildViewHolder.kt
@@ -0,0 +1,29 @@
+package poke.rogue.helper.presentation.biome.detail.wild
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemBiomePokemonBinding
+import poke.rogue.helper.presentation.biome.detail.BiomPokemonAdapter
+import poke.rogue.helper.presentation.biome.model.BiomePokemonUiModel
+import poke.rogue.helper.presentation.dex.PokemonListNavigateHandler
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class BiomeWildViewHolder(
+ private val binding: ItemBiomePokemonBinding,
+ private val onClickPokemon: PokemonListNavigateHandler,
+) :
+ RecyclerView.ViewHolder(binding.root) {
+ private val pokemonAdapter: BiomPokemonAdapter by lazy { BiomPokemonAdapter(onClickPokemon) }
+
+ init {
+ val decoration = GridSpacingItemDecoration(3, 18.dp, false)
+ binding.rvBiomeWildPokemon.addItemDecoration(decoration)
+ }
+
+ fun bind(wildPokemon: BiomePokemonUiModel) {
+ binding.biomePokemon = wildPokemon
+
+ wildPokemon.pokemons.let(pokemonAdapter::submitList)
+ binding.rvBiomeWildPokemon.adapter = pokemonAdapter
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/guide/BiomeGuideActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/guide/BiomeGuideActivity.kt
new file mode 100644
index 00000000..d1304191
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/guide/BiomeGuideActivity.kt
@@ -0,0 +1,26 @@
+package poke.rogue.helper.presentation.biome.guide
+
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityBiomeGuideBinding
+import poke.rogue.helper.presentation.base.toolbar.ToolbarActivity
+import poke.rogue.helper.presentation.util.context.stringOf
+
+class BiomeGuideActivity :
+ ToolbarActivity(R.layout.activity_biome_guide) {
+ override val toolbar: Toolbar
+ get() = binding.toolbarBiomeGuide
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initView()
+ }
+
+ private fun initView() {
+ with(binding.wvBiomeGuide) {
+ settings.loadWithOverviewMode = true
+ loadUrl(stringOf(R.string.biome_guide_url))
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomePokemonUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomePokemonUiModel.kt
new file mode 100644
index 00000000..7ea3e20c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomePokemonUiModel.kt
@@ -0,0 +1,59 @@
+package poke.rogue.helper.presentation.biome.model
+
+import poke.rogue.helper.data.model.biome.BiomePokemon
+import poke.rogue.helper.data.model.biome.BossPokemon
+import poke.rogue.helper.data.model.biome.GymPokemon
+import poke.rogue.helper.data.model.biome.WildPokemon
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.type.model.toUi
+
+data class BiomePokemonUiModel(
+ val grade: String,
+ val gymLeaderUrl: String?,
+ val type: TypeUiModel?,
+ val pokemons: List,
+)
+
+fun WildPokemon.toUi(): BiomePokemonUiModel =
+ BiomePokemonUiModel(
+ grade = tier,
+ gymLeaderUrl = null,
+ type = null,
+ pokemons =
+ pokemons.mapIndexed { index, biomePokemon ->
+ biomePokemon.toPokemonUiModel(index)
+ },
+ )
+
+fun BossPokemon.toUi(): BiomePokemonUiModel =
+ BiomePokemonUiModel(
+ grade = tier,
+ gymLeaderUrl = null,
+ type = null,
+ pokemons =
+ pokemons.mapIndexed { index, biomePokemon ->
+ biomePokemon.toPokemonUiModel(index)
+ },
+ )
+
+fun GymPokemon.toUi(): BiomePokemonUiModel =
+ BiomePokemonUiModel(
+ grade = gymLeaderName,
+ gymLeaderUrl = gymLeaderImage,
+ type = gymLeaderTypeLogos.firstOrNull()?.toUi(),
+ pokemons =
+ pokemons.mapIndexed { index, biomePokemon ->
+ biomePokemon.toPokemonUiModel(index)
+ },
+ )
+
+fun BiomePokemon.toPokemonUiModel(hashId: Int): PokemonUiModel =
+ PokemonUiModel(
+ id = id,
+ hashId = hashId.toLong(),
+ dexNumber = 0,
+ name = name,
+ imageUrl = imageUrl,
+ types = types.map { it.toUi() },
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomeUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomeUiModel.kt
new file mode 100644
index 00000000..e39d5c46
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomeUiModel.kt
@@ -0,0 +1,84 @@
+package poke.rogue.helper.presentation.biome.model
+
+import poke.rogue.helper.data.model.Biome
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.type.model.toUi
+import java.util.Locale
+
+data class BiomeUiModel(
+ val id: String,
+ val name: String,
+ val imageUrl: String,
+ val types: List,
+) {
+ companion object {
+ val DUMMYS: List =
+ listOf(
+ BiomeUiModel(
+ "grass",
+ "ํ์ฒ",
+ "https://wiki.pokerogue.net/_media/ko:biomes:ko_grassy_fields_bg.png?w=200&tok=745c5b",
+ types = listOf(TypeUiModel.GRASS, TypeUiModel.POISON),
+ ),
+ BiomeUiModel(
+ "tall_grass",
+ "๋์ ํ์ฒ",
+ "https://wiki.pokerogue.net/_media/ko:biomes:ko_tall_grass_bg.png?w=200&tok=b3497c",
+ types = listOf(TypeUiModel.BUG),
+ ),
+ BiomeUiModel(
+ "cave",
+ "๋๊ตด",
+ "https://wiki.pokerogue.net/_media/ko:biomes:ko_cave_bg.png?w=200&tok=905d8b",
+ types = listOf(TypeUiModel.GRASS),
+ ),
+ BiomeUiModel(
+ "badlands",
+ "์
์ง",
+ "https://wiki.pokerogue.net/_media/ko:biomes:ko_badlands_bg.png?w=200&tok=37d070",
+ types = listOf(TypeUiModel.DARK, TypeUiModel.FIGHTING),
+ ),
+ BiomeUiModel(
+ "factory",
+ "๊ณต์ฅ",
+ "https://wiki.pokerogue.net/_media/en:biomes:en_factory_bg.png?w=200&tok=5c1cb5",
+ types = listOf(TypeUiModel.DARK, TypeUiModel.FIGHTING),
+ ),
+ BiomeUiModel(
+ "construction_site",
+ "๊ณต์ฌ์ฅ",
+ "https://wiki.pokerogue.net/_media/en:biomes:en_construction_site_bg.png?w=200&tok=8cf671",
+ types = listOf(TypeUiModel.NORMAL, TypeUiModel.GROUND),
+ ),
+ BiomeUiModel(
+ "snowy_forest",
+ "๋๋ฎํ ์ฒ",
+ "https://wiki.pokerogue.net/_media/en:biomes:en_snowy_forest_bg.png?w=200&tok=2fe712",
+ types = listOf(TypeUiModel.ICE, TypeUiModel.STEEL),
+ ),
+ BiomeUiModel(
+ "ice_cave",
+ "์ผ์๋๊ตด",
+ "https://wiki.pokerogue.net/_media/en:biomes:en_ice_cave_bg.png?w=200&tok=aa8cf1",
+ types = listOf(TypeUiModel.ICE, TypeUiModel.WATER),
+ ),
+ )
+ }
+}
+
+fun List.toTypeUi(): List {
+ return this.map { url ->
+ val typeName = url.substringAfter("type/").substringBefore("-")
+ TypeUiModel.valueOf(typeName.uppercase(Locale.ROOT))
+ }
+}
+
+fun Biome.toUi(): BiomeUiModel =
+ BiomeUiModel(
+ id = id,
+ name = name,
+ imageUrl = image,
+ types = (gymLeaderType.toUi() + pokemonType.toUi()).distinct().take(4),
+ )
+
+fun List.toUi(): List = map(Biome::toUi)
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/NextBiomeUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/NextBiomeUiModel.kt
new file mode 100644
index 00000000..d6988fa4
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/NextBiomeUiModel.kt
@@ -0,0 +1,21 @@
+package poke.rogue.helper.presentation.biome.model
+
+import poke.rogue.helper.data.model.NextBiome
+import poke.rogue.helper.presentation.type.model.toUi
+
+data class NextBiomeUiModel(
+ val biome: BiomeUiModel,
+ val probability: Double,
+)
+
+fun NextBiome.toUi(): NextBiomeUiModel =
+ NextBiomeUiModel(
+ biome =
+ BiomeUiModel(
+ id = id,
+ name = name,
+ imageUrl = image,
+ types = (gymLeaderType.toUi() + pokemonType.toUi()).distinct().take(4),
+ ),
+ probability = probability,
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/AnalyticsExtensions.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/AnalyticsExtensions.kt
new file mode 100644
index 00000000..eb2a74ca
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/AnalyticsExtensions.kt
@@ -0,0 +1,63 @@
+package poke.rogue.helper.presentation.dex
+
+import poke.rogue.helper.analytics.AnalyticsEvent
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.presentation.dex.detail.NavigateToBattleEvent
+import poke.rogue.helper.presentation.dex.filter.PokeFilterUiModel
+import poke.rogue.helper.presentation.dex.sort.PokemonSortUiModel
+
+fun AnalyticsLogger.logPokemonSort(sort: PokemonSortUiModel) {
+ val eventType = "pokemon_dex_sort"
+ logEvent(
+ AnalyticsEvent(
+ type = eventType,
+ extras = sort.toParams(),
+ ),
+ )
+}
+
+private fun PokemonSortUiModel.toParams(): List {
+ return listOf(
+ AnalyticsEvent.Param(key = "sort_type", value = name),
+ )
+}
+
+fun AnalyticsLogger.logPokemonFilter(filter: PokeFilterUiModel) {
+ val eventType = "pokemon_dex_filter"
+ logEvent(
+ AnalyticsEvent(
+ type = eventType,
+ extras = filter.toParams(),
+ ),
+ )
+}
+
+private fun PokeFilterUiModel.toParams(): List {
+ return selectedTypes.map {
+ AnalyticsEvent.Param(key = "type", value = it.name)
+ } + AnalyticsEvent.Param(key = "generation", value = selectedGeneration.name)
+}
+
+fun AnalyticsLogger.logPokemonDetailToBattle(event: NavigateToBattleEvent) {
+ val eventType = "pokemon_detail_to_battle_directly"
+ logEvent(
+ AnalyticsEvent(
+ type = eventType,
+ extras = event.toParams(),
+ ),
+ )
+}
+
+private fun NavigateToBattleEvent.toParams(): List {
+ val (battleRoleValue, pokemon) =
+ when (this) {
+ is NavigateToBattleEvent.WithMyPokemon -> Pair("MyPokemon", pokemon)
+ is NavigateToBattleEvent.WithOpponentPokemon -> Pair("EnemyPokemon", pokemon)
+ }
+
+ return listOf(
+ AnalyticsEvent.Param(key = "battle_role", value = battleRoleValue),
+ AnalyticsEvent.Param(key = "pokemon_id", value = pokemon.id),
+ AnalyticsEvent.Param(key = "pokemon_name", value = pokemon.name),
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonAdapter.kt
new file mode 100644
index 00000000..74d5c099
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonAdapter.kt
@@ -0,0 +1,39 @@
+package poke.rogue.helper.presentation.dex
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemPokemonListPokemonBinding
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class PokemonAdapter(private val onClickPokeMonItem: PokemonListNavigateHandler) :
+ ListAdapter(poketmonComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): PokemonViewHolder =
+ PokemonViewHolder(
+ ItemPokemonListPokemonBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickPokeMonItem,
+ )
+
+ override fun onBindViewHolder(
+ viewHolder: PokemonViewHolder,
+ position: Int,
+ ) {
+ viewHolder.bind(getItem(position))
+ }
+
+ companion object {
+ val poketmonComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.hashId == newItem.hashId },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt
new file mode 100644
index 00000000..6e089b75
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt
@@ -0,0 +1,145 @@
+package poke.rogue.helper.presentation.dex
+
+import android.content.res.Configuration
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import androidx.recyclerview.widget.GridLayoutManager
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityPokemonListBinding
+import poke.rogue.helper.presentation.base.error.ErrorHandleActivity
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailActivity
+import poke.rogue.helper.presentation.dex.filter.PokeFilterUiModel
+import poke.rogue.helper.presentation.dex.filter.PokemonFilterBottomSheetFragment
+import poke.rogue.helper.presentation.dex.sort.PokemonSortBottomSheetFragment
+import poke.rogue.helper.presentation.dex.sort.PokemonSortUiModel
+import poke.rogue.helper.presentation.util.activity.hideKeyboard
+import poke.rogue.helper.presentation.util.context.stringOf
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.ui.component.PokeChip
+import poke.rogue.helper.ui.component.PokeChip.Companion.bindPokeChip
+import poke.rogue.helper.ui.layout.PaddingValues
+
+class PokemonListActivity :
+ ErrorHandleActivity(R.layout.activity_pokemon_list) {
+ private val viewModel by viewModel()
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+
+ private val pokemonAdapter: PokemonAdapter by lazy {
+ PokemonAdapter(viewModel)
+ }
+
+ override val toolbar: Toolbar
+ get() = binding.toolbarDex
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ initAdapter()
+ initObservers()
+ initListeners()
+ }
+
+ private fun initAdapter() {
+ binding.rvPokemonList.apply {
+ val spanCount =
+ if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
+ adapter = pokemonAdapter
+ layoutManager = GridLayoutManager(context, spanCount)
+ addItemDecoration(
+ GridSpacingItemDecoration(
+ spanCount = spanCount,
+ spacing = 9.dp,
+ includeEdge = false,
+ ),
+ )
+ }
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ viewModel.uiState.collect { uiState ->
+ pokemonAdapter.submitList(uiState.pokemons) {
+ binding.rvPokemonList.scrollToPosition(0)
+ }
+
+ binding.chipPokeFiter.bindPokeChip(
+ PokeChip.Spec(
+ label =
+ stringOf(
+ R.string.dex_filter_chip,
+ if (uiState.isFiltered) uiState.filterCount.toString() else "",
+ ),
+ trailingIconRes = R.drawable.ic_filter,
+ isSelected = uiState.isFiltered,
+ padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
+ onSelect = {
+ PokemonFilterBottomSheetFragment.newInstance(
+ uiState.filteredTypes,
+ uiState.filteredGeneration,
+ ).show(
+ supportFragmentManager,
+ PokemonFilterBottomSheetFragment.TAG,
+ )
+ },
+ ),
+ )
+
+ binding.chipPokeSort.bindPokeChip(
+ PokeChip.Spec(
+ label = uiState.sort.label.clean(),
+ trailingIconRes = R.drawable.ic_sort,
+ isSelected = uiState.isSorted,
+ padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
+ onSelect = {
+ PokemonSortBottomSheetFragment.newInstance(uiState.sort).show(
+ supportFragmentManager,
+ PokemonSortBottomSheetFragment.TAG,
+ )
+ },
+ ),
+ )
+ }
+ }
+ repeatOnStarted {
+ viewModel.navigateToDetailEvent.collect { pokemonId ->
+ hideKeyboard()
+ startActivity(PokemonDetailActivity.intent(this, pokemonId))
+ }
+ }
+
+ supportFragmentManager.setFragmentResultListener(FILTER_RESULT_KEY, this) { key, bundle ->
+ val filterArgs: PokeFilterUiModel =
+ PokemonFilterBottomSheetFragment.argsFrom(bundle)
+ ?: return@setFragmentResultListener
+ viewModel.filterPokemon(filterArgs)
+ }
+ supportFragmentManager.setFragmentResultListener(SORT_RESULT_KEY, this) { key, bundle ->
+ val sortArgs: PokemonSortUiModel =
+ PokemonSortBottomSheetFragment.argsFrom(bundle)
+ ?: return@setFragmentResultListener
+ viewModel.sortPokemon(sortArgs)
+ }
+ }
+
+ private fun initListeners() {
+ binding.root.setOnClickListener {
+ hideKeyboard()
+ }
+ }
+
+ private fun String.clean() =
+ this
+ .replace("\\s".toRegex(), "")
+ .replace("[^a-zA-Z0-9ใฑ-ใ
๊ฐ-ํฃ]".toRegex(), "")
+
+ companion object {
+ const val FILTER_RESULT_KEY = "FILTER_RESULT_KEY_result_key"
+ const val SORT_RESULT_KEY = "SORT_RESULT_KEY_result_key"
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListNavigateHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListNavigateHandler.kt
new file mode 100644
index 00000000..f8097632
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListNavigateHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.dex
+
+interface PokemonListNavigateHandler {
+ fun navigateToPokemonDetail(pokemonId: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt
new file mode 100644
index 00000000..973107f2
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt
@@ -0,0 +1,164 @@
+package poke.rogue.helper.presentation.dex
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.exception.PokeException
+import poke.rogue.helper.data.model.PokemonFilter
+import poke.rogue.helper.data.repository.DexRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.dex.filter.PokeFilterUiModel
+import poke.rogue.helper.presentation.dex.filter.PokeGenerationUiModel
+import poke.rogue.helper.presentation.dex.filter.toDataOrNull
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.dex.model.toUi
+import poke.rogue.helper.presentation.dex.sort.PokemonSortUiModel
+import poke.rogue.helper.presentation.dex.sort.toData
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.type.model.toData
+
+class PokemonListViewModel(
+ private val pokemonRepository: DexRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), PokemonListNavigateHandler, PokemonQueryHandler {
+ private val searchQuery = MutableStateFlow("")
+ private val pokeFilter =
+ MutableStateFlow(
+ PokeFilterUiModel(
+ emptyList(),
+ PokeGenerationUiModel.ALL,
+ ),
+ )
+ private val pokeSort = MutableStateFlow(PokemonSortUiModel.ByDexNumber)
+
+ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+ val uiState: StateFlow =
+ merge(refreshEvent.map { "" }, searchQuery)
+ .onStart {
+ if (isEmpty.value) {
+ _isLoading.value = true
+ }
+ }
+ .debounce(300L)
+ .flatMapLatest { query ->
+ combine(pokeSort, pokeFilter) { sort, filter ->
+ PokemonListUiState(
+ pokemons =
+ queriedPokemons(
+ query = query,
+ types = filter.selectedTypes,
+ generation = filter.selectedGeneration,
+ sort = sort,
+ ),
+ sort = sort,
+ filteredTypes = filter.selectedTypes,
+ filteredGeneration = filter.selectedGeneration,
+ )
+ }
+ }.stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000),
+ PokemonListUiState(),
+ )
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ val isEmpty: StateFlow =
+ uiState.map { it.pokemons.isEmpty() && !isLoading.value }
+ .stateIn(
+ viewModelScope + errorHandler,
+ SharingStarted.WhileSubscribed(5000),
+ true,
+ )
+
+ private val _navigateToDetailEvent = MutableSharedFlow()
+ val navigateToDetailEvent = _navigateToDetailEvent.asSharedFlow()
+
+ private suspend fun queriedPokemons(
+ query: String,
+ types: List,
+ generation: PokeGenerationUiModel,
+ sort: PokemonSortUiModel,
+ ): List {
+ return try {
+ val filteredTypes = types.map { PokemonFilter.ByType(it.toData()) }
+ val filteredGenerations =
+ listOfNotNull(generation.toDataOrNull()).map { PokemonFilter.ByGeneration(it) }
+ pokemonRepository.filteredPokemons(
+ query,
+ sort.toData(),
+ filteredTypes + filteredGenerations,
+ ).map {
+ it.toUi().copy(sortUiModel = sort)
+ }
+ } catch (e: PokeException) {
+ handlePokemonError(e)
+ emptyList()
+ } finally {
+ _isLoading.value = false
+ }
+ }
+
+ override fun navigateToPokemonDetail(pokemonId: String) {
+ viewModelScope.launch {
+ _navigateToDetailEvent.emit(pokemonId)
+ }
+ }
+
+ override fun queryName(name: String) {
+ viewModelScope.launch {
+ searchQuery.value = name
+ }
+ }
+
+ fun filterPokemon(filter: PokeFilterUiModel) {
+ viewModelScope.launch {
+ pokeFilter.value = filter
+ }
+ analyticsLogger().logPokemonFilter(filter)
+ }
+
+ fun sortPokemon(sort: PokemonSortUiModel) {
+ viewModelScope.launch {
+ pokeSort.value = sort
+ }
+ analyticsLogger().logPokemonSort(sort)
+ }
+}
+
+data class PokemonListUiState(
+ val pokemons: List = emptyList(),
+ val sort: PokemonSortUiModel = PokemonSortUiModel.ByDexNumber,
+ val filteredTypes: List = emptyList(),
+ val filteredGeneration: PokeGenerationUiModel = PokeGenerationUiModel.ALL,
+) {
+ val isSorted get() = sort != PokemonSortUiModel.ByDexNumber
+ val isFiltered get() = filteredTypes.isNotEmpty() || filteredGeneration != PokeGenerationUiModel.ALL
+
+ val filterCount
+ get() =
+ run {
+ var count = 0
+ if (filteredTypes.isNotEmpty()) count += filteredTypes.size
+ if (filteredGeneration != PokeGenerationUiModel.ALL) count++
+ count
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonQueryHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonQueryHandler.kt
new file mode 100644
index 00000000..4697f579
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonQueryHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.dex
+
+fun interface PokemonQueryHandler {
+ fun queryName(name: String)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonSearchViewBindingAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonSearchViewBindingAdapter.kt
new file mode 100644
index 00000000..f7fe0c0d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonSearchViewBindingAdapter.kt
@@ -0,0 +1,21 @@
+package poke.rogue.helper.presentation.dex
+
+import androidx.appcompat.widget.SearchView
+import androidx.databinding.BindingAdapter
+
+@BindingAdapter("onQueryTextChange")
+fun setOnQueryTextListener(
+ searchView: SearchView,
+ onQueryTextChangeListener: PokemonQueryHandler,
+) {
+ searchView.setOnQueryTextListener(
+ object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ onQueryTextChangeListener.queryName(newText.toString())
+ return true
+ }
+ },
+ )
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonTypesAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonTypesAdapter.kt
new file mode 100644
index 00000000..d4bf23e3
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonTypesAdapter.kt
@@ -0,0 +1,47 @@
+package poke.rogue.helper.presentation.dex
+
+import android.content.Context
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.LinearLayout.LayoutParams
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.type.view.TypeChip
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonTypesAdapter(private val context: Context, private val viewGroup: ViewGroup) {
+ fun addTypes(
+ types: List,
+ config: TypeChip.PokemonTypeViewConfiguration,
+ spacingBetweenTypes: Int = 0.dp,
+ ) {
+ viewGroup.removeAllViews()
+
+ types.forEach { type ->
+ val typeChip =
+ TypeChip(context).apply {
+ layoutParams =
+ LayoutParams(
+ LayoutParams.WRAP_CONTENT,
+ LayoutParams.WRAP_CONTENT,
+ 1f,
+ ).apply {
+ setMargins(horizontalMargin = spacingBetweenTypes)
+ }
+ TypeChip.setTypeUiConfiguration(
+ view = this,
+ typeUiModel = type,
+ typeViewConfiguration = config,
+ )
+ }
+ viewGroup.addView(typeChip)
+ }
+ }
+}
+
+private fun MarginLayoutParams.setMargins(
+ topMargin: Int = 0.dp,
+ bottomMargin: Int = 0.dp,
+ horizontalMargin: Int,
+) {
+ setMargins(horizontalMargin, topMargin, horizontalMargin, bottomMargin)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonViewHolder.kt
new file mode 100644
index 00000000..159b1d53
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonViewHolder.kt
@@ -0,0 +1,68 @@
+package poke.rogue.helper.presentation.dex
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ItemPokemonListPokemonBinding
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.type.view.TypeChip
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.ui.component.PokeChip
+import poke.rogue.helper.ui.layout.PaddingValues
+
+class PokemonViewHolder(
+ private val binding: ItemPokemonListPokemonBinding,
+ onClickPokeMonItem: PokemonListNavigateHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ init {
+ binding.listener = onClickPokeMonItem
+ }
+
+ fun bind(pokemonUiModel: PokemonUiModel) {
+ binding.pokemon = pokemonUiModel
+ binding.spec =
+ PokeChip.Spec(
+ label = pokemonUiModel.displayStat.toString(),
+ sizes =
+ PokeChip.Sizes(
+ labelSize = 10,
+ ),
+ colors =
+ PokeChip.Colors(
+ labelColor = R.color.poke_grey_80,
+ selectedContainerColor = R.color.lemon,
+ ),
+ strokeWidth = 0.dp,
+ padding =
+ PaddingValues(
+ start = 4.dp,
+ top = 2.dp,
+ end = 4.dp,
+ bottom = 2.dp,
+ ),
+ isSelected = true,
+ )
+ val typesLayout = binding.layoutItemPokemonPokemonTypes
+
+ val pokemonTypesAdapter =
+ PokemonTypesAdapter(
+ context = binding.root.context,
+ viewGroup = typesLayout,
+ )
+
+ pokemonTypesAdapter.addTypes(
+ types = pokemonUiModel.types,
+ config = typesUiConfig,
+ spacingBetweenTypes = 0.dp,
+ )
+ }
+
+ companion object {
+ private val typesUiConfig =
+ TypeChip.PokemonTypeViewConfiguration(
+ hasBackGround = true,
+ nameSize = 8.dp,
+ iconSize = 18.dp,
+ spacing = 0.dp,
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/NavigateToBattleEvent.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/NavigateToBattleEvent.kt
new file mode 100644
index 00000000..f443223e
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/NavigateToBattleEvent.kt
@@ -0,0 +1,9 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+
+sealed class NavigateToBattleEvent {
+ data class WithMyPokemon(val pokemon: PokemonUiModel) : NavigateToBattleEvent()
+
+ data class WithOpponentPokemon(val pokemon: PokemonUiModel) : NavigateToBattleEvent()
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt
new file mode 100644
index 00000000..aad81565
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt
@@ -0,0 +1,263 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.LinearLayout.LayoutParams
+import androidx.appcompat.widget.Toolbar
+import com.google.android.material.tabs.TabLayoutMediator
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ActivityPokemonDetailBinding
+import poke.rogue.helper.presentation.ability.AbilityActivity
+import poke.rogue.helper.presentation.base.toolbar.ToolbarActivity
+import poke.rogue.helper.presentation.battle.BattleActivity
+import poke.rogue.helper.presentation.biome.detail.BiomeDetailActivity
+import poke.rogue.helper.presentation.dex.PokemonTypesAdapter
+import poke.rogue.helper.presentation.home.HomeActivity
+import poke.rogue.helper.presentation.type.view.TypeChip
+import poke.rogue.helper.presentation.util.context.startActivity
+import poke.rogue.helper.presentation.util.context.stringArrayOf
+import poke.rogue.helper.presentation.util.context.stringOf
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.presentation.util.view.loadImageWithProgress
+
+class PokemonDetailActivity :
+ ToolbarActivity(R.layout.activity_pokemon_detail) {
+ private val viewModel by viewModel()
+ override val toolbar: Toolbar
+ get() = binding.toolbarPokemonDetail
+
+ private lateinit var pokemonTypesAdapter: PokemonTypesAdapter
+
+ private lateinit var pokemonDetailPagerAdapter: PokemonDetailPagerAdapter
+
+ private var isExpanded = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.updatePokemonDetail(intent.getStringExtra(POKEMON_ID).toString())
+
+ binding.eventHandler = viewModel
+ binding.lifecycleOwner = this
+ binding.vm = viewModel
+
+ initAdapter()
+ initObservers()
+ initFloatingButtonsHandler()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putBoolean(IS_EXPANDED, isExpanded)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ isExpanded = savedInstanceState.getBoolean(IS_EXPANDED)
+ super.onRestoreInstanceState(savedInstanceState)
+ updateFloatingButtonsState()
+ }
+
+ private fun initAdapter() {
+ pokemonTypesAdapter =
+ PokemonTypesAdapter(
+ context = this,
+ viewGroup = binding.layoutPokemonDetailPokemonTypes,
+ )
+
+ pokemonDetailPagerAdapter = PokemonDetailPagerAdapter(this)
+ binding.pagerPokemonDetail.apply {
+ adapter = pokemonDetailPagerAdapter
+ }
+
+ val tabTitles = stringArrayOf(R.array.pokemon_detail_tab_titles)
+ TabLayoutMediator(
+ binding.tabLayoutPokemonDetail,
+ binding.pagerPokemonDetail,
+ ) { tab, position ->
+ tab.text = tabTitles[position]
+ }.attach()
+ }
+
+ private fun initObservers() {
+ observePokemonDetailUi()
+ observeNavigateToHomeEvent()
+ observeNavigateToAbilityDetailEvent()
+ observeNavigateToBiomeDetailEvent()
+ observeNavigateToPokemonDetailEvent()
+ observeNavigateToBattleEvent()
+ }
+
+ private fun initFloatingButtonsHandler() {
+ binding.fabPokemonDetailBattle.setOnClickListener {
+ toggleFloatingButtons()
+ }
+ }
+
+ private fun observePokemonDetailUi() {
+ repeatOnStarted {
+ viewModel.uiState.collect { pokemonDetail ->
+ when (pokemonDetail) {
+ is PokemonDetailUiState.IsLoading -> return@collect
+ is PokemonDetailUiState.Success -> {
+ bindPokemonDetail(pokemonDetail)
+ }
+ }
+ }
+ }
+ }
+
+ private fun observeNavigateToHomeEvent() {
+ repeatOnStarted {
+ viewModel.navigateToHomeEvent.collect {
+ if (it) {
+ startActivity(HomeActivity.intent(this))
+ }
+ }
+ }
+ }
+
+ private fun observeNavigateToAbilityDetailEvent() {
+ repeatOnStarted {
+ viewModel.navigationToAbilityDetailEvent.collect { abilityId ->
+ startActivity(AbilityActivity.intent(this, abilityId))
+ }
+ }
+ }
+
+ private fun observeNavigateToBiomeDetailEvent() {
+ repeatOnStarted {
+ viewModel.navigationToBiomeDetailEvent.collect { biomeId ->
+ startActivity(BiomeDetailActivity.intent(this, biomeId))
+ }
+ }
+ }
+
+ private fun observeNavigateToPokemonDetailEvent() {
+ repeatOnStarted {
+ viewModel.navigateToPokemonDetailEvent.collect { pokemonId ->
+ startActivity(intent(this, pokemonId))
+ }
+ }
+ }
+
+ private fun observeNavigateToBattleEvent() {
+ repeatOnStarted {
+ viewModel.navigateToBattleEvent.collect { battleEvent ->
+ val intent = battleIntent(battleEvent)
+ startActivity {
+ putExtras(intent)
+ }
+ }
+ }
+ }
+
+ private fun battleIntent(battleEvent: NavigateToBattleEvent): Intent =
+ when (battleEvent) {
+ is NavigateToBattleEvent.WithMyPokemon -> {
+ BattleActivity.intent(
+ this@PokemonDetailActivity,
+ pokemonId = battleEvent.pokemon.id,
+ isMine = true,
+ )
+ }
+
+ is NavigateToBattleEvent.WithOpponentPokemon -> {
+ BattleActivity.intent(
+ this@PokemonDetailActivity,
+ pokemonId = battleEvent.pokemon.id,
+ isMine = false,
+ )
+ }
+ }
+
+ private fun bindPokemonDetail(pokemonDetail: PokemonDetailUiState.Success) {
+ with(binding) {
+ ivPokemonDetailPokemon.loadImageWithProgress(
+ pokemonDetail.pokemon.imageUrl,
+ progressIndicatorPokemonDetail,
+ )
+
+ collapsingToolbarLayoutPokemonDetail?.title =
+ stringOf(
+ R.string.pokemon_list_poke_name_format,
+ pokemonDetail.pokemon.name,
+ pokemonDetail.pokemon.dexNumber,
+ )
+
+ tvPokemonDetailPokemonName?.text =
+ stringOf(
+ R.string.pokemon_list_poke_name_format,
+ pokemonDetail.pokemon.name,
+ pokemonDetail.pokemon.dexNumber,
+ )
+ }
+
+ val typesUiConfig =
+ TypeChip.PokemonTypeViewConfiguration(
+ width = LayoutParams.WRAP_CONTENT,
+ nameSize = resources.getDimensionPixelSize(R.dimen.pokemon_detail_pokemon_types_name_size),
+ iconSize = resources.getDimensionPixelSize(R.dimen.pokemon_detail_pokemon_types_icon_size),
+ hasBackGround = false,
+ )
+
+ pokemonTypesAdapter.addTypes(
+ types = pokemonDetail.pokemon.types,
+ config = typesUiConfig,
+ spacingBetweenTypes = 0.dp,
+ )
+ }
+
+ private fun toggleFloatingButtons() {
+ val rotateOpen: Animation = AnimationUtils.loadAnimation(this, R.anim.rotate_open)
+ val rotateClose: Animation = AnimationUtils.loadAnimation(this, R.anim.rotate_close)
+ val fromBottom: Animation = AnimationUtils.loadAnimation(this, R.anim.from_bottom)
+ val toBottom: Animation = AnimationUtils.loadAnimation(this, R.anim.to_bottom)
+
+ updateFloatingButtonsState()
+ with(binding) {
+ if (!isExpanded) {
+ fabPokemonDetailBattle.startAnimation(rotateOpen)
+ efabPokemonDetailBattleWithMine.startAnimation(fromBottom)
+ efabPokemonDetailBattleWithOpponent.startAnimation(fromBottom)
+ } else {
+ fabPokemonDetailBattle.startAnimation(rotateClose)
+ efabPokemonDetailBattleWithMine.startAnimation(toBottom)
+ efabPokemonDetailBattleWithOpponent.startAnimation(toBottom)
+ }
+ }
+
+ isExpanded = !isExpanded
+ }
+
+ private fun updateFloatingButtonsState() {
+ with(binding) {
+ if (isExpanded) {
+ efabPokemonDetailBattleWithMine.visibility = View.VISIBLE
+ efabPokemonDetailBattleWithOpponent.visibility = View.VISIBLE
+ } else {
+ efabPokemonDetailBattleWithMine.visibility = View.INVISIBLE
+ efabPokemonDetailBattleWithOpponent.visibility = View.INVISIBLE
+ }
+ }
+ }
+
+ companion object {
+ private const val POKEMON_ID = "pokemonId"
+ private const val IS_EXPANDED = "isExpanded"
+
+ val TAG: String = PokemonDetailActivity::class.java.simpleName
+
+ fun intent(
+ context: Context,
+ pokemonId: String,
+ ): Intent =
+ Intent(context, PokemonDetailActivity::class.java).apply {
+ putExtra(POKEMON_ID, pokemonId)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailNavigateHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailNavigateHandler.kt
new file mode 100644
index 00000000..581ff43c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailNavigateHandler.kt
@@ -0,0 +1,15 @@
+package poke.rogue.helper.presentation.dex.detail
+
+interface PokemonDetailNavigateHandler {
+ fun navigateToAbilityDetail(abilityId: String)
+
+ fun navigateToBiomeDetail(biomeId: String)
+
+ fun navigateToHome()
+
+ fun navigateToPokemonDetail(pokemonId: String)
+
+ fun navigateToBattleWithMine()
+
+ fun navigateToBattleWithOpponent()
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailPagerAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailPagerAdapter.kt
new file mode 100644
index 00000000..b996fd70
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailPagerAdapter.kt
@@ -0,0 +1,23 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import poke.rogue.helper.presentation.dex.detail.evolution.PokemonEvolutionFragment
+import poke.rogue.helper.presentation.dex.detail.information.PokemonInformationFragment
+import poke.rogue.helper.presentation.dex.detail.skill.PokemonDetailSkillFragment
+import poke.rogue.helper.presentation.dex.detail.stat.PokemonStatFragment
+
+class PokemonDetailPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
+ private val fragments =
+ listOf(
+ PokemonStatFragment(),
+ PokemonEvolutionFragment(),
+ PokemonDetailSkillFragment(),
+ PokemonInformationFragment(),
+ )
+
+ override fun getItemCount(): Int = fragments.size
+
+ override fun createFragment(position: Int): Fragment = fragments[position]
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailUiState.kt
new file mode 100644
index 00000000..1d3551da
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailUiState.kt
@@ -0,0 +1,52 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import poke.rogue.helper.data.model.Biome
+import poke.rogue.helper.data.model.PokemonDetail
+import poke.rogue.helper.data.model.PokemonDetailSkills
+import poke.rogue.helper.data.model.Stat
+import poke.rogue.helper.presentation.dex.model.EvolutionsUiModel
+import poke.rogue.helper.presentation.dex.model.PokemonBiomeUiModel
+import poke.rogue.helper.presentation.dex.model.PokemonDetailAbilityUiModel
+import poke.rogue.helper.presentation.dex.model.PokemonUiModel
+import poke.rogue.helper.presentation.dex.model.StatUiModel
+import poke.rogue.helper.presentation.dex.model.toPokemonDetailUi
+import poke.rogue.helper.presentation.dex.model.toUi
+
+sealed interface PokemonDetailUiState {
+ data class Success(
+ val pokemon: PokemonUiModel,
+ val stats: List,
+ val abilities: List,
+ val evolutions: EvolutionsUiModel,
+ val skills: PokemonDetailSkills,
+ val height: Float,
+ val weight: Float,
+ val biomes: List,
+ ) : PokemonDetailUiState
+
+ data object IsLoading : PokemonDetailUiState
+}
+
+fun PokemonDetail.toUi(allBiomes: List): PokemonDetailUiState.Success =
+ PokemonDetailUiState.Success(
+ pokemon = pokemon.toUi(),
+ stats = stats.map(Stat::toUi),
+ abilities = abilities.toPokemonDetailUi(),
+ evolutions = evolutions.toUi(),
+ skills = skills,
+ height = height.toFloat(),
+ weight = weight.toFloat(),
+ biomes = biomes.toUi(),
+ )
+
+fun PokemonDetail.toUi(): PokemonDetailUiState.Success =
+ PokemonDetailUiState.Success(
+ pokemon = pokemon.toUi(),
+ stats = stats.map(Stat::toUi),
+ abilities = abilities.toPokemonDetailUi(),
+ evolutions = evolutions.toUi(),
+ skills = skills,
+ height = height.toFloat(),
+ weight = weight.toFloat(),
+ biomes = biomes.toUi(),
+ )
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt
new file mode 100644
index 00000000..6e5cf406
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt
@@ -0,0 +1,100 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.DexRepository
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+import poke.rogue.helper.presentation.dex.logPokemonDetailToBattle
+import poke.rogue.helper.presentation.util.event.MutableEventFlow
+import poke.rogue.helper.presentation.util.event.asEventFlow
+
+class PokemonDetailViewModel(
+ private val dexRepository: DexRepository,
+ private val logger: AnalyticsLogger = analyticsLogger(),
+) :
+ ErrorHandleViewModel(logger),
+ PokemonDetailNavigateHandler {
+ private val _uiState: MutableStateFlow = MutableStateFlow(PokemonDetailUiState.IsLoading)
+ val uiState = _uiState.asStateFlow()
+
+ val isLoading: StateFlow =
+ uiState.map { it is PokemonDetailUiState.IsLoading }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), true)
+
+ private val _navigationToAbilityDetailEvent = MutableEventFlow()
+ val navigationToAbilityDetailEvent = _navigationToAbilityDetailEvent.asEventFlow()
+
+ private val _navigationToBiomeDetailEvent = MutableEventFlow()
+ val navigationToBiomeDetailEvent = _navigationToBiomeDetailEvent.asEventFlow()
+
+ private val _navigateToHomeEvent = MutableEventFlow()
+ val navigateToHomeEvent = _navigateToHomeEvent.asEventFlow()
+
+ private val _navigateToPokemonDetailEvent = MutableEventFlow()
+ val navigateToPokemonDetailEvent = _navigateToPokemonDetailEvent.asEventFlow()
+
+ private val _navigateToBattleEvent = MutableEventFlow()
+ val navigateToBattleEvent = _navigateToBattleEvent.asEventFlow()
+
+ fun updatePokemonDetail(pokemonId: String?) {
+ requireNotNull(pokemonId) { "Pokemon ID must not be null" }
+ viewModelScope.launch {
+ _uiState.value = dexRepository.pokemonDetail(pokemonId).toUi()
+ }
+ }
+
+ override fun navigateToAbilityDetail(abilityId: String) {
+ viewModelScope.launch {
+ _navigationToAbilityDetailEvent.emit(abilityId)
+ }
+ }
+
+ override fun navigateToBiomeDetail(biomeId: String) {
+ viewModelScope.launch {
+ _navigationToBiomeDetailEvent.emit(biomeId)
+ }
+ }
+
+ override fun navigateToHome() {
+ viewModelScope.launch {
+ _navigateToHomeEvent.emit(true)
+ }
+ }
+
+ override fun navigateToPokemonDetail(pokemonId: String) {
+ viewModelScope.launch {
+ _navigateToPokemonDetailEvent.emit(pokemonId)
+ }
+ }
+
+ override fun navigateToBattleWithMine() {
+ viewModelScope.launch {
+ val navigation = NavigateToBattleEvent.WithMyPokemon(pokemonUiModel())
+ _navigateToBattleEvent.emit(navigation)
+ logger.logPokemonDetailToBattle(navigation)
+ }
+ }
+
+ override fun navigateToBattleWithOpponent() {
+ viewModelScope.launch {
+ val navigation = NavigateToBattleEvent.WithOpponentPokemon(pokemonUiModel())
+ _navigateToBattleEvent.emit(navigation)
+ logger.logPokemonDetailToBattle(navigation)
+ }
+ }
+
+ private suspend fun pokemonUiModel() =
+ uiState
+ .filterIsInstance()
+ .first().pokemon
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionAdapter.kt
new file mode 100644
index 00000000..4e174dce
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionAdapter.kt
@@ -0,0 +1,42 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemPokemonDetailEvolutionBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class EvolutionAdapter(
+ private val onClickPokemon: PokemonDetailNavigateHandler,
+) : ListAdapter(evolutionComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): EvolutionViewHolder =
+ EvolutionViewHolder(
+ binding =
+ ItemPokemonDetailEvolutionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ navigateHandler = onClickPokemon,
+ )
+
+ override fun onBindViewHolder(
+ holder: EvolutionViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ private val evolutionComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.pokemonId == newItem.pokemonId },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionStageAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionStageAdapter.kt
new file mode 100644
index 00000000..41854239
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionStageAdapter.kt
@@ -0,0 +1,31 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.EvolutionsUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class EvolutionStageAdapter(
+ private val navigateHandler: PokemonDetailNavigateHandler,
+) : ListAdapter(comparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): EvolutionStageViewHolder = EvolutionStageViewHolder.inflated(parent, navigateHandler)
+
+ override fun onBindViewHolder(
+ holder: EvolutionStageViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val comparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.evolutions == newItem.evolutions },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionStageViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionStageViewHolder.kt
new file mode 100644
index 00000000..d085da6d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionStageViewHolder.kt
@@ -0,0 +1,35 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import poke.rogue.helper.databinding.ViewGroupPokemonEvolutionBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.EvolutionsUiModel
+
+class EvolutionStageViewHolder(
+ private val binding: ViewGroupPokemonEvolutionBinding,
+ private val navigateHandler: PokemonDetailNavigateHandler,
+) : ViewHolder(binding.root) {
+ private val evolutionAdapter by lazy { EvolutionAdapter(navigateHandler) }
+
+ fun bind(evolutionsUiModel: EvolutionsUiModel) {
+ binding.recyclerView.adapter = evolutionAdapter
+ evolutionsUiModel.evolutions.let(evolutionAdapter::submitList)
+ }
+
+ companion object {
+ fun inflated(
+ parent: ViewGroup,
+ navigateHandler: PokemonDetailNavigateHandler,
+ ) = EvolutionStageViewHolder(
+ binding =
+ ViewGroupPokemonEvolutionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ navigateHandler = navigateHandler,
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionViewHolder.kt
new file mode 100644
index 00000000..e21627c1
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/EvolutionViewHolder.kt
@@ -0,0 +1,55 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.view.View.GONE
+import android.widget.TextView
+import androidx.databinding.BindingAdapter
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ItemPokemonDetailEvolutionBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.detail.evolution.EvolutionViewHolder.Companion.level
+import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel
+import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.LEVEL_DOES_NOT_MATTER
+import poke.rogue.helper.presentation.util.context.stringOf
+
+class EvolutionViewHolder(
+ private val binding: ItemPokemonDetailEvolutionBinding,
+ private val navigateHandler: PokemonDetailNavigateHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(pokemonEvolutionUiModel: SingleEvolutionUiModel) {
+ binding.evolution = pokemonEvolutionUiModel
+ binding.onClickPokemon = navigateHandler
+ }
+
+ companion object {
+ @JvmStatic
+ @BindingAdapter("evolutionLevel")
+ fun TextView.level(level: Int) {
+ if (level == LEVEL_DOES_NOT_MATTER) {
+ visibility = GONE
+ return
+ }
+ text = context.stringOf(resId = R.string.pokemon_detail_evolution_level, level)
+ }
+
+ @JvmStatic
+ @BindingAdapter("item")
+ fun TextView.item(item: String?) {
+ if (item == null || item.contains("EMPTY") || item.contains("none") || item.isBlank()) {
+ visibility = GONE
+ return
+ }
+ text = item
+ }
+
+ @JvmStatic
+ @BindingAdapter("condition")
+ fun TextView.condition(condition: String?) {
+ if (condition == null || condition.contains("EMPTY") || condition.contains("none") || condition.isBlank()) {
+ visibility = GONE
+ return
+ }
+ text = condition
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragment.kt
new file mode 100644
index 00000000..b25b717b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragment.kt
@@ -0,0 +1,54 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.os.Bundle
+import android.view.View
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentPokemonEvolutionBinding
+import poke.rogue.helper.presentation.base.BindingFragment
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailUiState
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+
+class PokemonEvolutionFragment : BindingFragment(R.layout.fragment_pokemon_evolution) {
+ private val activityViewModel: PokemonDetailViewModel by activityViewModel()
+
+ private val evolutionStageAdapter by lazy {
+ EvolutionStageAdapter(activityViewModel)
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initObserver()
+ }
+
+ private fun initAdapter() {
+ binding.apply {
+ rvPokemonDetailEvolutions.adapter = evolutionStageAdapter
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ activityViewModel.uiState.collect { uiState ->
+ when (uiState) {
+ is PokemonDetailUiState.IsLoading -> {}
+ is PokemonDetailUiState.Success -> {
+ binding.evolutions = uiState.evolutions
+
+ uiState.evolutions.apply {
+ evolutionStageAdapter.submitList(
+ this.evolutions(),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionViewGroup.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionViewGroup.kt
new file mode 100644
index 00000000..60149ca2
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionViewGroup.kt
@@ -0,0 +1,30 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ViewGroupPokemonEvolutionBinding
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonEvolutionViewGroup
+ @JvmOverloads
+ constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyle: Int = 0,
+ ) : ConstraintLayout(context, attrs, defStyle) {
+ val recyclerView: RecyclerView
+ private val binding = ViewGroupPokemonEvolutionBinding.inflate(LayoutInflater.from(context), this, true)
+
+ init {
+ recyclerView = binding.recyclerView
+ recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
+ val itemDecoration =
+ LinearSpacingItemDecoration(spacing = 8.dp, orientation = LinearSpacingItemDecoration.Orientation.HORIZONTAL)
+ recyclerView.addItemDecoration(itemDecoration)
+ }
+ }
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeAdapter.kt
new file mode 100644
index 00000000..2f7b59e3
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeAdapter.kt
@@ -0,0 +1,42 @@
+package poke.rogue.helper.presentation.dex.detail.information
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemPokemonDetailInformationBiomeBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.PokemonBiomeUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class PokemonDetailBiomeAdapter(
+ private val onClickBiomeItem: PokemonDetailNavigateHandler,
+) : ListAdapter(biomeComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): PokemonDetailBiomeViewHolder =
+ PokemonDetailBiomeViewHolder(
+ binding =
+ ItemPokemonDetailInformationBiomeBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickBiomeItem = onClickBiomeItem,
+ )
+
+ override fun onBindViewHolder(
+ holder: PokemonDetailBiomeViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ val biomeComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeTypesAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeTypesAdapter.kt
new file mode 100644
index 00000000..7b03105d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeTypesAdapter.kt
@@ -0,0 +1,35 @@
+package poke.rogue.helper.presentation.dex.detail.information
+
+import android.content.Context
+import android.widget.ImageView
+import com.google.android.flexbox.FlexboxLayout
+import poke.rogue.helper.R
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+
+class PokemonDetailBiomeTypesAdapter(private val context: Context, private val viewGroup: FlexboxLayout) {
+ fun addTypes(types: List) {
+ viewGroup.removeAllViews()
+
+ types.forEach { type ->
+ val imageView =
+ ImageView(context).apply {
+ setImageResource(type.typeIconResId)
+
+ layoutParams =
+ FlexboxLayout.LayoutParams(
+ context.resources.getDimensionPixelSize(R.dimen.pokemon_detail_item_pokemon_biome_type_icon_size),
+ context.resources.getDimensionPixelSize(R.dimen.pokemon_detail_item_pokemon_biome_type_icon_size),
+ ).apply {
+ setMargins(
+ context.resources.getDimensionPixelSize(R.dimen.pokemon_detail_item_pokemon_biome_type_icon_spacing),
+ 0,
+ 0,
+ 0,
+ )
+ }
+ }
+
+ viewGroup.addView(imageView)
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeViewHolder.kt
new file mode 100644
index 00000000..a943a1e3
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeViewHolder.kt
@@ -0,0 +1,28 @@
+package poke.rogue.helper.presentation.dex.detail.information
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemPokemonDetailInformationBiomeBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.PokemonBiomeUiModel
+
+class PokemonDetailBiomeViewHolder(
+ private val binding: ItemPokemonDetailInformationBiomeBinding,
+ private val onClickBiomeItem: PokemonDetailNavigateHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(biome: PokemonBiomeUiModel) {
+ binding.apply {
+ this.biome = biome
+ uiEventHandler = onClickBiomeItem
+ }
+
+ val typesLayout = binding.flBiomeTypeIcons
+ val biomeTypesAdapter =
+ PokemonDetailBiomeTypesAdapter(
+ context = binding.root.context,
+ viewGroup = typesLayout,
+ )
+ biomeTypesAdapter.addTypes(
+ types = biome.types,
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragment.kt
new file mode 100644
index 00000000..0dc4f364
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragment.kt
@@ -0,0 +1,57 @@
+package poke.rogue.helper.presentation.dex.detail.information
+
+import android.os.Bundle
+import android.view.View
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentPokemonInformationBinding
+import poke.rogue.helper.presentation.base.BindingFragment
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailUiState
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration
+
+class PokemonInformationFragment :
+ BindingFragment(R.layout.fragment_pokemon_information) {
+ private val activityViewModel: PokemonDetailViewModel by activityViewModel()
+ private val biomesAdapter: PokemonDetailBiomeAdapter by lazy { PokemonDetailBiomeAdapter(activityViewModel) }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initObserver()
+ }
+
+ private fun initAdapter() {
+ val spanCount = resources.getInteger(R.integer.pokemon_detail_item_pokemon_biome_span_count)
+ val spacing = resources.getDimensionPixelSize(R.dimen.pokemon_detail_item_pokemon_biome_spacing)
+
+ binding.rvPokemonDetailInformation.apply {
+ adapter = biomesAdapter
+ addItemDecoration(GridSpacingItemDecoration(spanCount, spacing, false))
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ activityViewModel.uiState.collect { pokemonDetailUiState ->
+ when (pokemonDetailUiState) {
+ is PokemonDetailUiState.IsLoading -> {}
+ is PokemonDetailUiState.Success -> bindPokemonInformation(pokemonDetailUiState)
+ }
+ }
+ }
+ }
+
+ private fun bindPokemonInformation(pokemonDetail: PokemonDetailUiState.Success) {
+ binding.apply {
+ height = pokemonDetail.height
+ weight = pokemonDetail.weight
+ }
+ biomesAdapter.submitList(pokemonDetail.biomes)
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillAdapter.kt
new file mode 100644
index 00000000..cc6ac52b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillAdapter.kt
@@ -0,0 +1,37 @@
+package poke.rogue.helper.presentation.dex.detail.skill
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemPokemonDetailSkillBinding
+import poke.rogue.helper.presentation.dex.model.PokemonSkillUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class PokemonDetailSkillAdapter : ListAdapter(skillsComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): PokemonDetailSkillViewHolder =
+ PokemonDetailSkillViewHolder(
+ ItemPokemonDetailSkillBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ )
+
+ override fun onBindViewHolder(
+ holder: PokemonDetailSkillViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ private val skillsComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragment.kt
new file mode 100644
index 00000000..79d59d91
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragment.kt
@@ -0,0 +1,83 @@
+package poke.rogue.helper.presentation.dex.detail.skill
+
+import android.os.Bundle
+import android.view.View
+import com.google.android.material.divider.MaterialDividerItemDecoration
+import com.google.android.material.divider.MaterialDividerItemDecoration.VERTICAL
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentPokemonSkillsBinding
+import poke.rogue.helper.presentation.base.BindingFragment
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailUiState
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel
+import poke.rogue.helper.presentation.dex.model.toUi
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonDetailSkillFragment : BindingFragment(R.layout.fragment_pokemon_skills) {
+ private val activityViewModel: PokemonDetailViewModel by activityViewModel()
+
+ private val eggSkillsAdapter: PokemonDetailSkillAdapter by lazy { PokemonDetailSkillAdapter() }
+ private val skillsAdapter: PokemonDetailSkillAdapter by lazy { PokemonDetailSkillAdapter() }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initAdapter() {
+ binding.rvPokemonDetailSkills.apply {
+ adapter = skillsAdapter
+ val spacingItemDecoration =
+ LinearSpacingItemDecoration(
+ spacing = 8.dp,
+ includeEdge = true,
+ orientation = LinearSpacingItemDecoration.Orientation.VERTICAL,
+ )
+ val dividerItemDecoration =
+ MaterialDividerItemDecoration(
+ context,
+ VERTICAL,
+ )
+ addItemDecoration(spacingItemDecoration)
+ addItemDecoration(dividerItemDecoration)
+ }
+
+ binding.rvPokemonDetailEggSkills.apply {
+ adapter = eggSkillsAdapter
+
+ val spacingItemDecoration =
+ LinearSpacingItemDecoration(
+ spacing = 8.dp,
+ includeEdge = true,
+ orientation = LinearSpacingItemDecoration.Orientation.VERTICAL,
+ )
+ val dividerItemDecoration =
+ MaterialDividerItemDecoration(
+ context,
+ VERTICAL,
+ )
+ addItemDecoration(spacingItemDecoration)
+ addItemDecoration(dividerItemDecoration)
+ }
+ }
+
+ private fun initObservers() {
+ repeatOnStarted {
+ activityViewModel.uiState.collect { state ->
+ when (state) {
+ is PokemonDetailUiState.IsLoading -> {}
+ is PokemonDetailUiState.Success -> {
+ eggSkillsAdapter.submitList(state.skills.eggLearn.toUi())
+ skillsAdapter.submitList(state.skills.selfLearn.toUi())
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillViewHolder.kt
new file mode 100644
index 00000000..7e812226
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillViewHolder.kt
@@ -0,0 +1,13 @@
+package poke.rogue.helper.presentation.dex.detail.skill
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.databinding.ItemPokemonDetailSkillBinding
+import poke.rogue.helper.presentation.dex.model.PokemonSkillUiModel
+
+class PokemonDetailSkillViewHolder(
+ private val binding: ItemPokemonDetailSkillBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(skill: PokemonSkillUiModel) {
+ binding.skill = skill
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/AbilityTitleAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/AbilityTitleAdapter.kt
new file mode 100644
index 00000000..4033b626
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/AbilityTitleAdapter.kt
@@ -0,0 +1,40 @@
+package poke.rogue.helper.presentation.dex.detail.stat
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import poke.rogue.helper.databinding.ItemAbilityTitleBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.PokemonDetailAbilityUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class AbilityTitleAdapter(private val onClickAbility: PokemonDetailNavigateHandler) :
+ ListAdapter(abilityComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): AbilityTitleViewHolder =
+ AbilityTitleViewHolder(
+ ItemAbilityTitleBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ onClickAbility,
+ )
+
+ override fun onBindViewHolder(
+ viewHolder: AbilityTitleViewHolder,
+ position: Int,
+ ) {
+ viewHolder.bind(getItem(position))
+ }
+
+ companion object {
+ private val abilityComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/AbilityTitleViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/AbilityTitleViewHolder.kt
new file mode 100644
index 00000000..495a68ef
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/AbilityTitleViewHolder.kt
@@ -0,0 +1,43 @@
+package poke.rogue.helper.presentation.dex.detail.stat
+
+import android.graphics.Typeface
+import android.widget.TextView
+import androidx.databinding.BindingAdapter
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.ItemAbilityTitleBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+import poke.rogue.helper.presentation.dex.model.PokemonDetailAbilityUiModel
+
+class AbilityTitleViewHolder(
+ private val binding: ItemAbilityTitleBinding,
+ private val onClickAbility: PokemonDetailNavigateHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(ability: PokemonDetailAbilityUiModel) {
+ binding.ability = ability
+ binding.onClickAbility = onClickAbility
+ }
+
+ companion object {
+ @JvmStatic
+ @BindingAdapter("passive")
+ fun TextView.passive(passive: Boolean) {
+ if (passive) {
+ setTypeface(null, Typeface.BOLD)
+ setTextColor(context.getColor(R.color.poke_electric))
+ } else {
+ setTypeface(null, Typeface.NORMAL)
+ }
+ }
+
+ @JvmStatic
+ @BindingAdapter("hidden")
+ fun TextView.hidden(hidden: Boolean) {
+ if (hidden) {
+ setTypeface(null, Typeface.BOLD)
+ } else {
+ setTypeface(null, Typeface.NORMAL)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatAdapter.kt
new file mode 100644
index 00000000..e4095751
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatAdapter.kt
@@ -0,0 +1,46 @@
+package poke.rogue.helper.presentation.dex.detail.stat
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import poke.rogue.helper.databinding.ItemStatBinding
+import poke.rogue.helper.presentation.dex.model.StatUiModel
+import poke.rogue.helper.presentation.util.view.ItemDiffCallback
+
+class PokemonStatAdapter :
+ ListAdapter(statComparator) {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): PokemonStatViewHolder =
+ PokemonStatViewHolder(
+ ItemStatBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
+ )
+
+ override fun onBindViewHolder(
+ viewHolder: PokemonStatViewHolder,
+ position: Int,
+ ) {
+ viewHolder.bind(getItem(position))
+ }
+
+ class PokemonStatViewHolder(private val binding: ItemStatBinding) :
+ ViewHolder(binding.root) {
+ fun bind(stat: StatUiModel) {
+ binding.stat = stat
+ }
+ }
+
+ companion object {
+ private val statComparator =
+ ItemDiffCallback(
+ onItemsTheSame = { oldItem, newItem -> oldItem.name == newItem.name },
+ onContentsTheSame = { oldItem, newItem -> oldItem == newItem },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragment.kt
new file mode 100644
index 00000000..67399151
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragment.kt
@@ -0,0 +1,96 @@
+package poke.rogue.helper.presentation.dex.detail.stat
+
+import android.graphics.drawable.ClipDrawable
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
+import android.os.Bundle
+import android.view.Gravity
+import android.view.View
+import android.widget.ProgressBar
+import androidx.databinding.BindingAdapter
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.FragmentPokemonStatBinding
+import poke.rogue.helper.presentation.base.BindingFragment
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailUiState
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel
+import poke.rogue.helper.presentation.util.context.colorOf
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonStatFragment : BindingFragment(R.layout.fragment_pokemon_stat) {
+ private val activityViewModel: PokemonDetailViewModel by activityViewModel()
+
+ private val abilityAdapter by lazy { AbilityTitleAdapter(activityViewModel) }
+ private val pokemonStatAdapter by lazy { PokemonStatAdapter() }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.eventHandler = activityViewModel
+
+ initAdapter()
+ repeatOnStarted {
+ activityViewModel.uiState.collect { uiState ->
+ when (uiState) {
+ is PokemonDetailUiState.IsLoading -> return@collect
+ is PokemonDetailUiState.Success -> bindData(uiState)
+ }
+ }
+ }
+ }
+
+ private fun initAdapter() {
+ binding.apply {
+ rvPokemonAbilities.adapter = abilityAdapter
+ rvPokemonStats.adapter = pokemonStatAdapter
+
+ rvPokemonAbilities.addItemDecoration(
+ LinearSpacingItemDecoration(
+ spacing = 7.dp,
+ includeEdge = false,
+ orientation = LinearSpacingItemDecoration.Orientation.HORIZONTAL,
+ ),
+ )
+ }
+ }
+
+ private fun bindData(uiState: PokemonDetailUiState.Success) {
+ binding.apply {
+ pokemonStatAdapter.submitList(uiState.stats)
+ abilityAdapter.submitList(uiState.abilities)
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ @BindingAdapter("progressColor")
+ fun ProgressBar.setProgressDrawable(color: Int) {
+ val background =
+ GradientDrawable().apply {
+ setColor(context.colorOf(R.color.poke_grey_20))
+ cornerRadius = resources.getDimension(R.dimen.progress_bar_corner_radius)
+ }
+
+ val progress =
+ GradientDrawable().apply {
+ setColor(context.colorOf(color))
+ cornerRadius = resources.getDimension(R.dimen.progress_bar_corner_radius)
+ }
+
+ val clipDrawable = ClipDrawable(progress, Gravity.START, ClipDrawable.HORIZONTAL)
+
+ val layerDrawable =
+ LayerDrawable(arrayOf(background, clipDrawable)).apply {
+ setId(0, android.R.id.background)
+ setId(1, android.R.id.progress)
+ }
+
+ progressDrawable = layerDrawable
+ }
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt
new file mode 100644
index 00000000..6d81d9eb
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiEvent.kt
@@ -0,0 +1,14 @@
+package poke.rogue.helper.presentation.dex.filter
+
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+
+sealed interface PokeFilterUiEvent {
+ data object IDLE : PokeFilterUiEvent
+
+ data class ApplyFiltering(
+ val selectedTypes: List,
+ val generation: PokeGenerationUiModel,
+ ) : PokeFilterUiEvent
+
+ data object CloseFilter : PokeFilterUiEvent
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt
new file mode 100644
index 00000000..72e0d718
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiModel.kt
@@ -0,0 +1,11 @@
+package poke.rogue.helper.presentation.dex.filter
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+
+@Parcelize
+data class PokeFilterUiModel(
+ val selectedTypes: List,
+ val selectedGeneration: PokeGenerationUiModel,
+) : Parcelable
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt
new file mode 100644
index 00000000..c08a083d
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterUiState.kt
@@ -0,0 +1,60 @@
+package poke.rogue.helper.presentation.dex.filter
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+
+@Parcelize
+data class PokeFilterUiState(
+ val types: List>,
+ val generations: List>,
+ val selectedTypes: List = emptyList(),
+) : Parcelable {
+ init {
+ require(generations.any { it.isSelected }) {
+ "์ ์ด๋ ํ๋์ ์ธ๋๊ฐ ์ ํ๋์ด์ผ ํฉ๋๋ค."
+ }
+ require(generations.size == PokeGenerationUiModel.entries.size) {
+ "์ธ๋์ ํฌ๊ธฐ๋ ${PokeGenerationUiModel.entries.size}์ฌ์ผ ํฉ๋๋ค."
+ }
+ require(types.size == TypeUiModel.entries.size) {
+ "ํ์
์ ํฌ๊ธฐ๋ ${TypeUiModel.entries.size}์ฌ์ผ ํฉ๋๋ค."
+ }
+ require(types.count { it.isSelected } <= 2) {
+ "์ต๋ 2๊ฐ์ ํ์
๋ง ์ ํํ ์ ์์ต๋๋ค."
+ }
+ }
+
+ val selectedGeneration: PokeGenerationUiModel
+ get() = generations.first { it.isSelected }.data
+
+ companion object {
+ val DEFAULT =
+ PokeFilterUiState(
+ types =
+ TypeUiModel.entries.mapIndexed { index, typeUiModel ->
+ SelectableUiModel(
+ index,
+ false,
+ typeUiModel,
+ )
+ },
+ generations =
+ PokeGenerationUiModel.entries.mapIndexed { index, pokeGenerationUiModel ->
+ if (pokeGenerationUiModel == PokeGenerationUiModel.ALL) {
+ SelectableUiModel(
+ index,
+ true,
+ pokeGenerationUiModel,
+ )
+ } else {
+ SelectableUiModel(
+ index,
+ false,
+ pokeGenerationUiModel,
+ )
+ }
+ },
+ )
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt
new file mode 100644
index 00000000..fcd1f868
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeFilterViewModel.kt
@@ -0,0 +1,141 @@
+package poke.rogue.helper.presentation.dex.filter
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.util.event.EventFlow
+import poke.rogue.helper.presentation.util.event.MutableEventFlow
+import poke.rogue.helper.presentation.util.event.asEventFlow
+
+class PokeFilterViewModel(
+ private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ val uiState: StateFlow =
+ savedStateHandle.getStateFlow(
+ UI_STATE_KEY,
+ PokeFilterUiState.DEFAULT,
+ )
+
+ private val _uiEvent = MutableEventFlow()
+ val uiEvent: EventFlow = _uiEvent.asEventFlow()
+
+ fun init(args: PokeFilterUiModel) {
+ savedStateHandle[UI_STATE_KEY] =
+ PokeFilterUiState(
+ types =
+ TypeUiModel.entries.mapIndexed { index, typeUiModel ->
+ SelectableUiModel(
+ index,
+ args.selectedTypes.contains(typeUiModel),
+ typeUiModel,
+ )
+ },
+ generations =
+ PokeGenerationUiModel.entries.mapIndexed { index, pokeGenerationUiModel ->
+ SelectableUiModel(
+ index,
+ args.selectedGeneration == pokeGenerationUiModel,
+ pokeGenerationUiModel,
+ )
+ },
+ selectedTypes = args.selectedTypes,
+ )
+ }
+
+ fun selectType(id: Int) {
+ val selectedTypes = uiState.value.selectedTypes
+ val types = uiState.value.types
+ if (selectedTypes.size < LIMIT_TYPE_COUNT) {
+ return selectTypeWithinLimit(id, types, selectedTypes)
+ }
+ if (selectedTypes.any { it.id == id }) {
+ selectTypeWithinLimit(id, types, selectedTypes)
+ return
+ }
+ selectTypeExceedingLimit(id, types, selectedTypes)
+ }
+
+ private fun selectTypeWithinLimit(
+ id: Int,
+ types: List>,
+ selectedTypes: List,
+ ) {
+ var newSelectedTypes = selectedTypes
+ val newTypes =
+ types.map { type ->
+ if (type.id == id) {
+ newSelectedTypes =
+ if (type.isSelected) {
+ selectedTypes - type.data
+ } else {
+ selectedTypes + type.data
+ }
+ return@map type.copy(isSelected = !type.isSelected)
+ }
+ type
+ }
+ savedStateHandle[UI_STATE_KEY] =
+ uiState.value.copy(types = newTypes, selectedTypes = newSelectedTypes)
+ }
+
+ private fun selectTypeExceedingLimit(
+ id: Int,
+ types: List>,
+ selectedTypes: List,
+ ) {
+ var newSelectedTypes = selectedTypes
+ val firstSelectedType = selectedTypes.first()
+ val newTypes =
+ types.map { type ->
+ if (type.data == firstSelectedType) {
+ return@map type.copy(isSelected = false)
+ }
+ if (type.id == id) {
+ newSelectedTypes = selectedTypes.drop(1) + type.data
+ return@map type.copy(isSelected = !type.isSelected)
+ }
+ type
+ }
+ savedStateHandle[UI_STATE_KEY] =
+ uiState.value.copy(types = newTypes, selectedTypes = newSelectedTypes)
+ }
+
+ fun toggleGeneration(generationId: Int) {
+ val generations = uiState.value.generations
+ if (generations[generationId].isSelected) return
+ val newGenerations =
+ uiState.value.generations.map { type ->
+ if (type.id == generationId) {
+ type.copy(isSelected = !type.isSelected)
+ } else {
+ type.copy(isSelected = false)
+ }
+ }
+ savedStateHandle[UI_STATE_KEY] = uiState.value.copy(generations = newGenerations)
+ }
+
+ fun applyFiltering() {
+ viewModelScope.launch {
+ _uiEvent.emit(
+ PokeFilterUiEvent.ApplyFiltering(
+ selectedTypes = uiState.value.selectedTypes,
+ generation = uiState.value.selectedGeneration,
+ ),
+ )
+ }
+ }
+
+ fun closeFilter() {
+ viewModelScope.launch {
+ _uiEvent.emit(PokeFilterUiEvent.CloseFilter)
+ }
+ }
+
+ companion object {
+ private const val UI_STATE_KEY = "uiState"
+ private const val LIMIT_TYPE_COUNT: Int = 2
+ }
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt
new file mode 100644
index 00000000..71c6e3f9
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokeGenerationUiModel.kt
@@ -0,0 +1,26 @@
+package poke.rogue.helper.presentation.dex.filter
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import poke.rogue.helper.data.model.PokemonGeneration
+
+@Parcelize
+enum class PokeGenerationUiModel(val number: Int) : Parcelable {
+ ALL(0),
+ ONE(1),
+ TWO(2),
+ THREE(3),
+ FOUR(4),
+ FIVE(5),
+ SIX(6),
+ SEVEN(7),
+ EIGHT(8),
+ NINE(9),
+}
+
+fun PokeGenerationUiModel.toDataOrNull(): PokemonGeneration? {
+ if (this == PokeGenerationUiModel.ALL) {
+ return null
+ }
+ return PokemonGeneration.of(number)
+}
diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt
new file mode 100644
index 00000000..8bf28f49
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/PokemonFilterBottomSheetFragment.kt
@@ -0,0 +1,152 @@
+package poke.rogue.helper.presentation.dex.filter
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.os.bundleOf
+import androidx.fragment.app.setFragmentResult
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.SavedStateViewModelFactory
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import poke.rogue.helper.R
+import poke.rogue.helper.databinding.BottomSheetPokemonFilterBinding
+import poke.rogue.helper.presentation.dex.PokemonListActivity.Companion.FILTER_RESULT_KEY
+import poke.rogue.helper.presentation.type.model.TypeUiModel
+import poke.rogue.helper.presentation.util.fragment.stringOf
+import poke.rogue.helper.presentation.util.parcelable
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.dp
+import poke.rogue.helper.ui.component.PokeChip
+
+class PokemonFilterBottomSheetFragment : BottomSheetDialogFragment() {
+ private var _binding: BottomSheetPokemonFilterBinding? = null
+ private val binding get() = requireNotNull(_binding)
+ private val viewModel by viewModels {
+ SavedStateViewModelFactory(
+ requireActivity().application,
+ this,
+ )
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = BottomSheetPokemonFilterBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+ if (arguments != null && savedInstanceState == null) {
+ val args = requireNotNull(argsFrom(requireArguments()))
+ viewModel.init(args)
+ }
+
+ observeUiState()
+ observeEvents()
+ }
+
+ private fun observeUiState() {
+ repeatOnStarted {
+ viewModel.uiState.collect {
+ binding.chipGroupPokeFilterType.submitList(
+ it.types.map { selectableType ->
+ PokeChip.Spec(
+ selectableType.id,
+ "",
+ leadingIconRes = selectableType.data.typeIconResId,
+ sizes =
+ PokeChip.Sizes(
+ leadingIconSize = 28.dp,
+ ),
+ colors =
+ PokeChip.Colors(
+ selectedContainerColor = selectableType.data.typeColor,
+ ),
+ isSelected = selectableType.isSelected,
+ onSelect = viewModel::selectType,
+ )
+ },
+ )
+ binding.chipGroupPokeFilterGeneration.submitList(
+ it.generations.map { selectableGeneration ->
+ val generationText =
+ if (selectableGeneration.data == PokeGenerationUiModel.ALL) {
+ stringOf(R.string.dex_filter_all_generations)
+ } else {
+ stringOf(
+ R.string.dex_filter_generation_format,
+ selectableGeneration.data.number,
+ )
+ }
+ PokeChip.Spec(
+ selectableGeneration.id,
+ generationText,
+ isSelected = selectableGeneration.isSelected,
+ onSelect = viewModel::toggleGeneration,
+ )
+ },
+ )
+ }
+ }
+ }
+
+ private fun observeEvents() {
+ repeatOnStarted {
+ viewModel.uiEvent.collect { event ->
+ when (event) {
+ is PokeFilterUiEvent.CloseFilter -> dismiss()
+ is PokeFilterUiEvent.ApplyFiltering -> {
+ val args = PokeFilterUiModel(event.selectedTypes, event.generation)
+ setFragmentResult(
+ FILTER_RESULT_KEY,
+ bundleOf(ARGS_KEY to args),
+ )
+ dismiss()
+ }
+
+ is PokeFilterUiEvent.IDLE -> Unit
+ }
+ }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val behavior = BottomSheetBehavior.from(requireView().parent as View)
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ val TAG: String = PokemonFilterBottomSheetFragment::class.java.simpleName
+ private const val ARGS_KEY = "PokemonFilterBottomSheetFragment_args_key"
+
+ fun argsFrom(result: Bundle): PokeFilterUiModel? {
+ return result.parcelable