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(ARGS_KEY) + } + + fun newInstance( + selectedTypes: List = emptyList(), + selectedGeneration: PokeGenerationUiModel = PokeGenerationUiModel.ALL, + ): PokemonFilterBottomSheetFragment { + return PokemonFilterBottomSheetFragment().apply { + arguments = + bundleOf(ARGS_KEY to PokeFilterUiModel(selectedTypes, selectedGeneration)) + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt new file mode 100644 index 00000000..b95a3d80 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt @@ -0,0 +1,22 @@ +package poke.rogue.helper.presentation.dex.filter + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SelectableUiModel( + val id: Int, + val isSelected: Boolean, + val data: T, +) : Parcelable + +fun List.initialized(): List> { + return this.mapIndexed { index, t -> SelectableUiModel(index, false, t) } +} + +fun List.toSelectableModelsBy(predicate: (T) -> Boolean): List> { + return this.mapIndexed { index, t -> + val isSelected = predicate(t) + SelectableUiModel(id = index, isSelected = isSelected, t) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModel.kt new file mode 100644 index 00000000..51e82012 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModel.kt @@ -0,0 +1,77 @@ +package poke.rogue.helper.presentation.dex.model + +import poke.rogue.helper.data.model.Evolution +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_ALOLA_RAICHU +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_EEVEE +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_ESPEON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_FLAREON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_GIGA_PIKACHU +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_GLACEON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_GOLDUCK +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_JOLTEON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_LEAFEON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_PICHU +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_PIKACHU +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_PSYDUCK +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_RAICHU +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_SYLYEON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_UMBREON +import poke.rogue.helper.presentation.dex.model.SingleEvolutionUiModel.Companion.DUMMY_VAPOREON + +data class EvolutionsUiModel( + val evolutions: List, +) { + constructor(vararg evolutions: SingleEvolutionUiModel) : this(evolutions.toList()) + + fun evolutions(): List = listOf(evolutions(0), evolutions(1), evolutions(2), evolutions(3)) + + private fun evolutions(depth: Int): EvolutionsUiModel = + EvolutionsUiModel( + evolutions.filter { + it.depth == depth + }, + ) + + fun hasEvolutionChain(): Boolean = evolutions.size > 1 + + companion object { + val DUMMY_PICAKCHU_EVOLUTION = + EvolutionsUiModel( + evolutions = + listOf( + DUMMY_PICHU, + DUMMY_PIKACHU, + DUMMY_RAICHU, + DUMMY_ALOLA_RAICHU, + DUMMY_GIGA_PIKACHU, + ), + ) + + val DUMMY_PSYDUCK_EVOLUTION = + EvolutionsUiModel( + evolutions = + listOf( + DUMMY_PSYDUCK, + DUMMY_GOLDUCK, + ), + ) + + val DUMMY_EVE_EVOLUTION = + EvolutionsUiModel( + evolutions = + listOf( + DUMMY_EEVEE, + DUMMY_SYLYEON, + DUMMY_ESPEON, + DUMMY_UMBREON, + DUMMY_VAPOREON, + DUMMY_JOLTEON, + DUMMY_FLAREON, + DUMMY_LEAFEON, + DUMMY_GLACEON, + ), + ) + } +} + +fun List.toUi(): EvolutionsUiModel = EvolutionsUiModel(evolutions = map(Evolution::toUi)) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonBiomeUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonBiomeUiModel.kt new file mode 100644 index 00000000..dd663c2c --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonBiomeUiModel.kt @@ -0,0 +1,22 @@ +package poke.rogue.helper.presentation.dex.model + +import poke.rogue.helper.data.model.PokemonBiome +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.type.model.toUi + +data class PokemonBiomeUiModel( + val id: String, + val name: String, + val imageUrl: String, + val types: List, +) + +fun PokemonBiome.toUi(): PokemonBiomeUiModel = + PokemonBiomeUiModel( + id = id, + name = name, + imageUrl = imageUrl, + types = pokemonType.toUi(), + ) + +fun List.toUi(): List = this.map(PokemonBiome::toUi) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonDetailAbilityUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonDetailAbilityUiModel.kt new file mode 100644 index 00000000..372be2c5 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonDetailAbilityUiModel.kt @@ -0,0 +1,20 @@ +package poke.rogue.helper.presentation.dex.model + +import poke.rogue.helper.data.model.PokemonDetailAbility + +data class PokemonDetailAbilityUiModel( + val id: String, + val name: String, + val passive: Boolean, + val hidden: Boolean, +) + +fun PokemonDetailAbility.toPokemonDetailUi(): PokemonDetailAbilityUiModel = + PokemonDetailAbilityUiModel( + id = id, + name = name, + passive = passive, + hidden = hidden, + ) + +fun List.toPokemonDetailUi(): List = map(PokemonDetailAbility::toPokemonDetailUi) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonSkillUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonSkillUiModel.kt new file mode 100644 index 00000000..1ebd1926 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonSkillUiModel.kt @@ -0,0 +1,34 @@ +package poke.rogue.helper.presentation.dex.model + +import poke.rogue.helper.data.model.PokemonSkill +import poke.rogue.helper.data.model.SkillCategory +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.type.model.toUi + +data class PokemonSkillUiModel( + val id: String, + val name: String, + val level: Int, + val power: String, + val type: TypeUiModel, + val accuracy: String, + val category: SkillCategory, +) { + companion object { + const val NO_POWER = "-" + const val NO_ACCURACY = "-" + } +} + +fun PokemonSkill.toUi(): PokemonSkillUiModel = + PokemonSkillUiModel( + id = id, + name = name, + level = level, + power = if (power == PokemonSkill.NO_POWER_VALUE) PokemonSkillUiModel.NO_POWER else power.toString(), + type = type.toUi(), + accuracy = if (accuracy == PokemonSkill.NO_ACCURACY_VALUE) PokemonSkillUiModel.NO_ACCURACY else accuracy.toString(), + category = category, + ) + +fun List.toUi(): List = map(PokemonSkill::toUi) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonUiModel.kt new file mode 100644 index 00000000..97a368d2 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/PokemonUiModel.kt @@ -0,0 +1,59 @@ +package poke.rogue.helper.presentation.dex.model + +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.presentation.dex.sort.PokemonSortUiModel +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.type.model.toUi + +data class PokemonUiModel( + val id: String = "", + val hashId: Long = 0, + val dexNumber: Long = 0, + val name: String, + val formName: String = "", + val imageUrl: String, + val types: List, + val baseStats: Int = 0, + val speed: Int = 0, + val hp: Int = 0, + val attack: Int = 0, + val defense: Int = 0, + val specialAttack: Int = 0, + val specialDefense: Int = 0, + private val sortUiModel: PokemonSortUiModel = PokemonSortUiModel.ByDexNumber, +) { + val displayStat: Int + get() = + when (sortUiModel) { + PokemonSortUiModel.ByBaseStat -> baseStats + PokemonSortUiModel.BySpeed -> speed + PokemonSortUiModel.ByHp -> hp + PokemonSortUiModel.ByAttack -> attack + PokemonSortUiModel.ByDefense -> defense + PokemonSortUiModel.BySpecialAttack -> specialAttack + PokemonSortUiModel.BySpecialDefense -> specialDefense + else -> 0 + } +} + +fun Pokemon.toUi(): PokemonUiModel = + PokemonUiModel( + id = id, + dexNumber = dexNumber, + name = name, + formName = formName, + imageUrl = imageUrl, + types = types.toUi(), + baseStats = baseStat, + speed = speed, + hp = hp, + attack = attack, + defense = defense, + specialAttack = specialAttack, + specialDefense = specialDefense, + ) + +fun List.toUi(): List = + mapIndexed { index, pokemon -> + pokemon.toUi().copy(hashId = index.toLong()) + } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/SingleEvolutionUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/SingleEvolutionUiModel.kt new file mode 100644 index 00000000..d694ab22 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/SingleEvolutionUiModel.kt @@ -0,0 +1,177 @@ +package poke.rogue.helper.presentation.dex.model + +import poke.rogue.helper.data.model.Evolution + +data class SingleEvolutionUiModel( + val pokemonId: String, + val pokemonName: String, + val imageUrl: String, + val depth: Int, + val level: Int = LEVEL_DOES_NOT_MATTER, + val item: String? = null, + val condition: String? = null, +) { + companion object { + const val LEVEL_DOES_NOT_MATTER = 1 + + val DUMMY_PICHU = + SingleEvolutionUiModel( + pokemonId = "pichu", + pokemonName = "ํ”ผ์ธ„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/172.png", + depth = 0, + ) + + val DUMMY_PIKACHU = + SingleEvolutionUiModel( + pokemonId = "pikachu{Normal}", + pokemonName = "ํ”ผ์นด์ธ„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png", + depth = 1, + condition = "์นœ๋ฐ€๋„ 90", + ) + + val DUMMY_RAICHU = + SingleEvolutionUiModel( + pokemonId = "raichu{Normal}", + pokemonName = "๋ผ์ด์ธ„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/26.png", + depth = 2, + item = "์ฒœ๋‘ฅ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + ) + + val DUMMY_ALOLA_RAICHU = + SingleEvolutionUiModel( + pokemonId = "raichu{Alola}", + pokemonName = "๋ผ์ด์ธ„{์•Œ๋กœ๋ผ}", + imageUrl = "https://data1.pokemonkorea.co.kr/newdata/pokedex/full/002602.png", + depth = 2, + item = "์ฒœ๋‘ฅ์˜ ๋Œ", + condition = "์„ฌ, ํ•ด๋ณ€์—์„œ ์•„์ดํ…œ ์‚ฌ์šฉ", + ) + + val DUMMY_GIGA_PIKACHU = + SingleEvolutionUiModel( + pokemonId = "pikachu{G-Max} ", + pokemonName = "ํ”ผ์นด์ธ„{G-Max}", + imageUrl = "https://data1.pokemonkorea.co.kr/newdata/pokedex/full/002502.png", + depth = 2, + item = "๋‹ค์ด ๋งฅ์Šค ๋ฒ„์„ฏ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + ) + + val DUMMY_PSYDUCK = + SingleEvolutionUiModel( + pokemonId = "psyduck", + pokemonName = "๊ณ ๋ผํŒŒ๋•", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/54.png", + depth = 0, + ) + + val DUMMY_GOLDUCK = + SingleEvolutionUiModel( + pokemonId = "golduck", + pokemonName = "๊ณจ๋•", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/55.png", + level = 33, + depth = 1, + ) + + val DUMMY_EEVEE = + SingleEvolutionUiModel( + pokemonId = "eevee{Normal}", + pokemonName = "์ด๋ธŒ์ด", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/133.png", + depth = 0, + ) + + val DUMMY_SYLYEON = + SingleEvolutionUiModel( + pokemonId = "sylveon", + pokemonName = "๋‹˜ํ”ผ์•„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/700.png", + condition = "์นœ๋ฐ€๋„ 70 \n+ ํŽ˜์–ด๋ฆฌ ํƒ€์ž… ๊ธฐ์ˆ  ์Šต๋“", + depth = 1, + ) + + val DUMMY_ESPEON = + SingleEvolutionUiModel( + pokemonId = "espeon", + pokemonName = "์—๋ธŒ์ด", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/196.png", + condition = "์นœ๋ฐ€๋„ 70 \n+ ๋‚ฎ์— ๋ ˆ๋ฒจ์—…", + depth = 1, + ) + + val DUMMY_UMBREON = + SingleEvolutionUiModel( + pokemonId = "umbreon", + pokemonName = "๋ธ”๋ž˜ํ‚ค", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/197.png", + condition = "์นœ๋ฐ€๋„ 70 \n+ ๋ฐค์— ๋ ˆ๋ฒจ์—…", + depth = 1, + ) + + val DUMMY_VAPOREON = + SingleEvolutionUiModel( + pokemonId = "vaporeon", + pokemonName = "์ƒค๋ฏธ๋“œ", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/134.png", + item = "๋ฌผ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_JOLTEON = + SingleEvolutionUiModel( + pokemonId = "jolteon", + pokemonName = "์ฅฌํ”ผ์ฌ๋”", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/135.png", + item = "์ฒœ๋‘ฅ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_FLAREON = + SingleEvolutionUiModel( + pokemonId = "flareon", + pokemonName = "๋ถ€์Šคํ„ฐ", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/136.png", + item = "ํ™”์—ผ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_LEAFEON = + SingleEvolutionUiModel( + pokemonId = "leafeon", + pokemonName = "๋ฆฌํ”ผ์•„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/470.png", + item = "๋ฆฌํ”„์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_GLACEON = + SingleEvolutionUiModel( + pokemonId = "glaceon", + pokemonName = "๊ธ€๋ ˆ์ด์‹œ์•„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/471.png", + item = "๋ˆˆ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + } +} + +fun Evolution.toUi(): SingleEvolutionUiModel = + SingleEvolutionUiModel( + pokemonId = pokemonId, + pokemonName = pokemonName, + imageUrl = imageUrl, + depth = depth, + level = level, + item = item, + condition = condition, + ) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/StatUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/StatUiModel.kt new file mode 100644 index 00000000..15257003 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/StatUiModel.kt @@ -0,0 +1,84 @@ +package poke.rogue.helper.presentation.dex.model + +import androidx.annotation.ColorRes +import poke.rogue.helper.R +import poke.rogue.helper.data.model.Stat + +data class StatUiModel( + val name: String, + val amount: Int, + val limit: Int, + @ColorRes val color: Int = 0, +) { + val progress: Int + get() = amount * 100 / limit +} + +fun Stat.toUi() = + when (name) { + "hp" -> + StatUiModel( + name = "HP", + amount = amount, + limit = MAX_HP_LIMIT, + color = R.color.stat_hp, + ) + + "attack" -> + StatUiModel( + name = "๊ณต๊ฒฉ", + amount = amount, + limit = MAX_ATTACK_LIMIT, + color = R.color.stat_attack, + ) + + "defense" -> + StatUiModel( + name = "๋ฐฉ์–ด", + amount = amount, + limit = MAX_DEFENSE_LIMIT, + color = R.color.stat_defense, + ) + + "specialAttack" -> + StatUiModel( + name = "ํŠน์ˆ˜๊ณต๊ฒฉ", + amount = amount, + limit = MAX_SPECIAL_ATTACK_LIMIT, + color = R.color.stat_special_attack, + ) + + "specialDefense" -> + StatUiModel( + name = "ํŠน์ˆ˜๋ฐฉ์–ด", + amount = amount, + limit = MAX_SPECIAL_DEFENSE_LIMIT, + color = R.color.stat_special_defense, + ) + + "speed" -> + StatUiModel( + name = "์Šคํ”ผ๋“œ", + amount = amount, + limit = MAX_SPEED_LIMIT, + color = R.color.stat_speed, + ) + + "total" -> + StatUiModel( + name = "์ข…์กฑ๊ฐ’", + amount = amount, + limit = MAX_TOTAL_LIMIT, + color = R.color.stat_total, + ) + + else -> error("Unknown stat name: $name") + } + +private const val MAX_HP_LIMIT = 255 +private const val MAX_ATTACK_LIMIT = 190 +private const val MAX_DEFENSE_LIMIT = 250 +private const val MAX_SPECIAL_ATTACK_LIMIT = 194 +private const val MAX_SPECIAL_DEFENSE_LIMIT = 250 +private const val MAX_SPEED_LIMIT = 200 +private const val MAX_TOTAL_LIMIT = 800 diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokeSortAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokeSortAdapter.kt new file mode 100644 index 00000000..fc30eb75 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokeSortAdapter.kt @@ -0,0 +1,50 @@ +package poke.rogue.helper.presentation.dex.sort + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import poke.rogue.helper.databinding.ItemPokemonSortBinding +import poke.rogue.helper.presentation.dex.filter.SelectableUiModel +import poke.rogue.helper.presentation.util.view.ItemDiffCallback + +class PokeSortAdapter( + private val onToggleSort: PokemonSortHandler, +) : ListAdapter, PokeSortAdapter.PokeSortViewHolder>( + sortComparator, + ) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PokeSortViewHolder { + val binding = + ItemPokemonSortBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PokeSortViewHolder(binding, onToggleSort) + } + + override fun onBindViewHolder( + holder: PokeSortViewHolder, + position: Int, + ) { + holder.bind(getItem(position)) + } + + class PokeSortViewHolder( + private val binding: ItemPokemonSortBinding, + private val onToggleSort: PokemonSortHandler, + ) : + RecyclerView.ViewHolder(binding.root) { + fun bind(selectableSort: SelectableUiModel) { + binding.sort = selectableSort + binding.handler = onToggleSort + } + } + + companion object { + private val sortComparator = + 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/sort/PokeSortViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokeSortViewModel.kt new file mode 100644 index 00000000..ea01cb44 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokeSortViewModel.kt @@ -0,0 +1,107 @@ +package poke.rogue.helper.presentation.dex.sort + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import poke.rogue.helper.presentation.dex.filter.SelectableUiModel +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 PokeSortViewModel( + private val savedStateHandle: SavedStateHandle, +) : ViewModel(), PokemonSortHandler { + val uiState: StateFlow = + savedStateHandle.getStateFlow( + key = UI_STATE_KEY, + initialValue = PokeSortUiState.Default, + ) + + private val _uiEvent = MutableEventFlow() + val uiEvent: EventFlow = _uiEvent.asEventFlow() + + fun init(sort: PokemonSortUiModel) { + savedStateHandle[UI_STATE_KEY] = PokeSortUiState(sort) + } + + override fun toggleSort(id: Int) { + val pokemonSorts = uiState.value.pokemonSorts + if (pokemonSorts.any { it.id == id && it.isSelected }) return applySorting() + val newSorts = + pokemonSorts.map { sort -> + if (sort.id == id) { + sort.copy(isSelected = !sort.isSelected) + } else { + sort.copy(isSelected = false) + } + } + savedStateHandle[UI_STATE_KEY] = uiState.value.copy(pokemonSorts = newSorts) + applySorting() + } + + private fun applySorting() { + viewModelScope.launch { + _uiEvent.emit( + PokeSortUiEvent.ApplySorting( + uiState.value.selectedSort, + ), + ) + } + } + + fun closeSort() { + viewModelScope.launch { + _uiEvent.emit(PokeSortUiEvent.CloseSort) + } + } + + companion object { + private const val UI_STATE_KEY = "uiState" + } +} + +sealed interface PokeSortUiEvent { + data object CloseSort : PokeSortUiEvent + + data class ApplySorting(val sort: PokemonSortUiModel) : PokeSortUiEvent +} + +@Parcelize +data class PokeSortUiState( + val pokemonSorts: List>, +) : Parcelable { + constructor(pokemonSort: PokemonSortUiModel) : this( + pokemonSorts = pokemonSortsFrom(pokemonSort), + ) + + val selectedSort: PokemonSortUiModel + get() = pokemonSorts.first { it.isSelected }.data + + companion object { + val Default = + PokeSortUiState( + pokemonSorts = pokemonSortsFrom(PokemonSortUiModel.ByDexNumber), + ) + + private fun pokemonSortsFrom(sort: PokemonSortUiModel) = + PokemonSortUiModel.entries.map { type -> + if (type == sort) { + SelectableUiModel( + id = type.id, + isSelected = true, + data = type, + ) + } else { + SelectableUiModel( + id = type.id, + isSelected = false, + data = type, + ) + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortBottomSheetFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortBottomSheetFragment.kt new file mode 100644 index 00000000..497bc65e --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortBottomSheetFragment.kt @@ -0,0 +1,110 @@ +package poke.rogue.helper.presentation.dex.sort + +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.databinding.BottomSheetPokemonSortBinding +import poke.rogue.helper.presentation.dex.PokemonListActivity.Companion.SORT_RESULT_KEY +import poke.rogue.helper.presentation.util.parcelable +import poke.rogue.helper.presentation.util.repeatOnStarted + +class PokemonSortBottomSheetFragment : BottomSheetDialogFragment() { + private var _binding: BottomSheetPokemonSortBinding? = null + private val binding get() = requireNotNull(_binding) + private lateinit var pokeSortAdapter: PokeSortAdapter + private val viewModel by viewModels { + SavedStateViewModelFactory( + requireActivity().application, + this, + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = BottomSheetPokemonSortBinding.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) + } + initAdapter() + observeUiState() + observeEvents() + } + + private fun initAdapter() { + pokeSortAdapter = PokeSortAdapter(viewModel) + binding.rvPokemonSort.adapter = pokeSortAdapter + } + + private fun observeUiState() { + repeatOnStarted { + viewModel.uiState.collect { state -> + pokeSortAdapter.submitList(state.pokemonSorts) + } + } + } + + private fun observeEvents() { + repeatOnStarted { + viewModel.uiEvent.collect { event -> + when (event) { + is PokeSortUiEvent.CloseSort -> dismiss() + is PokeSortUiEvent.ApplySorting -> { + setFragmentResult( + SORT_RESULT_KEY, + bundleOf(ARGS_KEY to event.sort), + ) + dismiss() + } + } + } + } + } + + 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 = PokemonSortBottomSheetFragment::class.java.simpleName + private const val ARGS_KEY = "PokemonSortBottomSheetFragment_args_key" + + fun argsFrom(result: Bundle): PokemonSortUiModel? { + return result.parcelable(ARGS_KEY) + } + + fun newInstance(sort: PokemonSortUiModel): PokemonSortBottomSheetFragment { + return PokemonSortBottomSheetFragment().apply { + arguments = + bundleOf(ARGS_KEY to sort) + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortHandler.kt new file mode 100644 index 00000000..9e457464 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortHandler.kt @@ -0,0 +1,5 @@ +package poke.rogue.helper.presentation.dex.sort + +interface PokemonSortHandler { + fun toggleSort(id: Int) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortUiModel.kt new file mode 100644 index 00000000..46c0a500 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/sort/PokemonSortUiModel.kt @@ -0,0 +1,41 @@ +package poke.rogue.helper.presentation.dex.sort + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import poke.rogue.helper.data.model.PokemonSort + +@Parcelize +enum class PokemonSortUiModel(val id: Int, val label: String) : Parcelable { + ByDexNumber(0, "๋„๊ฐ์ˆœ"), + ByBaseStat(1, "์ข…์กฑ๊ฐ’์ˆœ โญ๏ธ๏ธ"), + BySpeed(2, "์Šคํ”ผ๋“œ์ˆœ โญ๏ธ๏ธ"), + ByAttack(3, "๊ณต๊ฒฉ๋ ฅ์ˆœ"), + ByDefense(4, "๋ฐฉ์–ด๋ ฅ์ˆœ"), + BySpecialAttack(5, "ํŠน์ˆ˜๊ณต๊ฒฉ์ˆœ"), + BySpecialDefense(6, "ํŠน์ˆ˜๋ฐฉ์–ด์ˆœ"), + ByHp(7, "HP์ˆœ"), +} + +fun PokemonSortUiModel.toData(): PokemonSort = + when (this) { + PokemonSortUiModel.ByDexNumber -> PokemonSort.ByDexNumber + PokemonSortUiModel.ByBaseStat -> PokemonSort.ByBaseStat + PokemonSortUiModel.BySpeed -> PokemonSort.BySpeed + PokemonSortUiModel.ByAttack -> PokemonSort.ByAttack + PokemonSortUiModel.ByDefense -> PokemonSort.ByDefense + PokemonSortUiModel.BySpecialAttack -> PokemonSort.BySpecialAttack + PokemonSortUiModel.BySpecialDefense -> PokemonSort.BySpecialDefense + PokemonSortUiModel.ByHp -> PokemonSort.ByHp + } + +fun PokemonSort.toUiModel(): PokemonSortUiModel = + when (this) { + PokemonSort.ByDexNumber -> PokemonSortUiModel.ByDexNumber + PokemonSort.ByBaseStat -> PokemonSortUiModel.ByBaseStat + PokemonSort.BySpeed -> PokemonSortUiModel.BySpeed + PokemonSort.ByAttack -> PokemonSortUiModel.ByAttack + PokemonSort.ByDefense -> PokemonSortUiModel.ByDefense + PokemonSort.BySpecialAttack -> PokemonSortUiModel.BySpecialAttack + PokemonSort.BySpecialDefense -> PokemonSortUiModel.BySpecialDefense + PokemonSort.ByHp -> PokemonSortUiModel.ByHp + } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeActionHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeActionHandler.kt new file mode 100644 index 00000000..f39e4a55 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeActionHandler.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.presentation.home + +interface HomeActionHandler { + fun navigateToType() + + fun navigateToDex() + + fun navigateToAbility() + + fun navigateToTip() + + fun navigateToPokeRogue() + + fun navigateToBiome() + + fun navigateToItem() + + fun navigateToBattle() +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeActivity.kt new file mode 100644 index 00000000..6b21b579 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeActivity.kt @@ -0,0 +1,159 @@ +package poke.rogue.helper.presentation.home + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.Toolbar +import com.google.android.material.snackbar.Snackbar +import com.google.android.play.core.install.model.ActivityResult.RESULT_IN_APP_UPDATE_FAILED +import org.koin.androidx.viewmodel.ext.android.viewModel +import poke.rogue.helper.R +import poke.rogue.helper.analytics.AnalyticsEvent +import poke.rogue.helper.databinding.ActivityHomeBinding +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.BiomeActivity +import poke.rogue.helper.presentation.dex.PokemonListActivity +import poke.rogue.helper.presentation.tip.TipActivity +import poke.rogue.helper.presentation.type.TypeActivity +import poke.rogue.helper.presentation.util.context.startActivity +import poke.rogue.helper.presentation.util.context.stringOf +import poke.rogue.helper.presentation.util.context.toast +import poke.rogue.helper.presentation.util.logClickEvent +import poke.rogue.helper.presentation.util.repeatOnStarted +import poke.rogue.helper.update.UpdateManager + +class HomeActivity : ToolbarActivity(R.layout.activity_home) { + private val viewModel by viewModel() + private lateinit var updateManager: UpdateManager + + override val toolbar: Toolbar + get() = binding.toolbarHome + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initViews() + initUpdateManager() + initObservers() + } + + private fun initViews() = + with(binding) { + supportActionBar?.setDisplayShowTitleEnabled(false) + supportActionBar?.setDisplayHomeAsUpEnabled(false) + actionHandler = viewModel + } + + private fun initUpdateManager() { + updateManager = UpdateManager(applicationContext) { showUpdateComplete() } + lifecycle.addObserver(updateManager) + + val appUpdateLauncher = + registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { result -> + when (result.resultCode) { + RESULT_OK -> { + logger.logClickEvent(UPDATE_AGREE) + toast(R.string.update_result_ok) + } + + RESULT_CANCELED -> { + logger.logClickEvent(UPDATE_DISAGREE) + } + + RESULT_IN_APP_UPDATE_FAILED -> { + logger.logEvent( + AnalyticsEvent(type = UPDATE_ERROR), + ) + toast(R.string.update_result_failed) + } + } + } + updateManager.checkForAppUpdates(appUpdateLauncher) + } + + private fun showUpdateComplete() { + Snackbar.make(binding.root, R.string.update_snackBar_title, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.update_snackBar_action_text) { + updateManager.completeUpdate() + } + .show() + } + + private fun initObservers() { + repeatOnStarted { + viewModel.navigationEvent.collect { state -> + when (state) { + is HomeNavigateEvent.ToType -> + startActivity { + logger.logClickEvent(NAVIGATE_TO_TYPE) + } + + is HomeNavigateEvent.ToDex -> + startActivity { + logger.logClickEvent(NAVIGATE_TO_DEX) + } + + is HomeNavigateEvent.ToAbility -> + startActivity { + logger.logClickEvent(NAVIGATE_TO_ABILITY) + } + + is HomeNavigateEvent.ToTip -> + startActivity { + logger.logClickEvent(NAVIGATE_TO_TIP) + } + + is HomeNavigateEvent.ToBiome -> + startActivity { + logger.logClickEvent(NAVIGATE_TO_BIOME) + } + + is HomeNavigateEvent.ToItem -> { + toast("Coming soon") + } + + is HomeNavigateEvent.ToBattle -> { + startActivity { + logger.logClickEvent(NAVIGATE_TO_BATTLE) + } + } + + is HomeNavigateEvent.ToLogo -> navigateToPokeRogue() + } + } + } + } + + private fun navigateToPokeRogue() { + Intent( + Intent.ACTION_VIEW, + Uri.parse(stringOf(R.string.home_pokerogue_url)), + ).also { + startActivity(it) + logger.logClickEvent(NAVIGATE_TO_POKE_ROGUE) + } + } + + companion object { + private const val NAVIGATE_TO_POKE_ROGUE = "Nav_Logo_To_PokeRogue_Game" + private const val NAVIGATE_TO_TYPE = "Nav_Type" + private const val NAVIGATE_TO_DEX = "Nav_Dex" + private const val NAVIGATE_TO_ABILITY = "Nav_Ability" + private const val NAVIGATE_TO_TIP = "Nav_Tip" + private const val NAVIGATE_TO_BIOME = "Nav_Biome" + private const val NAVIGATE_TO_ITEM = "Nav_Item" + private const val NAVIGATE_TO_BATTLE = "Nav_Battle" + private const val UPDATE_AGREE = "Update_Agree" + private const val UPDATE_DISAGREE = "Update_Disagree" + private const val UPDATE_ERROR = "Update_Error" + + fun intent(context: Context): Intent { + return Intent(context, HomeActivity::class.java) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeNavigateEvent.kt b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeNavigateEvent.kt new file mode 100644 index 00000000..9fa9a0d6 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeNavigateEvent.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.presentation.home + +sealed interface HomeNavigateEvent { + data object ToType : HomeNavigateEvent + + data object ToDex : HomeNavigateEvent + + data object ToAbility : HomeNavigateEvent + + data object ToTip : HomeNavigateEvent + + data object ToLogo : HomeNavigateEvent + + data object ToBiome : HomeNavigateEvent + + data object ToItem : HomeNavigateEvent + + data object ToBattle : HomeNavigateEvent +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeViewModel.kt new file mode 100644 index 00000000..2dd55f7f --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/home/HomeViewModel.kt @@ -0,0 +1,61 @@ +package poke.rogue.helper.presentation.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class HomeViewModel : ViewModel(), HomeActionHandler { + private val _navigationEvent = MutableSharedFlow() + val navigationEvent: SharedFlow = _navigationEvent.asSharedFlow() + + override fun navigateToType() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToType) + } + } + + override fun navigateToDex() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToDex) + } + } + + override fun navigateToAbility() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToAbility) + } + } + + override fun navigateToTip() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToTip) + } + } + + override fun navigateToPokeRogue() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToLogo) + } + } + + override fun navigateToBiome() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToBiome) + } + } + + override fun navigateToItem() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToItem) + } + } + + override fun navigateToBattle() { + viewModelScope.launch { + _navigationEvent.emit(HomeNavigateEvent.ToBattle) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/item/ItemActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/item/ItemActivity.kt new file mode 100644 index 00000000..32a835d6 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/item/ItemActivity.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.presentation.item + +import android.content.Context +import android.content.Intent +import androidx.appcompat.widget.Toolbar +import poke.rogue.helper.R +import poke.rogue.helper.databinding.ActivityItemBinding +import poke.rogue.helper.presentation.base.toolbar.ToolbarActivity + +class ItemActivity : ToolbarActivity(R.layout.activity_item) { + override val toolbar: Toolbar + get() = binding.toolbarItem + + companion object { + fun intent(context: Context): Intent { + return Intent(context, ItemActivity::class.java) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/splash/PokemonIntroActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/splash/PokemonIntroActivity.kt new file mode 100644 index 00000000..b36674c0 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/splash/PokemonIntroActivity.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.splash + +import android.os.Bundle +import androidx.appcompat.widget.Toolbar +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import org.koin.androidx.viewmodel.ext.android.viewModel +import poke.rogue.helper.R +import poke.rogue.helper.databinding.ActivityPokemonIntroBinding +import poke.rogue.helper.presentation.base.error.ErrorHandleActivity +import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel +import poke.rogue.helper.presentation.home.HomeActivity +import poke.rogue.helper.presentation.util.context.startActivity +import poke.rogue.helper.presentation.util.repeatOnStarted + +class PokemonIntroActivity : + ErrorHandleActivity(R.layout.activity_pokemon_intro) { + private val viewModel by viewModel() + override val errorViewModel: ErrorHandleViewModel + get() = viewModel + + override val toolbar: Toolbar? = null + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + repeatOnStarted { + viewModel.navigationToHomeEvent.collect { + finishAffinity() + startActivity() + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/splash/PokemonIntroViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/splash/PokemonIntroViewModel.kt new file mode 100644 index 00000000..250400d9 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/splash/PokemonIntroViewModel.kt @@ -0,0 +1,56 @@ +package poke.rogue.helper.presentation.splash + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.koin.mp.KoinPlatform.getKoin +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.repository.DexRepository +import poke.rogue.helper.presentation.base.BaseViewModelFactory +import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel +import poke.rogue.helper.presentation.util.event.MutableEventFlow +import poke.rogue.helper.presentation.util.event.asEventFlow + +class PokemonIntroViewModel( + private val pokemonRepository: DexRepository, + logger: AnalyticsLogger, +) : ErrorHandleViewModel(logger) { + private val _navigationToHomeEvent = MutableEventFlow() + val navigationToHomeEvent = _navigationToHomeEvent.asEventFlow() + + init { + refreshEvent + .onStart { emit(Unit) } + .onEach { + try { + coroutineScope { + launch { delay(MIN_SPLASH_TIME) } + launch { pokemonRepository.warmUp() } + // TODO Koin ์ผ๊ด„์ ์œผ๋กœ ์ ์šฉ ์‹œ ์‚ญ์ œ ์˜ˆ์ • + launch { getKoin().get().warmUp() } + } + } catch (e: Exception) { + handlePokemonError(e) + } finally { + _navigationToHomeEvent.emit(Unit) + } + } + .launchIn(viewModelScope + errorHandler) + } + + companion object { + private const val MIN_SPLASH_TIME = 1000L + + fun factory( + pokemonRepository: DexRepository, + logger: AnalyticsLogger, + ) = BaseViewModelFactory { + PokemonIntroViewModel(pokemonRepository, logger) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/tip/TipActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/tip/TipActivity.kt new file mode 100644 index 00000000..e8040608 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/tip/TipActivity.kt @@ -0,0 +1,31 @@ +package poke.rogue.helper.presentation.tip + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.webkit.WebViewClient +import poke.rogue.helper.R +import poke.rogue.helper.databinding.ActivityTipBinding +import poke.rogue.helper.presentation.base.BindingActivity +import poke.rogue.helper.presentation.util.context.stringOf + +class TipActivity : BindingActivity(R.layout.activity_tip) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initView() + } + + private fun initView() { + with(binding.webView) { + settings.loadWithOverviewMode = true + webViewClient = WebViewClient() + loadUrl(stringOf(R.string.home_pokerogue_tip_url)) + } + } + + companion object { + fun intent(context: Context): Intent { + return Intent(context, TipActivity::class.java) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeActivity.kt new file mode 100644 index 00000000..6ef3527f --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeActivity.kt @@ -0,0 +1,97 @@ +package poke.rogue.helper.presentation.type + +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 poke.rogue.helper.R +import poke.rogue.helper.databinding.ActivityTypeBinding +import poke.rogue.helper.presentation.base.toolbar.ToolbarActivity +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.type.result.TypeResultAdapter +import poke.rogue.helper.presentation.type.selection.TypeSelectionBottomSheetFragment +import poke.rogue.helper.presentation.util.repeatOnStarted + +class TypeActivity : ToolbarActivity(R.layout.activity_type) { + private val viewModel by viewModel() + private val typeResultAdapter by lazy { TypeResultAdapter() } + + override val toolbar: Toolbar + get() = binding.toolbarType + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initViews() + initAdapter() + initObserver() + } + + private fun initViews() = + with(binding) { + typeHandler = viewModel + vm = viewModel + lifecycleOwner = this@TypeActivity + } + + private fun initAdapter() { + binding.rvTypeResult.adapter = typeResultAdapter + } + + private fun initObserver() { + observeTypeEvent() + observeTypeSelectionStates() + observeTypeResults() + } + + private fun observeTypeEvent() { + repeatOnStarted { + viewModel.typeEvent.collect { + if (it is TypeEvent.ShowSelection) { + showBottomSheet(it.selectorType, it.disabledTypes) + } + } + } + } + + private fun observeTypeSelectionStates() { + repeatOnStarted { + viewModel.typeSelectionStates.collect { states -> + if (states.myType is TypeSelectionUiState.Selected) { + binding.myType = states.myType.selectedType + } + + if (states.opponentType1 is TypeSelectionUiState.Selected) { + binding.opponent1Type = states.opponentType1.selectedType + } + + if (states.opponentType2 is TypeSelectionUiState.Selected) { + binding.opponent2Type = states.opponentType2.selectedType + } + } + } + } + + private fun observeTypeResults() { + repeatOnStarted { + viewModel.type.collect { matchedResult -> + typeResultAdapter.submitList(matchedResult) + } + } + } + + private fun showBottomSheet( + selectorType: SelectorType, + disabledTypes: Set, + ) { + TypeSelectionBottomSheetFragment.newInstance(selectorType, disabledTypes).show( + supportFragmentManager, + TypeSelectionBottomSheetFragment.TAG, + ) + } + + companion object { + fun intent(context: Context): Intent = Intent(context, TypeActivity::class.java) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeEvent.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeEvent.kt new file mode 100644 index 00000000..c98cb4ca --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeEvent.kt @@ -0,0 +1,10 @@ +package poke.rogue.helper.presentation.type + +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel + +sealed interface TypeEvent { + data class ShowSelection(val selectorType: SelectorType, val disabledTypes: Set) : TypeEvent + + data object HideSelection : TypeEvent +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeHandler.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeHandler.kt new file mode 100644 index 00000000..07edceac --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeHandler.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.presentation.type + +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel + +interface TypeHandler { + fun startSelection(selectorType: SelectorType) + + fun selectType( + selectorType: SelectorType, + selectedType: TypeUiModel, + ) + + fun removeSelection(selectorType: SelectorType) + + fun removeAllSelections() +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeSelectionStates.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeSelectionStates.kt new file mode 100644 index 00000000..61a8328d --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeSelectionStates.kt @@ -0,0 +1,57 @@ +package poke.rogue.helper.presentation.type + +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel + +data class TypeSelectionStates( + val myType: TypeSelectionUiState = TypeSelectionUiState.Empty, + val opponentType1: TypeSelectionUiState = TypeSelectionUiState.Empty, + val opponentType2: TypeSelectionUiState = TypeSelectionUiState.Empty, +) { + val selectedOpponents: List + get() = + listOf( + opponentType1, + opponentType2, + ).filterIsInstance() + + val isMyTypeSelected: Boolean + get() = myType.isSelected() + + val isOpponent1Selected: Boolean + get() = opponentType1.isSelected() + + val isOpponent2Selected: Boolean + get() = opponentType2.isSelected() + + val isMyTypeEmptyAndAnyOpponentSelected: Boolean + get() = myType.isEmpty() && (isOpponent1Selected || isOpponent2Selected) + + val isMyTypeSelectedAndOpponentsEmpty: Boolean + get() = isMyTypeSelected && opponentType1.isEmpty() && opponentType2.isEmpty() + + val isMyTypeSelectedAndAnyOpponentSelected: Boolean + get() = isMyTypeSelected && (isOpponent1Selected || isOpponent2Selected) + + val isAllEmpty: Boolean + get() = !isMyTypeSelected && !isOpponent1Selected && !isOpponent2Selected + + fun disabledTypeItems(selectorType: SelectorType): Set { + return when (selectorType) { + SelectorType.MINE -> emptySet() + SelectorType.OPPONENT1 -> opponentType2.selectedTypes() + SelectorType.OPPONENT2 -> opponentType1.selectedTypes() + } + } +} + +private fun TypeSelectionUiState.isSelected(): Boolean = this is TypeSelectionUiState.Selected + +private fun TypeSelectionUiState.isEmpty(): Boolean = this is TypeSelectionUiState.Empty + +private fun TypeSelectionUiState.selectedTypes(): Set { + return when (this) { + is TypeSelectionUiState.Selected -> setOf(selectedType) + else -> emptySet() + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeSelectionUiState.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeSelectionUiState.kt new file mode 100644 index 00000000..3b3eaefa --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeSelectionUiState.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.presentation.type + +import poke.rogue.helper.presentation.type.model.TypeUiModel + +sealed interface TypeSelectionUiState { + data class Selected(val selectedType: TypeUiModel) : TypeSelectionUiState + + data object Empty : TypeSelectionUiState +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeViewModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeViewModel.kt new file mode 100644 index 00000000..34d90ee5 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/TypeViewModel.kt @@ -0,0 +1,128 @@ +package poke.rogue.helper.presentation.type + +import androidx.lifecycle.ViewModel +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.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import poke.rogue.helper.data.repository.TypeRepository +import poke.rogue.helper.presentation.type.model.MatchedTypesUiModel +import poke.rogue.helper.presentation.type.model.MatchedTypesUiModelComparator +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.type.model.toUi + +class TypeViewModel( + private val typeRepository: TypeRepository, +) : ViewModel(), TypeHandler { + private val _typeSelectionStates = MutableStateFlow(TypeSelectionStates()) + val typeSelectionStates: StateFlow = _typeSelectionStates + + private val _typeEvent = MutableSharedFlow() + val typeEvent: SharedFlow = _typeEvent.asSharedFlow() + + val allTypes: List by lazy { + typeRepository.allTypes().map { it.toUi() } + } + + val type: StateFlow> = + typeSelectionStates.map { states -> + when { + states.isMyTypeEmptyAndAnyOpponentSelected -> + matchedTypesAgainstOpponents(states.selectedOpponents) + + states.isMyTypeSelectedAndOpponentsEmpty -> + matchedTypesAgainstMine(states.myType as TypeSelectionUiState.Selected) + + states.isMyTypeSelectedAndAnyOpponentSelected -> + matchedTypes( + states.myType as TypeSelectionUiState.Selected, + states.selectedOpponents, + ) + + else -> emptyList() + }.sortedWith(MatchedTypesUiModelComparator) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList()) + + private fun matchedTypesAgainstOpponents(opponents: List): List = + opponents.flatMap { + matchedTypesAgainstOpponent(it) + } + + private fun matchedTypesAgainstOpponent(opponent: TypeSelectionUiState.Selected): List { + val selectedTypeId = opponent.selectedType.id + return typeRepository.matchedTypesAgainstOpponent(selectedTypeId) + .toUi(selectedTypeId = selectedTypeId, isMyType = false) + } + + private fun matchedTypesAgainstMine(mine: TypeSelectionUiState.Selected): List { + val selectedTypeId = mine.selectedType.id + return typeRepository.matchedTypesAgainstMyType(selectedTypeId) + .toUi(selectedTypeId = selectedTypeId, isMyType = true) + } + + private fun matchedTypes( + mine: TypeSelectionUiState.Selected, + opponents: List, + ): List { + val mySelectedId = mine.selectedType.id + val opponentIds = opponents.map { it.selectedType.id } + + return typeRepository.matchedTypes(mySelectedId, opponentIds) + .toUi(selectedTypeId = mySelectedId, isMyType = true) + } + + override fun startSelection(selectorType: SelectorType) { + viewModelScope.launch { + _typeEvent.emit( + TypeEvent.ShowSelection( + selectorType, + typeSelectionStates.value.disabledTypeItems(selectorType), + ), + ) + } + } + + override fun selectType( + selectorType: SelectorType, + selectedType: TypeUiModel, + ) { + val changedState = TypeSelectionUiState.Selected(selectedType) + updateSelectionState(selectorType, changedState) + viewModelScope.launch { + _typeEvent.emit(TypeEvent.HideSelection) + } + } + + override fun removeSelection(selectorType: SelectorType) { + val changedState = TypeSelectionUiState.Empty + updateSelectionState(selectorType, changedState) + } + + private fun updateSelectionState( + selectorType: SelectorType, + changedState: TypeSelectionUiState, + ) { + _typeSelectionStates.value = + when (selectorType) { + SelectorType.MINE -> _typeSelectionStates.value.copy(myType = changedState) + SelectorType.OPPONENT1 -> _typeSelectionStates.value.copy(opponentType1 = changedState) + SelectorType.OPPONENT2 -> _typeSelectionStates.value.copy(opponentType2 = changedState) + } + } + + override fun removeAllSelections() { + _typeSelectionStates.value = + _typeSelectionStates.value.copy( + myType = TypeSelectionUiState.Empty, + opponentType1 = TypeSelectionUiState.Empty, + opponentType2 = TypeSelectionUiState.Empty, + ) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/model/MatchedResultUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/MatchedResultUiModel.kt new file mode 100644 index 00000000..4452ddca --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/MatchedResultUiModel.kt @@ -0,0 +1,47 @@ +package poke.rogue.helper.presentation.type.model + +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import poke.rogue.helper.R +import poke.rogue.helper.data.model.MatchedResult + +enum class MatchedResultUiModel( + @StringRes val descriptionRes: Int, + @ColorRes val colorRes: Int, +) { + STRONG(R.string.type_result_strong, R.color.poke_red_20), + WEAK(R.string.type_result_weak, R.color.poke_green_20), + INEFFECTIVE(R.string.type_result_ineffective, R.color.poke_grey_60), + NORMAL(R.string.type_result_normal, R.color.poke_white), + ; + + companion object { + fun fromMatchedResult(matchedResult: MatchedResult): MatchedResultUiModel { + return when (matchedResult) { + MatchedResult.STRONG -> STRONG + MatchedResult.WEAK -> WEAK + MatchedResult.INEFFECTIVE -> INEFFECTIVE + MatchedResult.NORMAL -> NORMAL + } + } + } +} + +object MatchedTypesUiModelComparator : Comparator { + private val order = + listOf( + MatchedResultUiModel.STRONG, + MatchedResultUiModel.WEAK, + MatchedResultUiModel.INEFFECTIVE, + MatchedResultUiModel.NORMAL, + ) + + override fun compare( + a: MatchedTypesUiModel, + b: MatchedTypesUiModel, + ): Int { + val indexA = order.indexOf(a.matchedResultUi) + val indexB = order.indexOf(b.matchedResultUi) + return indexA.compareTo(indexB) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/model/MatchedTypesUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/MatchedTypesUiModel.kt new file mode 100644 index 00000000..5fa62ace --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/MatchedTypesUiModel.kt @@ -0,0 +1,29 @@ +package poke.rogue.helper.presentation.type.model + +import poke.rogue.helper.data.model.MatchedTypes + +data class MatchedTypesUiModel( + val selectedType: TypeUiModel, + val isMyType: Boolean, + val matchedResultUi: MatchedResultUiModel, + val matchedItem: List, +) + +fun MatchedTypes.toUi( + typeId: Int, + isMyType: Boolean, +): MatchedTypesUiModel { + val inputTypeUi = TypeUiModel.fromId(typeId) + val matchedResultUi = MatchedResultUiModel.fromMatchedResult(this.matchedResult) + return MatchedTypesUiModel( + selectedType = inputTypeUi, + isMyType = isMyType, + matchedResultUi = matchedResultUi, + matchedItem = this.types.map { it.toUi() }, + ) +} + +fun List.toUi( + selectedTypeId: Int, + isMyType: Boolean, +): List = this.map { it.toUi(selectedTypeId, isMyType) } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/model/SelectorType.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/SelectorType.kt new file mode 100644 index 00000000..2066115d --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/SelectorType.kt @@ -0,0 +1,7 @@ +package poke.rogue.helper.presentation.type.model + +enum class SelectorType { + MINE, + OPPONENT1, + OPPONENT2, +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt new file mode 100644 index 00000000..5d108573 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/model/TypeUiModel.kt @@ -0,0 +1,49 @@ +package poke.rogue.helper.presentation.type.model + +import android.os.Parcelable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import kotlinx.parcelize.Parcelize +import poke.rogue.helper.R +import poke.rogue.helper.data.model.Type + +@Parcelize +enum class TypeUiModel( + val id: Int, + val typeName: String, + @ColorRes val typeColor: Int, + @DrawableRes val typeIconResId: Int, +) : Parcelable { + NORMAL(0, "๋…ธ๋ง", R.color.poke_normal, R.drawable.icon_type_normal), + FIRE(1, "๋ถˆ๊ฝƒ", R.color.poke_fire, R.drawable.icon_type_fire), + WATER(2, "๋ฌผ", R.color.poke_water, R.drawable.icon_type_water), + ELECTRIC(3, "์ „๊ธฐ", R.color.poke_electric, R.drawable.icon_type_electric), + GRASS(4, "ํ’€", R.color.poke_grass, R.drawable.icon_type_grass), + ICE(5, "์–ผ์Œ", R.color.poke_ice, R.drawable.icon_type_ice), + FIGHTING(6, "๊ฒฉํˆฌ", R.color.poke_fighting, R.drawable.icon_type_fighting), + POISON(7, "๋…", R.color.poke_poison, R.drawable.icon_type_poison), + GROUND(8, "๋•…", R.color.poke_ground, R.drawable.icon_type_ground), + FLYING(9, "๋น„ํ–‰", R.color.poke_flying, R.drawable.icon_type_flying), + PSYCHIC(10, "์—์Šคํผ", R.color.poke_psychic, R.drawable.icon_type_psychic), + BUG(11, "๋ฒŒ๋ ˆ", R.color.poke_bug, R.drawable.icon_type_bug), + ROCK(12, "๋ฐ”์œ„", R.color.poke_rock, R.drawable.icon_type_rock), + GHOST(13, "๊ณ ์ŠคํŠธ", R.color.poke_ghost, R.drawable.icon_type_ghost), + DRAGON(14, "๋“œ๋ž˜๊ณค", R.color.poke_dragon, R.drawable.icon_type_dragon), + DARK(15, "์•…", R.color.poke_dark, R.drawable.icon_type_dark), + STEEL(16, "๊ฐ•์ฒ ", R.color.poke_steel, R.drawable.icon_type_steel), + FAIRY(17, "ํŽ˜์–ด๋ฆฌ", R.color.poke_fairy, R.drawable.icon_type_fairy), + STELLAR(18, "์Šคํ…”๋ผ", R.color.poke_white, R.drawable.icon_type_stellar), + ; + + companion object { + fun fromId(id: Int): TypeUiModel { + return entries.find { it.id == id } ?: throw IllegalArgumentException("Unknown type ID: $id") + } + } +} + +fun Type.toUi(): TypeUiModel = TypeUiModel.valueOf(this.name) + +fun TypeUiModel.toData(): Type = Type.fromId(this.id) + +fun List.toUi(): List = map(Type::toUi) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultAdapter.kt new file mode 100644 index 00000000..46e21b9c --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultAdapter.kt @@ -0,0 +1,37 @@ +package poke.rogue.helper.presentation.type.result + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import poke.rogue.helper.databinding.ItemTypeResultBinding +import poke.rogue.helper.presentation.type.model.MatchedTypesUiModel +import poke.rogue.helper.presentation.util.view.ItemDiffCallback + +class TypeResultAdapter : ListAdapter(typeComparator) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): TypeResultViewHolder = + TypeResultViewHolder( + ItemTypeResultBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + ) + + override fun onBindViewHolder( + holder: TypeResultViewHolder, + position: Int, + ) { + holder.bind(getItem(position)) + } + + companion object { + val typeComparator = + ItemDiffCallback( + onItemsTheSame = { oldItem, newItem -> oldItem.selectedType == newItem.selectedType }, + onContentsTheSame = { oldItem, newItem -> oldItem == newItem }, + ) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultItemAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultItemAdapter.kt new file mode 100644 index 00000000..66bec210 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultItemAdapter.kt @@ -0,0 +1,30 @@ +package poke.rogue.helper.presentation.type.result + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import poke.rogue.helper.databinding.ItemTypeBinding +import poke.rogue.helper.presentation.type.model.TypeUiModel + +class TypeResultItemAdapter(private val types: List = listOf()) : + RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): TypeResultItemViewHolder { + val view = ItemTypeBinding.inflate(LayoutInflater.from(parent.context)) + return TypeResultItemViewHolder(view) + } + + override fun onBindViewHolder( + holder: TypeResultItemViewHolder, + position: Int, + ) { + val item = types[position] + holder.bind(item) + } + + override fun getItemCount(): Int { + return types.size + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultItemViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultItemViewHolder.kt new file mode 100644 index 00000000..e46d85bf --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultItemViewHolder.kt @@ -0,0 +1,29 @@ +package poke.rogue.helper.presentation.type.result + +import android.view.Gravity +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import poke.rogue.helper.databinding.ItemTypeBinding +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 TypeResultItemViewHolder( + private val binding: ItemTypeBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(typeItem: TypeUiModel) { + binding.type = typeItem + binding.viewConfiguration = typeViewConfiguration + } + + companion object { + private val typeViewConfiguration = + TypeChip.PokemonTypeViewConfiguration( + width = ViewGroup.LayoutParams.MATCH_PARENT, + contentAlignment = Gravity.START, + hasBackGround = false, + nameSize = 14.dp, + iconSize = 18.dp, + ) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultViewHolder.kt new file mode 100644 index 00000000..537d162d --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/result/TypeResultViewHolder.kt @@ -0,0 +1,70 @@ +package poke.rogue.helper.presentation.type.result + +import android.content.Context +import android.graphics.Typeface +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.recyclerview.widget.RecyclerView +import poke.rogue.helper.R +import poke.rogue.helper.databinding.ItemTypeResultBinding +import poke.rogue.helper.presentation.type.model.MatchedTypesUiModel +import poke.rogue.helper.presentation.util.context.colorOf +import poke.rogue.helper.presentation.util.context.stringOf + +class TypeResultViewHolder(private val binding: ItemTypeResultBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(typeMatchedResult: MatchedTypesUiModel) { + val context = binding.root.context + + bindTypeNameText(typeMatchedResult, context) + bindTypeStrengthText(typeMatchedResult, context) + + binding.typeResult = typeMatchedResult + binding.rvResultMatchedTypes.adapter = TypeResultItemAdapter(typeMatchedResult.matchedItem) + } + + private fun bindTypeNameText( + typeMatchedResult: MatchedTypesUiModel, + context: Context, + ) { + val typeName = typeMatchedResult.selectedType.typeName + val typeNameTail = + if (typeMatchedResult.isMyType) { + context.getString(R.string.type_item_result_subject_mine) + } else { + context.getString(R.string.type_item_result_subject_opponent) + } + + binding.tvResultSelectedTypeName.text = + buildSpannedString { + inSpans( + StyleSpan(Typeface.BOLD), + ) { + append(typeName) + } + append(typeNameTail) + } + } + + private fun bindTypeStrengthText( + typeMatchedResult: MatchedTypesUiModel, + context: Context, + ) { + val matchedResultText = context.stringOf(typeMatchedResult.matchedResultUi.descriptionRes) + val matchedResultTextTail = context.stringOf(R.string.type_item_result_tail) + val matchedResultTextColor = context.colorOf(typeMatchedResult.matchedResultUi.colorRes) + + binding.tvResultSelectedTypeStrength.text = + buildSpannedString { + inSpans( + ForegroundColorSpan(matchedResultTextColor), + StyleSpan(Typeface.BOLD), + ) { + append(matchedResultText) + } + append(matchedResultTextTail) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionAdapter.kt new file mode 100644 index 00000000..edce4e70 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionAdapter.kt @@ -0,0 +1,37 @@ +package poke.rogue.helper.presentation.type.selection + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import poke.rogue.helper.databinding.ItemTypeSelectionBinding +import poke.rogue.helper.presentation.type.TypeHandler +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel + +class TypeSelectionAdapter( + private val types: List = listOf(), + private val selector: SelectorType, + private val disabledType: Set = setOf(), + private val typeHandler: TypeHandler, +) : + RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): TypeSelectionViewHolder { + val view = ItemTypeSelectionBinding.inflate(LayoutInflater.from(parent.context)) + return TypeSelectionViewHolder(view) + } + + override fun getItemCount(): Int { + return types.size + } + + override fun onBindViewHolder( + holder: TypeSelectionViewHolder, + position: Int, + ) { + val item = types[position] + holder.bind(item, selector, disabledType, typeHandler) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionBottomSheetFragment.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionBottomSheetFragment.kt new file mode 100644 index 00000000..ad5902a7 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionBottomSheetFragment.kt @@ -0,0 +1,113 @@ +package poke.rogue.helper.presentation.type.selection + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import poke.rogue.helper.databinding.FragmentTypeSelectionBottomSheetBinding +import poke.rogue.helper.presentation.type.TypeEvent +import poke.rogue.helper.presentation.type.TypeViewModel +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.util.fragment.withArgs +import poke.rogue.helper.presentation.util.repeatOnStarted +import poke.rogue.helper.presentation.util.serializable +import poke.rogue.helper.presentation.util.view.GridSpacingItemDecoration +import poke.rogue.helper.presentation.util.view.dp + +class TypeSelectionBottomSheetFragment : BottomSheetDialogFragment() { + private var _binding: FragmentTypeSelectionBottomSheetBinding? = null + private val binding get() = requireNotNull(_binding) + private val selectorType: SelectorType by lazy { + arguments?.serializable(KEY_SELECTOR_TYPE) + ?: error("InValid TypeSelector") + } + private val disabledTypes: Set by lazy { + arguments?.serializable>(KEY_DISABLED_TYPES)?.toSet() + ?: error("Invalid DisabledTypes") + } + + private val sharedViewModel by activityViewModels() + + private val typeSelectionAdapter by lazy { + TypeSelectionAdapter( + sharedViewModel.allTypes, + selectorType, + disabledTypes, + sharedViewModel, + ) + } + + override fun onStart() { + super.onStart() + val behavior = BottomSheetBehavior.from(requireView().parent as View) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentTypeSelectionBottomSheetBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initAdapter() + initObserver() + } + + private fun initAdapter() { + with(binding.rvTypeSelection) { + adapter = typeSelectionAdapter + + val spanCount = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) 6 else 4 + val gridLayoutManager = GridLayoutManager(requireContext(), spanCount) + layoutManager = gridLayoutManager + + val decoration = GridSpacingItemDecoration(spanCount = spanCount, spacing = 20.dp, includeEdge = false) + addItemDecoration(decoration) + } + } + + private fun initObserver() { + repeatOnStarted { + sharedViewModel.typeEvent.collect { + if (it is TypeEvent.HideSelection) { + dismiss() + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "type_selection_bottom_sheet_fragment" + private const val KEY_SELECTOR_TYPE = "selection_type" + private const val KEY_DISABLED_TYPES = "disabled_type" + + fun newInstance( + selectorType: SelectorType, + disabledTypes: Set, + ): TypeSelectionBottomSheetFragment { + return TypeSelectionBottomSheetFragment().withArgs { + putSerializable(KEY_SELECTOR_TYPE, selectorType) + putSerializable(KEY_DISABLED_TYPES, disabledTypes.toTypedArray()) + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionViewHolder.kt new file mode 100644 index 00000000..09680dd3 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/selection/TypeSelectionViewHolder.kt @@ -0,0 +1,23 @@ +package poke.rogue.helper.presentation.type.selection + +import androidx.recyclerview.widget.RecyclerView +import poke.rogue.helper.databinding.ItemTypeSelectionBinding +import poke.rogue.helper.presentation.type.TypeHandler +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel + +class TypeSelectionViewHolder( + private val binding: ItemTypeSelectionBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind( + typeItem: TypeUiModel, + selector: SelectorType, + disabledTypeSet: Set, + typeHandler: TypeHandler, + ) { + binding.typeItem = typeItem + binding.selector = selector + binding.isDisabled = typeItem in disabledTypeSet + binding.typeHandler = typeHandler + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/view/PokeParallelogramLayout.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/view/PokeParallelogramLayout.kt new file mode 100644 index 00000000..5a35716a --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/view/PokeParallelogramLayout.kt @@ -0,0 +1,152 @@ +package poke.rogue.helper.presentation.type.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.annotation.ColorInt +import androidx.annotation.Dimension +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.databinding.BindingAdapter +import poke.rogue.helper.R +import poke.rogue.helper.databinding.ItemTypeParallelogramBinding +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.util.context.colorOf +import poke.rogue.helper.presentation.util.view.dp +import kotlin.math.tan + +/** + * ```xml + * + * + * + * + * + * ``` + * */ +class PokeParallelogramLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + ) : ConstraintLayout(context, attrs, defStyle) { + private val boarderPaint: Paint = Paint() + private val contentPaint: Paint = Paint() + private var angleDegree: Double = 0.0 + private val binding: ItemTypeParallelogramBinding by lazy { + ItemTypeParallelogramBinding.inflate(LayoutInflater.from(context), this, true) + } + + init { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.Parallogram, + 0, + 0, + ).apply { + try { + val topAngle = getInteger(R.styleable.Parallogram_topAngle, 110) + require(topAngle >= 90) { "(topAngle=$topAngle) - ํ‰ํ–‰์‚ฌ๋ณ€ํ˜•์˜ ์œ— ๊ฐ๋„๋Š” 90๋„ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." } + initPaint( + contentColor = + getColor( + R.styleable.Parallogram_contentColor, + context.colorOf(android.R.color.transparent), + ), + borderColor = + getColor( + R.styleable.Parallogram_borderColor, + context.colorOf(android.R.color.transparent), + ), + borderWidth = + getDimension( + R.styleable.Parallogram_borderWidth, + 0f.dp, + ), + ) + angleDegree = topAngle.toDouble() - 90 + } finally { + recycle() + } + } + } + + override fun dispatchDraw(canvas: Canvas) { + val path = + Path().apply { + val offset = tan(Math.toRadians(angleDegree)).toFloat() * height + moveTo(offset, 0f) + lineTo(width.toFloat(), 0f) + lineTo(width.toFloat() - offset, height.toFloat()) + lineTo(0f, height.toFloat()) + lineTo(offset, 0f) + close() + } + canvas.drawPath(path, boarderPaint) + canvas.drawPath(path, contentPaint) + canvas.save() + canvas.clipPath(path) + super.dispatchDraw(canvas) + canvas.restore() + } + + private fun initPaint( + @ColorInt contentColor: Int, + @ColorInt borderColor: Int, + @Dimension(unit = Dimension.PX) borderWidth: Float, + ) { + contentPaint.apply { + color = contentColor + style = Paint.Style.FILL + strokeJoin = Paint.Join.ROUND + } + if (borderWidth == 0f) return + boarderPaint.apply { + isAntiAlias = true + color = borderColor + style = Paint.Style.STROKE + strokeWidth = borderWidth + } + } + + private fun bindType(type: TypeUiModel) { + binding.type = type + contentPaint.color = context.colorOf(type.typeColor) + invalidate() + } + + private fun bindListener(clickListener: OnClickListener) { + binding.btnTopRight.setOnClickListener(clickListener) + } + + companion object { + @JvmStatic + @BindingAdapter("typeSelection", "typeSelectionListener", requireAll = false) + fun setSelectedType( + view: PokeParallelogramLayout, + type: TypeUiModel?, + clickListener: OnClickListener?, + ) { + type?.let { view.bindType(it) } + clickListener?.let { view.bindListener(clickListener) } + } + } + } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/type/view/TypeChip.kt b/android/app/src/main/java/poke/rogue/helper/presentation/type/view/TypeChip.kt new file mode 100644 index 00000000..4c5dd016 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/type/view/TypeChip.kt @@ -0,0 +1,94 @@ +package poke.rogue.helper.presentation.type.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.annotation.Dimension +import androidx.core.content.res.use +import androidx.databinding.BindingAdapter +import poke.rogue.helper.R +import poke.rogue.helper.databinding.ItemTypeBinding +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.presentation.util.view.px + +class TypeChip + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : LinearLayout(context, attrs, defStyleAttr) { + private val binding: ItemTypeBinding = + ItemTypeBinding.inflate(LayoutInflater.from(context), this, true) + + init { + val attributes = context.obtainStyledAttributes(attrs, R.styleable.TypeChip) + attributes.use { + val iconSize = attributes.getDimension(R.styleable.TypeChip_iconSize, 18f) + val spacing = attributes.getDimension(R.styleable.TypeChip_spacing, 7f) + val nameSize = attributes.getDimension(R.styleable.TypeChip_nameSize, 8f).px + initViews(iconSize, spacing, nameSize) + } + } + + private fun initViews( + @Dimension iconSize: Float, + @Dimension spacing: Float, + @Dimension nameSize: Float, + ) = with(binding) { + orientation = HORIZONTAL + ivTypeNameIcon.layoutParams.apply { + width = iconSize.toInt() + height = iconSize.toInt() + } + spaceType.layoutParams.width = spacing.toInt() + tvTypeName.textSize = nameSize + } + + companion object { + @JvmStatic + @BindingAdapter("type", "PokemonTypeViewConfiguration") + fun setTypeUiConfiguration( + view: TypeChip, + typeUiModel: TypeUiModel, + typeViewConfiguration: PokemonTypeViewConfiguration, + ) { + with(view.binding) { + type = typeUiModel + viewConfiguration = typeViewConfiguration + } + } + + @JvmStatic + @BindingAdapter("layoutWidth") + fun setLayoutWidth( + view: View, + width: Int, + ) { + view.layoutParams.width = width + } + + @JvmStatic + @BindingAdapter("layoutHeight") + fun setLayoutHeight( + view: View, + height: Int, + ) { + view.layoutParams.height = height + } + } + + data class PokemonTypeViewConfiguration( + val nameSize: Int = 8.dp, + val iconSize: Int = 18.dp, + val width: Int = ViewGroup.LayoutParams.MATCH_PARENT, + val contentAlignment: Int = Gravity.CENTER, + val spacing: Int = 7.dp, + val hasBackGround: Boolean = false, + ) + } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/AnalyticsLoggerExtensions.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/AnalyticsLoggerExtensions.kt new file mode 100644 index 00000000..011ce71a --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/AnalyticsLoggerExtensions.kt @@ -0,0 +1,36 @@ +package poke.rogue.helper.presentation.util + +import androidx.databinding.ViewDataBinding +import poke.rogue.helper.analytics.AnalyticsEvent +import poke.rogue.helper.analytics.AnalyticsLogger + +fun AnalyticsLogger.logClickEvent(eventName: String) { + logEvent( + AnalyticsEvent( + type = AnalyticsEvent.Types.ACTION, + extras = + listOf( + AnalyticsEvent.Param(AnalyticsEvent.ParamKeys.ACTION_NAME, eventName), + ), + ), + ) +} + +fun AnalyticsLogger.logScreenView(clz: Class) { + logEvent( + AnalyticsEvent( + type = AnalyticsEvent.Types.SCREEN_VIEW, + extras = + listOf( + AnalyticsEvent.Param( + AnalyticsEvent.ParamKeys.SCREEN_NAME, + clz.simpleName // ex: FragmentPokemonDetailBindingImpl -> Screen_PokemonDetail + .replace("BindingImpl", "") + .replace("Fragment", "Screen_") + .replace("Activity", "Screen_") + .replace("Binding", ""), + ), + ), + ), + ) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/BundleExtensions.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/BundleExtensions.kt new file mode 100644 index 00000000..e7681b72 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/BundleExtensions.kt @@ -0,0 +1,14 @@ +package poke.rogue.helper.presentation.util + +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.BundleCompat +import java.io.Serializable + +inline fun Bundle.parcelable(key: String): T? { + return BundleCompat.getParcelable(this, key, T::class.java) +} + +inline fun Bundle.serializable(key: String): T? { + return BundleCompat.getSerializable(this, key, T::class.java) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/IntentExensions.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/IntentExensions.kt new file mode 100644 index 00000000..dc3cca38 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/IntentExensions.kt @@ -0,0 +1,14 @@ +package poke.rogue.helper.presentation.util + +import android.content.Intent +import android.os.Parcelable +import androidx.core.content.IntentCompat +import java.io.Serializable + +inline fun Intent.parcelable(key: String): T? { + return IntentCompat.getParcelableExtra(this, key, T::class.java) +} + +inline fun Intent.serializable(key: String): T? { + return IntentCompat.getSerializableExtra(this, key, T::class.java) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/LifecycleExtension.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/LifecycleExtension.kt new file mode 100644 index 00000000..c8a80303 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/LifecycleExtension.kt @@ -0,0 +1,34 @@ +package poke.rogue.helper.presentation.util + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch + +/** + * Lifecycle์— ๋งž๊ฒŒ ์•Œ์•„์„œ collect/cancel์„ ๋ฐ˜๋ณตํ•ด์ฃผ๊ฒŒ ํ•ด์ฃผ๋Š” ํ™•์žฅ ํ•จ์ˆ˜ + * + * @param block + */ +inline fun LifecycleOwner.repeatOnStarted(crossinline block: suspend () -> Unit) { + when (this) { + is AppCompatActivity -> { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + block() + } + } + } + + is Fragment -> { + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + block() + } + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/activity/ActivityExtension.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/activity/ActivityExtension.kt new file mode 100644 index 00000000..df929f35 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/activity/ActivityExtension.kt @@ -0,0 +1,22 @@ +package poke.rogue.helper.presentation.util.activity + +import android.app.Activity +import android.content.Context +import android.view.inputmethod.InputMethodManager + +fun Activity.hideKeyboard() { + val inputMethodManager = + getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + currentFocus?.let { view -> + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + view.clearFocus() + } +} + +fun Activity.show() { + val inputMethodManager = + getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + currentFocus?.let { view -> + inputMethodManager.showSoftInput(view, 0) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/context/ContextExtension.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/context/ContextExtension.kt new file mode 100644 index 00000000..24fef177 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/context/ContextExtension.kt @@ -0,0 +1,129 @@ +package poke.rogue.helper.presentation.util.context + +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.graphics.Point +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.view.View +import android.view.WindowInsets +import android.view.WindowManager +import android.widget.Toast +import androidx.annotation.ArrayRes +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.google.android.material.snackbar.Snackbar + +fun Context.toast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +fun Context.toast( + @StringRes messageRes: Int, +) { + Toast.makeText(this, messageRes, Toast.LENGTH_SHORT).show() +} + +fun Context.longToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() +} + +fun Context.longToast( + @StringRes messageRes: Int, +) { + Toast.makeText(this, messageRes, Toast.LENGTH_LONG).show() +} + +fun Context.snackBar( + anchorView: View, + message: () -> String, +) { + Snackbar.make(anchorView, message(), Snackbar.LENGTH_SHORT).show() +} + +fun Context.snackBar( + anchorView: View, + @StringRes messageRes: Int, +) { + Snackbar.make(anchorView, messageRes, Snackbar.LENGTH_SHORT).show() +} + +fun Context.stringOf( + @StringRes resId: Int, +) = getString(resId) + +fun Context.stringOf( + @StringRes resId: Int, + vararg formatArgs: Any?, +) = getString(resId, *formatArgs) + +fun Context.stringArrayOf( + @ArrayRes resId: Int, +): Array = resources.getStringArray(resId) + +fun Context.colorOf( + @ColorRes resId: Int, +) = ContextCompat.getColor(this, resId) + +fun Context.drawableOf( + @DrawableRes resId: Int, +) = ContextCompat.getDrawable(this, resId) + +fun Context.dialogWidthPercent( + dialog: Dialog?, + percent: Double = 0.8, +) { + val deviceSize = deviceSize() + dialog?.window?.run { + val params = attributes + params.width = (deviceSize[0] * percent).toInt() + attributes = params + } +} + +fun Context.deviceSize(): IntArray { + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager.currentWindowMetrics + val windowInsets = windowMetrics.windowInsets + + val insets = + windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() or WindowInsets.Type.displayCutout(), + ) + val insetsWidth = insets.right + insets.left + val insetsHeight = insets.top + insets.bottom + + val bounds = windowMetrics.bounds + + return intArrayOf(bounds.width() - insetsWidth, bounds.height() - insetsHeight) + } + val display = windowManager.defaultDisplay + val size = Point() + display?.getSize(size) + return intArrayOf(size.x, size.y) +} + +inline fun Context.startActivity(argusBuilder: Intent.() -> Unit = {}) { + startActivity(Intent(this, T::class.java).apply(argusBuilder)) +} + +fun Context.isNetworkConnected(): Boolean { + var isConnected = false + val cm = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) + if (capabilities != null) { + isConnected = + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport( + NetworkCapabilities.TRANSPORT_CELLULAR, + ) + } + return isConnected +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/event/EventFlow.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/event/EventFlow.kt new file mode 100644 index 00000000..0032caed --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/event/EventFlow.kt @@ -0,0 +1,47 @@ +package poke.rogue.helper.presentation.util.event + +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.concurrent.atomic.AtomicBoolean + +interface EventFlow : Flow { + companion object { + const val DEFAULT_BUFFER = 1 + } +} + +interface MutableEventFlow : EventFlow, FlowCollector + +@Suppress("FunctionName") +fun MutableEventFlow(capacity: Int = EventFlow.DEFAULT_BUFFER): MutableEventFlow = EventFlowImpl(capacity) + +fun MutableEventFlow.asEventFlow(): EventFlow = ReadOnlyEventFlow(this) + +private class ReadOnlyEventFlow(flow: EventFlow) : EventFlow by flow + +private class EventFlowImpl( + replay: Int, +) : MutableEventFlow { + private val flow: MutableSharedFlow> = MutableSharedFlow(replay = replay) + + @InternalCoroutinesApi + override suspend fun collect(collector: FlowCollector) = + flow + .collect { slot -> + if (!slot.markConsumed()) { + collector.emit(slot.value) + } + } + + override suspend fun emit(value: T) { + flow.emit(EventFlowSlot(value)) + } +} + +private class EventFlowSlot(val value: T) { + private val consumed: AtomicBoolean = AtomicBoolean(false) + + fun markConsumed(): Boolean = consumed.getAndSet(true) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/event/RefreshEventBus.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/event/RefreshEventBus.kt new file mode 100644 index 00000000..357b1dff --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/event/RefreshEventBus.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.presentation.util.event + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +object RefreshEventBus { + private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob()) + private val _event = MutableEventFlow() + val event: EventFlow = _event.asEventFlow() + + fun refresh() { + coroutineScope.launch { + _event.emit(Unit) + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/fragment/FragmentExtension.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/fragment/FragmentExtension.kt new file mode 100644 index 00000000..067778a6 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/fragment/FragmentExtension.kt @@ -0,0 +1,97 @@ +package poke.rogue.helper.presentation.util.fragment + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar + +fun Fragment.toast(message: String) { + if (!isAdded) return + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() +} + +fun Fragment.toast( + @StringRes messageRes: Int, +) { + if (!isAdded) return + Toast.makeText(requireContext(), messageRes, Toast.LENGTH_SHORT).show() +} + +fun Fragment.longToast(message: String) { + if (!isAdded) return + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() +} + +fun Fragment.longToast( + @StringRes messageRes: Int, +) { + if (!isAdded) return + Toast.makeText(requireContext(), messageRes, Toast.LENGTH_LONG).show() +} + +fun Fragment.snackBar( + anchorView: View, + message: () -> String, +) { + if (!isAdded) return + Snackbar.make(anchorView, message(), Snackbar.LENGTH_SHORT).show() +} + +fun Fragment.snackBar( + anchorView: View, + @StringRes messageRes: Int, +) { + if (!isAdded) return + Snackbar.make(anchorView, messageRes, Snackbar.LENGTH_SHORT).show() +} + +fun Fragment.stringOf( + @StringRes resId: Int, + formatArgs: Any? = null, +) = getString(resId, formatArgs) + +fun Fragment.stringOf( + @StringRes resId: Int, + vararg formatArgs: Any?, +) = getString(resId, *formatArgs) + +fun Fragment.colorOf( + @ColorRes resId: Int, +) = ContextCompat.getColor(requireContext(), resId) + +fun Fragment.drawableOf( + @DrawableRes resId: Int, +) = ContextCompat.getDrawable(requireContext(), resId) + +inline fun T.withArgs(argsBuilder: Bundle.() -> Unit): T { + return this.apply { + arguments = Bundle().apply(argsBuilder) + } +} + +val Fragment.viewLifeCycle + get() = viewLifecycleOwner.lifecycle + +val Fragment.viewLifeCycleScope + get() = viewLifecycleOwner.lifecycleScope + +inline fun Fragment.startActivity(argusBuilder: Intent.() -> Unit = {}) { + startActivity(Intent(requireContext(), T::class.java).apply(argusBuilder)) +} + +fun Fragment.hideKeyboard() { + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (imm.isAcceptingText) { + imm.hideSoftInputFromWindow(requireView().windowToken, 0) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/CommonBindingAdapter.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/CommonBindingAdapter.kt new file mode 100644 index 00000000..4a852b38 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/CommonBindingAdapter.kt @@ -0,0 +1,91 @@ +package poke.rogue.helper.presentation.util.view + +import android.view.View +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.databinding.BindingAdapter +import coil.ImageLoader +import coil.decode.SvgDecoder +import coil.request.ImageRequest +import com.bumptech.glide.Glide +import com.google.android.material.progressindicator.CircularProgressIndicator +import poke.rogue.helper.R +import poke.rogue.helper.presentation.util.context.colorOf + +@BindingAdapter("imageUrl") +fun ImageView.setImage(imageUrl: String?) { + Glide.with(context) + .load(imageUrl) + .placeholder(R.drawable.ic_pikachu_silhouette) + .error(R.drawable.ic_ditto_silhouette) + .into(this) +} + +@BindingAdapter("svgUrl") +fun ImageView.loadSvgFromUrl(url: String?) { + val imageLoader = + ImageLoader.Builder(context) + .components { add(SvgDecoder.Factory()) } + .build() + + val request = + ImageRequest.Builder(context) + .data(url) + .placeholder(R.drawable.ic_pikachu_silhouette) + .error(R.drawable.ic_ditto_silhouette) + .target(this) + .build() + + imageLoader.enqueue(request) +} + +@BindingAdapter("cropImageUrl") +fun ImageView.setCroppedImage(imageUrl: String?) { + Glide.with(context) + .load(imageUrl) + .placeholder(R.drawable.ic_pikachu_silhouette) + .error(R.drawable.ic_ditto_silhouette) + .into(this) +} + +@BindingAdapter("imageUrlWithProgress", "progressIndicator") +fun ImageView.loadImageWithProgress( + imageUrl: String?, + progressIndicator: CircularProgressIndicator, +) { + progressIndicator.visibility = View.VISIBLE + + Glide.with(context) + .load(imageUrl) + .listener(createProgressListener(progressIndicator)) + .error(R.drawable.ic_ditto_silhouette) + .into(this) +} + +@BindingAdapter("imageRes") +fun ImageView.setImage(imageRes: Int) { + setImageResource(imageRes) +} + +@BindingAdapter("backgroundColorRes") +fun View.setBackGroundColorRes( + @ColorRes backgroundColorRes: Int, +) { + if (backgroundColorRes == 0) return + setBackgroundColor(context.colorOf(backgroundColorRes)) +} + +@BindingAdapter("visible") +fun View.setVisible(visible: Boolean) { + visibility = if (visible) View.VISIBLE else View.GONE +} + +@BindingAdapter("backgroundTintRes") +fun View.setBackgroundTint( + @ColorRes colorRes: Int?, +) { + colorRes?.let { + backgroundTintList = ContextCompat.getColorStateList(context, it) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/CropMarginTransformation.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/CropMarginTransformation.kt new file mode 100644 index 00000000..bb3b604c --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/CropMarginTransformation.kt @@ -0,0 +1,65 @@ +package poke.rogue.helper.presentation.util.view + +import android.graphics.Bitmap +import android.graphics.Color +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import java.security.MessageDigest + +class CropMarginTransformation() : + BitmapTransformation() { + override fun transform( + pool: BitmapPool, + toTransform: Bitmap, + outWidth: Int, + outHeight: Int, + ): Bitmap { + return noMarginBitmap(toTransform) + } + + private fun noMarginBitmap(original: Bitmap): Bitmap { + val width = original.width + val height = original.height + val pixels = IntArray(width * height) + original.getPixels(pixels, 0, width, 0, 0, width, height) + var minX = width + var minY = height + var maxX = 0 + var maxY = 0 + + for (y in 0 until height) { + for (x in 0 until width) { + val currentColor = pixels[y * width + x] + if (currentColor.hasColor()) { + minX = minX.coerceAtMost(x) + minY = minY.coerceAtMost(y) + maxX = maxX.coerceAtLeast(x) + maxY = maxY.coerceAtLeast(y) + } + } + } + + return Bitmap.createBitmap(original, minX, minY, maxX - minX + 1, maxY - minY + 1) + } + + private fun Int.hasColor() = (this != Color.TRANSPARENT) + + override fun equals(other: Any?): Boolean { + return other is CropMarginTransformation + } + + override fun hashCode(): Int { + return ID.hashCode() + } + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(ID_BYTES) + } + + companion object { + private val ID: String = + CropMarginTransformation::class.java.canonicalName + ?: CropMarginTransformation::class.java.simpleName + private val ID_BYTES = ID.toByteArray(Charsets.UTF_8) + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt new file mode 100644 index 00000000..c15574ac --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/DimensionUtils.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.presentation.util.view + +import android.content.res.Resources + +val density = Resources.getSystem().displayMetrics.density + +// TODO : move to ui/unit/Dimension.kt + +val Int.dp + get(): Int = (density * this).toInt() + +val Float.dp + get(): Float = density * this + +val Int.px + get(): Int = (this / density).toInt() + +val Float.px + get(): Float = this / density diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/GridSpacingItemDecoration.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/GridSpacingItemDecoration.kt new file mode 100644 index 00000000..de2d5779 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/GridSpacingItemDecoration.kt @@ -0,0 +1,40 @@ +package poke.rogue.helper.presentation.util.view + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class GridSpacingItemDecoration( + private val spanCount: Int, + private val spacing: Int, + private val includeEdge: Boolean, +) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + val itemPosition = parent.getChildAdapterPosition(view) + val itemColumn = itemPosition % spanCount + + if (!includeEdge) { + outRect.left = + spacing - itemColumn * spacing / spanCount + outRect.right = + (itemColumn + 1) * spacing / spanCount + + if (itemPosition < spanCount) { + outRect.top = spacing + } + outRect.bottom = spacing + } else { + outRect.left = itemColumn * spacing / spanCount + outRect.right = + spacing - (itemColumn + 1) * spacing / spanCount + if (itemPosition >= spanCount) { + outRect.top = spacing + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ItemDiffCallback.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ItemDiffCallback.kt new file mode 100644 index 00000000..cbaa5ea3 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ItemDiffCallback.kt @@ -0,0 +1,38 @@ +package poke.rogue.helper.presentation.util.view + +import androidx.recyclerview.widget.DiffUtil + +/** + * [DiffUtil.ItemCallback] ๋Š” ๋‘ ๋ฆฌ์ŠคํŠธ์˜ ์•„์ดํ…œ์„ ๋น„๊ตํ•˜์—ฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + * + * @param T ์•„์ดํ…œ ํƒ€์ž… + * @param onItemsTheSame ์•„์ดํ…œ์ด ๊ฐ™์€์ง€ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๋Š” ํ•จ์ˆ˜ + * @param onContentsTheSame ์•„์ดํ…œ์˜ ๋‚ด์šฉ์ด ๊ฐ™์€์ง€ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๋Š” ํ•จ์ˆ˜ + * + * ```kotlin + * data class Item(val id: Int, val name: String) + * + * class MyRecyclerViewAdapter : ListAdapter(itemComparator) { + * companion object { + * val itemComparator = ItemDiffCallback( + * onItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id }, + * onContentsTheSame = { oldItem, newItem -> oldItem == newItem } + * ) + * } + * } + * ``` + */ +class ItemDiffCallback( + val onItemsTheSame: (T, T) -> Boolean, + val onContentsTheSame: (T, T) -> Boolean, +) : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: T, + newItem: T, + ): Boolean = onItemsTheSame(oldItem, newItem) + + override fun areContentsTheSame( + oldItem: T, + newItem: T, + ): Boolean = onContentsTheSame(oldItem, newItem) +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/LinearSpacingItemDecoration.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/LinearSpacingItemDecoration.kt new file mode 100644 index 00000000..614289db --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/LinearSpacingItemDecoration.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.util.view + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class LinearSpacingItemDecoration( + private val spacing: Int, + private val includeEdge: Boolean = true, + private val orientation: Orientation = Orientation.VERTICAL, +) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + val position = parent.getChildAdapterPosition(view) + if (orientation == Orientation.VERTICAL) { + outRect.top = if (includeEdge || position != 0) spacing else 0 + outRect.bottom = spacing + return + } + outRect.left = if (includeEdge || position != 0) spacing else 0 + outRect.right = spacing + } + + enum class Orientation { + HORIZONTAL, + VERTICAL, + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/NestedScrollableHost.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/NestedScrollableHost.kt new file mode 100644 index 00000000..ea09afe5 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/NestedScrollableHost.kt @@ -0,0 +1,99 @@ +package poke.rogue.helper.presentation.util.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.FrameLayout +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL +import kotlin.math.absoluteValue +import kotlin.math.sign + +/** + * Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem + * where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as + * ViewPager2. The scrollable element needs to be the immediate and only child of this host layout. + * + * This solution has limitations when using multiple levels of nested scrollable elements + * (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2). + */ +class NestedScrollableHost : FrameLayout { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + private var touchSlop = 0 + private var initialX = 0f + private var initialY = 0f + private val parentViewPager: ViewPager2? + get() { + var v: View? = parent as? View + while (v != null && v !is ViewPager2) { + v = v.parent as? View + } + return v as? ViewPager2 + } + + private val child: View? get() = if (childCount > 0) getChildAt(0) else null + + init { + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + } + + private fun canChildScroll( + orientation: Int, + delta: Float, + ): Boolean { + val direction = -delta.sign.toInt() + return when (orientation) { + 0 -> child?.canScrollHorizontally(direction) ?: false + 1 -> child?.canScrollVertically(direction) ?: false + else -> throw IllegalArgumentException() + } + } + + override fun onInterceptTouchEvent(e: MotionEvent): Boolean { + handleInterceptTouchEvent(e) + return super.onInterceptTouchEvent(e) + } + + private fun handleInterceptTouchEvent(e: MotionEvent) { + val orientation = parentViewPager?.orientation ?: return + + // Early return if child can't scroll in same direction as parent + if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) { + return + } + + if (e.action == MotionEvent.ACTION_DOWN) { + initialX = e.x + initialY = e.y + parent.requestDisallowInterceptTouchEvent(true) + } else if (e.action == MotionEvent.ACTION_MOVE) { + val dx = e.x - initialX + val dy = e.y - initialY + val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL + + // assuming ViewPager2 touch-slop is 2x touch-slop of child + val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f + val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f + + if (scaledDx > touchSlop || scaledDy > touchSlop) { + if (isVpHorizontal == (scaledDy > scaledDx)) { + // Gesture is perpendicular, allow all parents to intercept + parent.requestDisallowInterceptTouchEvent(false) + } else { + // Gesture is parallel, query child if movement in that direction is possible + if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { + // Child can scroll, disallow all parents to intercept + parent.requestDisallowInterceptTouchEvent(true) + } else { + // Child cannot scroll, allow all parents to intercept + parent.requestDisallowInterceptTouchEvent(false) + } + } + } + } + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ProgressListener.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ProgressListener.kt new file mode 100644 index 00000000..bfa72ac1 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ProgressListener.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.util.view + +import android.graphics.drawable.Drawable +import android.view.View +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.google.android.material.progressindicator.CircularProgressIndicator + +fun createProgressListener(progressIndicator: CircularProgressIndicator): RequestListener = + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + progressIndicator.visibility = View.GONE + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + progressIndicator.visibility = View.GONE + return false + } + } diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/SpannableStringUtils.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/SpannableStringUtils.kt new file mode 100644 index 00000000..be597631 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/SpannableStringUtils.kt @@ -0,0 +1,20 @@ +package poke.rogue.helper.presentation.util.view + +import android.content.Context +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.text.style.TextAppearanceSpan +import androidx.annotation.StyleRes +import androidx.core.text.inSpans + +inline fun SpannableStringBuilder.font( + typeface: Typeface? = null, + builderAction: SpannableStringBuilder.() -> Unit, +) = inSpans(StyleSpan(typeface?.style ?: Typeface.DEFAULT.style), builderAction = builderAction) + +inline fun SpannableStringBuilder.textAppearance( + context: Context, + @StyleRes style: Int, + builderAction: SpannableStringBuilder.() -> Unit, +) = inSpans(TextAppearanceSpan(context, style), builderAction = builderAction) diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewExtension.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewExtension.kt new file mode 100644 index 00000000..e56c5577 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewExtension.kt @@ -0,0 +1,13 @@ +package poke.rogue.helper.presentation.util.view + +import android.view.inputmethod.EditorInfo +import android.widget.EditText + +fun EditText.setOnSearchAction(action: () -> Unit) { + setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + action() + } + true + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt new file mode 100644 index 00000000..8e68dad7 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/presentation/util/view/ViewInteractionUtils.kt @@ -0,0 +1,42 @@ +package poke.rogue.helper.presentation.util.view + +import android.view.View +import androidx.databinding.BindingAdapter +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +// TODO : move to ui/intereaction/SingleClick.kt + +@BindingAdapter("duration", "onSingleClick", requireAll = false) +fun View.setOnSingleClickListener( + duration: Int = 500, + listener: View.OnClickListener?, +) { + val throttleDuration = if (duration == 0) 500 else duration + val singleEventHandler: SingleEventHandler = + DefaultSingleEventHandler(throttleDuration.milliseconds) + setOnClickListener { view -> + singleEventHandler.handle { + listener?.onClick(view) + } + } +} + +fun interface SingleEventHandler { + fun handle(event: () -> Unit) +} + +class DefaultSingleEventHandler(private val throttleDuration: Duration = 500.milliseconds) : + SingleEventHandler { + private val currentTime: TimeMark get() = TimeSource.Monotonic.markNow() + private lateinit var lastEventTime: TimeMark + + override fun handle(event: () -> Unit) { + if (::lastEventTime.isInitialized.not() || (lastEventTime + throttleDuration).hasPassedNow()) { + event() + } + lastEventTime = currentTime + } +} diff --git a/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt new file mode 100644 index 00000000..c1ca449e --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChip.kt @@ -0,0 +1,346 @@ +package poke.rogue.helper.ui.component + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Space +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.Dimension +import androidx.annotation.Dimension.Companion.DP +import androidx.annotation.Dimension.Companion.PX +import androidx.annotation.DrawableRes +import androidx.core.content.res.use +import androidx.core.graphics.ColorUtils +import androidx.databinding.BindingAdapter +import poke.rogue.helper.R +import poke.rogue.helper.presentation.util.context.colorOf +import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.presentation.util.view.setOnSingleClickListener +import poke.rogue.helper.ui.layout.PaddingValues +import poke.rogue.helper.ui.layout.applyTo + +class PokeChip + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : LinearLayout(context, attrs, defStyleAttr) { + var chipId: Int = NO_ID + private set + + private var leadingIcon: ImageView = ImageView(context) + private var leadingSpacer: Space = Space(context) + private val label: TextView = TextView(context) + private var trailingIcon: ImageView = ImageView(context) + private var trailingSpacer: Space = Space(context) + + init { + context.obtainStyledAttributes(attrs, R.styleable.PokeChip).use { attributes -> + attributes.apply { + val leadingIcon = getResourceId(R.styleable.PokeChip_leadingIcon, NO_ICON) + val leadingIconSize = + getDimensionPixelSize(R.styleable.PokeChip_leadingIconSize, 24.dp) + val leadingSpacing = + getDimensionPixelSize(R.styleable.PokeChip_leadingSpacing, 8.dp) + + val label: String = getString(R.styleable.PokeChip_label) ?: "" + val labelSize = getDimension(R.styleable.PokeChip_labelSize, 16f) + + val trailingIcon = getResourceId(R.styleable.PokeChip_trailingIcon, NO_ICON) + val trailingIconSize = + getDimensionPixelSize(R.styleable.PokeChip_trailingIconSize, 24.dp) + val trailingSpacing = + getDimensionPixelSize(R.styleable.PokeChip_trailingSpacing, 4.dp) + + val labelColor = + getColor( + R.styleable.PokeChip_labelColor, + context.colorOf(R.color.poke_chip_text_default), + ) + val containerColor = + getColor( + R.styleable.PokeChip_containerColor, + context.colorOf(R.color.poke_chip_background_default), + ) + val strokeColor = + getColor( + R.styleable.PokeChip_strokeColor, + context.colorOf(R.color.poke_chip_stroke_default), + ) + val selectedLabelColor = + getColor( + R.styleable.PokeChip_selectedLabelColor, + context.colorOf(R.color.poke_chip_text_selected), + ) + val selectedStrokeColor = + getColor( + R.styleable.PokeChip_selectedStrokeColor, + context.colorOf(R.color.poke_chip_stroke_selected), + ) + val selectedContainerColor = + getColor( + R.styleable.PokeChip_selectedContainerColor, + context.colorOf(R.color.poke_chip_background_selected), + ) + + val cornerRadius = getDimensionPixelSize(R.styleable.PokeChip_cornerRadius, 10.dp) + val strokeWidth = getDimensionPixelSize(R.styleable.PokeChip_strokeWidth, 1.dp) + // init views + initLeadingIcon(leadingIcon, leadingIconSize, leadingSpacing, label.isNotBlank()) + initLabel(label, labelSize) + initTrailingIcon( + trailingIcon, + trailingIconSize, + trailingSpacing, + label.isNotBlank(), + ) + initDrawableBackground( + containerColor, + selectedContainerColor, + strokeColor, + selectedStrokeColor, + cornerRadius, + strokeWidth, + ) + initLabelColor(labelColor, selectedLabelColor) + initLayout() + } + } + } + + private fun initPokeChip(chipSpec: Spec) { + removeAllViews() + chipId = chipSpec.id + isSelected = chipSpec.isSelected + val (leadingIconSize, leadingSpacing, labelSize, trailingSpacing, trailingIconSize) = + chipSpec.sizes + val colorSpec = chipSpec.colors + initLeadingIcon( + chipSpec.leadingIconRes ?: NO_ICON, + leadingIconSize, + leadingSpacing, + chipSpec.label.isNotBlank(), + ) + initLabel(chipSpec.label, labelSize.toFloat()) + initTrailingIcon( + chipSpec.trailingIconRes ?: NO_ICON, + trailingIconSize, + trailingSpacing, + chipSpec.label.isNotBlank(), + ) + initDrawableBackground( + containerColor = context.colorOf(colorSpec.containerColor), + selectedContainerColor = context.colorOf(colorSpec.selectedContainerColor), + strokeColor = context.colorOf(colorSpec.strokeColor), + selectedStrokeColor = context.colorOf(colorSpec.selectedStrokeColor), + cornerRadius = chipSpec.cornerRadius, + strokeWidth = chipSpec.strokeWidth, + ) + initLabelColor( + labelColor = context.colorOf(colorSpec.labelColor), + selectedLabelColor = context.colorOf(colorSpec.selectedLabelColor), + ) + initLayout(chipSpec.padding) + setOnSingleClickListener(duration = 200) { + chipSpec.onSelect?.invoke(chipId) + } + } + + private fun initLayout(padding: PaddingValues = PaddingValues(8.dp)) { + gravity = Gravity.CENTER_VERTICAL + if (paddingStart == 0 && paddingTop == 0 && paddingEnd == 0 && paddingBottom == 0) { + padding.applyTo(this) + } + } + + private fun initLeadingIcon( + @DrawableRes leadingIconRes: Int, + @Dimension(DP) leadingIconSize: Int, + @Dimension(DP) leadingSpacing: Int, + hasLabel: Boolean, + ) { + leadingIcon.setImageResource(leadingIconRes) + leadingIcon.layoutParams = LayoutParams(leadingIconSize, leadingIconSize) + leadingSpacer.layoutParams = LayoutParams(leadingSpacing, LayoutParams.WRAP_CONTENT) + if (leadingIconRes != NO_ICON) { + addView(leadingIcon) + if (hasLabel) addView(leadingSpacer) + } + } + + private fun initLabel( + label: String, + @Dimension(PX) labelSize: Float, + ) { + this.label.text = label + this.label.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + this.label.textSize = labelSize + this.label.paint.isFakeBoldText = isSelected + if (label.isNotBlank()) { + addView(this.label) + } + } + + private fun initTrailingIcon( + @DrawableRes trailingIconRes: Int, + @Dimension(DP) trailingIconSize: Int, + @Dimension(DP) trailingSpacing: Int, + hasLabel: Boolean, + ) { + trailingIcon.setImageResource(trailingIconRes) + trailingIcon.layoutParams = LayoutParams(trailingIconSize, trailingIconSize) + trailingSpacer.layoutParams = LayoutParams(trailingSpacing, LayoutParams.WRAP_CONTENT) + if (trailingIconRes != NO_ICON) { + if (hasLabel) addView(trailingSpacer) + addView(trailingIcon) + } + } + + private fun initDrawableBackground( + @ColorInt containerColor: Int, + @ColorInt selectedContainerColor: Int, + @ColorInt strokeColor: Int, + @ColorInt selectedStrokeColor: Int, + @Dimension(DP) cornerRadius: Int = 10.dp, + @Dimension(DP) strokeWidth: Int = 1.dp, + ) { + val unSelectedDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + this.cornerRadius = cornerRadius.toFloat() + setColor(containerColor) + setStroke(strokeWidth, strokeColor) + } + + val selectedDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + this.cornerRadius = cornerRadius.toFloat() + setColor(selectedContainerColor) + setStroke(strokeWidth, selectedStrokeColor) + } + + val states = + arrayOf( + intArrayOf(android.R.attr.state_selected), + intArrayOf(), + ) + val stateListDrawable = + StateListDrawable().apply { + addState(states[0], selectedDrawable) + addState(states[1], unSelectedDrawable) + } + val rippleColor = ColorUtils.setAlphaComponent(selectedContainerColor, 128) + this.background = + RippleDrawable( + ColorStateList.valueOf(rippleColor), + stateListDrawable, + null, + ) + } + + private fun initLabelColor( + @ColorInt labelColor: Int, + @ColorInt selectedLabelColor: Int, + ) { + val textColorList = + ColorStateList( + arrayOf( + intArrayOf(), + intArrayOf(android.R.attr.state_selected), + ), + intArrayOf( + labelColor, + selectedLabelColor, + ), + ) + label.setTextColor(textColorList) + } + + data class Spec( + val id: Int = NO_ID, + val label: String, + @DrawableRes val leadingIconRes: Int? = null, + @DrawableRes val trailingIconRes: Int? = null, + val colors: Colors = Colors(), + val sizes: Sizes = Sizes(), + val padding: PaddingValues = PaddingValues(8.dp), + @Dimension(DP) val strokeWidth: Int = 1.dp, + @Dimension(DP) val cornerRadius: Int = 10.dp, + val isSelected: Boolean = false, + val onSelect: ((chipId: Int) -> Unit)? = null, + ) { + init { + require(leadingIconRes != null || label.isNotBlank()) { + "leadingIconRes ์™€ label ์ค‘ ํ•˜๋‚˜๋Š” ๋ฐ˜๋“œ์‹œ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + } + } + + companion object { + // TODO: ๋ฐ”์ด์˜ด ์ชฝ์—์„œ๋Š” pokeChip ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. + // ๊ทธ๋Ÿฐ๋ฐ ๋ฐ”์ด์˜ด ์ชฝ์—์„œ ํฌ์ผ“๋ชฌ UI ๋ฅผ pokeChip ์„ ์‚ฌ์šฉํ•œ๋‹ค. + // ์ด ๋•Œ๋ฌธ์— ๋ฐ”์ด์˜ด ์ƒ์„ธ์—์„œ ์—๋Ÿฌ๊ฐ€ ํ„ฐ์ง„๋‹ค. + // PokeChip.bindPokeChip ๋ฉ”์„œ๋“œ ์ฐธ๊ณ  + val EMPTY = + Spec( + label = "INVALID_LABEL", + ) + } + } + + data class Colors( + @ColorRes val labelColor: Int = R.color.poke_chip_text_default, + @ColorRes val strokeColor: Int = R.color.poke_chip_stroke_default, + @ColorRes val containerColor: Int = R.color.poke_chip_background_default, + @ColorRes val selectedLabelColor: Int = R.color.poke_chip_text_selected, + @ColorRes val selectedStrokeColor: Int = R.color.poke_chip_stroke_selected, + @ColorRes val selectedContainerColor: Int = R.color.poke_chip_background_selected, + ) + + data class Sizes( + @Dimension(DP) val leadingIconSize: Int = 24.dp, + @Dimension(DP) val leadingSpacing: Int = 8.dp, + @Dimension(PX) val labelSize: Int = 16, + @Dimension(DP) val trailingSpacing: Int = 4.dp, + @Dimension(DP) val trailingIconSize: Int = 24.dp, + ) { + init { + require(leadingIconSize >= 0) { "leadingIconSize can't be negative" } + require(leadingSpacing >= 0) { "leadingSpacing can't be negative" } + require(trailingIconSize >= 0) { "trailingIconSize can't be negative" } + require(trailingSpacing >= 0) { "trailingSpacing can't be negative" } + require(labelSize >= 0) { "labelSize can't be negative" } + } + } + + companion object { + private const val NO_ICON = 0 + private const val NO_ID = -1 + + @JvmStatic + @BindingAdapter("pokeChipSpec") + fun PokeChip.bindPokeChip(chipSpec: Spec) { + if (chipSpec == Spec.EMPTY) { + return + } + initPokeChip(chipSpec) + } + +// fun PokeChip.bindPokeChip(chipSpec: Spec?) { +// chipSpec?.let { initPokeChip(it) } +// if (chipSpec == null) { +// visibility = GONE +// } +// } + } + } diff --git a/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt new file mode 100644 index 00000000..1da639ec --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/ui/component/PokeChipGroup.kt @@ -0,0 +1,163 @@ +package poke.rogue.helper.ui.component + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RectShape +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.Space +import androidx.annotation.Dimension +import androidx.annotation.Dimension.Companion.DP +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayout +import com.google.android.flexbox.JustifyContent +import poke.rogue.helper.R +import poke.rogue.helper.presentation.util.context.colorOf +import poke.rogue.helper.presentation.util.view.dp +import poke.rogue.helper.ui.component.PokeChip.Companion.bindPokeChip + +class PokeChipGroup + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : FlexboxLayout(context, attrs, defStyleAttr) { + private val chipViews = mutableListOf() + var direction: PokeChipGroupDirection = PokeChipGroupDirection.ROW + var itemSpacing: Int = 0 + var lineSpacing: Int = 0 + + init { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.PokeChipGroup, + 0, + 0, + ).apply { + try { + direction = + PokeChipGroupDirection.from(getInt(R.styleable.PokeChipGroup_direction, 0)) + itemSpacing = getDimensionPixelSize(R.styleable.PokeChipGroup_itemSpacing, 0.dp) + lineSpacing = getDimensionPixelSize(R.styleable.PokeChipGroup_lineSpacing, 0.dp) + } finally { + recycle() + } + } + + flexWrap = FlexWrap.WRAP + alignItems = AlignItems.CENTER + flexDirection = direction.toFlexDirection() + justifyContent = JustifyContent.FLEX_START + val horizontalDivider = spacingDrawable(lineSpacing) + setDividerDrawable(horizontalDivider) + setShowDivider(SHOW_DIVIDER_MIDDLE) + } + + fun submitList( + specs: List, + onSelect: ((chipId: Int) -> Unit)? = null, + ) { + if (chipViews.isEmpty()) { + addChips(specs, onSelect) + } else { + updateChips(specs) + } + } + + private fun addChips( + specs: List, + onSelect: ((chipId: Int) -> Unit)?, + ) { + removeAllViews() + chipViews.clear() + specs.forEach { spec -> + addChip(spec, chipViews.toList(), onSelect) + } + } + + private fun addChip( + spec: PokeChip.Spec, + originalChipViews: List, + onSelect: ((chipId: Int) -> Unit)?, + ) { + require(originalChipViews.any { it.chipId == spec.id }.not()) { + "id=${spec.id}์ธ chip์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค." + } + val chip = PokeChip(context) + onSelect?.let { + chip.setOnClickListener { onSelect(spec.id) } + } + chip.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + chip.bindPokeChip(spec) + addView(chip) + val spacer = Space(context) + spacer.layoutParams = LinearLayout.LayoutParams(itemSpacing, itemSpacing) + addView(spacer) + chipViews.add(chip) + } + + private fun updateChips(specs: List) { + require(chipViews.all { chip -> specs.any { it.id == chip.chipId } }) { + "์—…๋ฐ์ดํŠธํ•  chip์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + } + + specs.forEach { spec -> + chipViews.find { it.chipId == spec.id }?.bindPokeChip(spec) + } + } + + private fun spacingDrawable( + @Dimension(unit = DP) height: Int, + ): Drawable { + val drawable = ShapeDrawable(RectShape()) + drawable.intrinsicHeight = height + drawable.paint.color = context.colorOf(android.R.color.transparent) + return drawable + } + + data class PokeChipGroupSpec( + val direction: PokeChipGroupDirection = PokeChipGroupDirection.ROW, + @Dimension(DP) val itemSpacing: Int, + @Dimension(DP) val lineSpacing: Int, + ) { + init { + require(itemSpacing >= 0) { + "chip ์‚ฌ์ด ๊ฐ„๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + } + require(lineSpacing >= 0) { + "line ์‚ฌ์ด ๊ฐ„๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + } + } + } + + enum class PokeChipGroupDirection { + ROW, + COLUMN, + ; + + fun toFlexDirection(): Int { + return when (this) { + ROW -> FlexDirection.ROW + COLUMN -> FlexDirection.COLUMN + } + } + + companion object { + fun from(value: Int): PokeChipGroupDirection { + return when (value) { + 0 -> ROW + 1 -> COLUMN + else -> error("PokeChipGroupDirection - Unknown value: $value") + } + } + } + } + } diff --git a/android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt b/android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt new file mode 100644 index 00000000..698a3b5b --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/ui/layout/PaddingValues.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.ui.layout + +import android.view.View +import androidx.annotation.Dimension +import androidx.annotation.Dimension.Companion.DP +import poke.rogue.helper.presentation.util.view.dp + +data class PaddingValues( + @Dimension(DP) val start: Int = 0.dp, + @Dimension(DP) val top: Int = 0.dp, + @Dimension(DP) val end: Int = 0.dp, + @Dimension(DP) val bottom: Int = 0.dp, +) { + constructor(horizontal: Int, vertical: Int) : this( + horizontal, + vertical, + horizontal, + vertical, + ) + + constructor(all: Int) : this(all, all, all, all) + + init { + require(start >= 0) { "start padding can't be negative" } + require(top >= 0) { "top padding can't be negative" } + require(end >= 0) { "end padding can't be negative" } + require(bottom >= 0) { "bottom padding can't be negative" } + } +} + +fun PaddingValues.applyTo(view: View) { + view.setPadding(start, top, end, bottom) +} diff --git a/android/app/src/main/java/poke/rogue/helper/update/UpdateManager.kt b/android/app/src/main/java/poke/rogue/helper/update/UpdateManager.kt new file mode 100644 index 00000000..2910efd2 --- /dev/null +++ b/android/app/src/main/java/poke/rogue/helper/update/UpdateManager.kt @@ -0,0 +1,96 @@ +package poke.rogue.helper.update + +import android.content.Context +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability +import timber.log.Timber + +class UpdateManager( + private val context: Context, + private val onDownloadComplete: () -> Unit, +) : DefaultLifecycleObserver { + private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(context) + private val updateType = AppUpdateType.FLEXIBLE + private val sharedPreferences = context.getSharedPreferences(UPDATE_TIME, Context.MODE_PRIVATE) + + private val installStateUpdateListener = + InstallStateUpdatedListener { state -> + when (state.installStatus()) { + InstallStatus.INSTALLING -> Timber.i("Update is downloading") + + InstallStatus.DOWNLOADED -> { + onDownloadComplete() + } + + InstallStatus.CANCELED -> Timber.e("Update was cancelled") + } + } + + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + registerInstallStateUpdateListener() + } + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + unregisterInstallStateUpdateListener() + } + + fun checkForAppUpdates(appUpdateLauncher: ActivityResultLauncher) { + if (!isTimeToShowUpdateDialog()) { + return + } + + appUpdateManager.appUpdateInfo.addOnSuccessListener { info -> + if (shouldAppUpdate(info)) { + val updateOptions = AppUpdateOptions.newBuilder(updateType).build() + appUpdateManager.startUpdateFlowForResult( + info, + appUpdateLauncher, + updateOptions, + ) + sharedPreferences.edit().putLong(KEY_DIALOG, System.currentTimeMillis()).apply() + } + } + } + + private fun shouldAppUpdate(info: AppUpdateInfo): Boolean { + val isUpdateAvailable = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE + val isUpdateAllowed = info.isUpdateTypeAllowed(updateType) + return isUpdateAvailable && isUpdateAllowed + } + + private fun isTimeToShowUpdateDialog(): Boolean { + val lastDialogTime = sharedPreferences.getLong(KEY_DIALOG, 0L) + val currentTime = System.currentTimeMillis() + return currentTime - lastDialogTime > UPDATE_DIALOG_INTERVAL + } + + fun completeUpdate() { + appUpdateManager.completeUpdate() + } + + private fun registerInstallStateUpdateListener() { + appUpdateManager.registerListener(installStateUpdateListener) + } + + private fun unregisterInstallStateUpdateListener() { + appUpdateManager.unregisterListener(installStateUpdateListener) + } + + companion object { + private const val UPDATE_TIME = "update_time" + private const val KEY_DIALOG = "LAST_UPDATE_DIALOG" + const val UPDATE_DIALOG_INTERVAL = 24 * 60 * 60 * 1000 + } +} diff --git a/android/app/src/main/res/anim/from_bottom.xml b/android/app/src/main/res/anim/from_bottom.xml new file mode 100644 index 00000000..9b794187 --- /dev/null +++ b/android/app/src/main/res/anim/from_bottom.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/anim/rotate_close.xml b/android/app/src/main/res/anim/rotate_close.xml new file mode 100644 index 00000000..c96ecbed --- /dev/null +++ b/android/app/src/main/res/anim/rotate_close.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/anim/rotate_open.xml b/android/app/src/main/res/anim/rotate_open.xml new file mode 100644 index 00000000..cdc7a2ae --- /dev/null +++ b/android/app/src/main/res/anim/rotate_open.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/anim/to_bottom.xml b/android/app/src/main/res/anim/to_bottom.xml new file mode 100644 index 00000000..6bc46599 --- /dev/null +++ b/android/app/src/main/res/anim/to_bottom.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_ability_title.xml b/android/app/src/main/res/drawable/bg_ability_title.xml new file mode 100644 index 00000000..ff41c920 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_ability_title.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_battle_default.xml b/android/app/src/main/res/drawable/bg_battle_default.xml new file mode 100644 index 00000000..7e5f6697 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_battle_default.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_battle_navigation_button.xml b/android/app/src/main/res/drawable/bg_battle_navigation_button.xml new file mode 100644 index 00000000..d1f10b02 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_battle_navigation_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/bg_battle_selected_border.xml b/android/app/src/main/res/drawable/bg_battle_selected_border.xml new file mode 100644 index 00000000..f8be55a2 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_battle_selected_border.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_battle_selection.xml b/android/app/src/main/res/drawable/bg_battle_selection.xml new file mode 100644 index 00000000..7e5f6697 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_battle_selection.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_before_button.xml b/android/app/src/main/res/drawable/bg_before_button.xml new file mode 100644 index 00000000..67c5e19c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_before_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_button_enabled_selector.xml b/android/app/src/main/res/drawable/bg_button_enabled_selector.xml new file mode 100644 index 00000000..93a5bcc0 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_button_enabled_selector.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_search_view.xml b/android/app/src/main/res/drawable/bg_search_view.xml new file mode 100644 index 00000000..46889101 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_search_view.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_spinner.xml b/android/app/src/main/res/drawable/bg_spinner.xml new file mode 100644 index 00000000..cf160519 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_spinner.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_tab_background.xml b/android/app/src/main/res/drawable/bg_tab_background.xml new file mode 100644 index 00000000..9b29a636 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_tab_background.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_type_choice_container.xml b/android/app/src/main/res/drawable/bg_type_choice_container.xml new file mode 100644 index 00000000..c90d9502 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_type_choice_container.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_type_selection_bottom_sheet.xml b/android/app/src/main/res/drawable/bg_type_selection_bottom_sheet.xml new file mode 100644 index 00000000..4b53d3b0 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_type_selection_bottom_sheet.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_type_selection_item.xml b/android/app/src/main/res/drawable/bg_type_selection_item.xml new file mode 100644 index 00000000..79052fdd --- /dev/null +++ b/android/app/src/main/res/drawable/bg_type_selection_item.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_back_24.xml b/android/app/src/main/res/drawable/ic_arrow_back_24.xml new file mode 100644 index 00000000..2735bd0f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_back_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_biome_map.xml b/android/app/src/main/res/drawable/ic_biome_map.xml new file mode 100644 index 00000000..42263f66 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_biome_map.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_change_status_skill.png b/android/app/src/main/res/drawable/ic_change_status_skill.png new file mode 100644 index 00000000..e87c9683 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_change_status_skill.png differ diff --git a/android/app/src/main/res/drawable/ic_check_24.xml b/android/app/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 00000000..562b6210 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_ditto_silhouette.png b/android/app/src/main/res/drawable/ic_ditto_silhouette.png new file mode 100644 index 00000000..efa358fd Binary files /dev/null and b/android/app/src/main/res/drawable/ic_ditto_silhouette.png differ diff --git a/android/app/src/main/res/drawable/ic_filter.xml b/android/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..6191d258 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d11..e682ec17 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,42 @@ - - - - - - - - - - \ No newline at end of file + android:viewportWidth="313" + android:viewportHeight="316"> + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_menu.xml b/android/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 00000000..966e8e94 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_network_error.xml b/android/app/src/main/res/drawable/ic_network_error.xml new file mode 100644 index 00000000..94e01f64 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_network_error.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_physical_attack_skill.png b/android/app/src/main/res/drawable/ic_physical_attack_skill.png new file mode 100644 index 00000000..a14ccd23 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_physical_attack_skill.png differ diff --git a/android/app/src/main/res/drawable/ic_pikachu_silhouette.png b/android/app/src/main/res/drawable/ic_pikachu_silhouette.png new file mode 100644 index 00000000..da979e84 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_pikachu_silhouette.png differ diff --git a/android/app/src/main/res/drawable/ic_pokemon_battle_enemy.png b/android/app/src/main/res/drawable/ic_pokemon_battle_enemy.png new file mode 100644 index 00000000..b1c3d39b Binary files /dev/null and b/android/app/src/main/res/drawable/ic_pokemon_battle_enemy.png differ diff --git a/android/app/src/main/res/drawable/ic_pokemon_battle_mine.png b/android/app/src/main/res/drawable/ic_pokemon_battle_mine.png new file mode 100644 index 00000000..e66693a9 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_pokemon_battle_mine.png differ diff --git a/android/app/src/main/res/drawable/ic_sort.xml b/android/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 00000000..68174e77 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_special_attack_skill.png b/android/app/src/main/res/drawable/ic_special_attack_skill.png new file mode 100644 index 00000000..c509de15 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_special_attack_skill.png differ diff --git a/android/app/src/main/res/drawable/ic_type_delete.xml b/android/app/src/main/res/drawable/ic_type_delete.xml new file mode 100644 index 00000000..41b7782f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_type_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_type_reset.xml b/android/app/src/main/res/drawable/ic_type_reset.xml new file mode 100644 index 00000000..512417b8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_type_reset.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_add.xml b/android/app/src/main/res/drawable/icon_add.xml new file mode 100644 index 00000000..1dce36ed --- /dev/null +++ b/android/app/src/main/res/drawable/icon_add.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/icon_air.xml b/android/app/src/main/res/drawable/icon_air.xml new file mode 100644 index 00000000..6ce14249 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_air.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_arena.xml b/android/app/src/main/res/drawable/icon_arena.xml new file mode 100644 index 00000000..4e359ce6 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_arena.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_arrow_down.xml b/android/app/src/main/res/drawable/icon_arrow_down.xml new file mode 100644 index 00000000..99bf673a --- /dev/null +++ b/android/app/src/main/res/drawable/icon_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_arrow_right.xml b/android/app/src/main/res/drawable/icon_arrow_right.xml new file mode 100644 index 00000000..edc71522 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_arrow_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_arrow_right_pixel.png b/android/app/src/main/res/drawable/icon_arrow_right_pixel.png new file mode 100644 index 00000000..940fa98c Binary files /dev/null and b/android/app/src/main/res/drawable/icon_arrow_right_pixel.png differ diff --git a/android/app/src/main/res/drawable/icon_book.xml b/android/app/src/main/res/drawable/icon_book.xml new file mode 100644 index 00000000..33b05110 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_book.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_close.xml b/android/app/src/main/res/drawable/icon_close.xml new file mode 100644 index 00000000..0efa44f9 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_error.xml b/android/app/src/main/res/drawable/icon_error.xml new file mode 100644 index 00000000..5beef880 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_error.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_foggy.xml b/android/app/src/main/res/drawable/icon_foggy.xml new file mode 100644 index 00000000..7d20f095 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_foggy.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_hail.xml b/android/app/src/main/res/drawable/icon_hail.xml new file mode 100644 index 00000000..a64c2d0e --- /dev/null +++ b/android/app/src/main/res/drawable/icon_hail.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_home.xml b/android/app/src/main/res/drawable/icon_home.xml new file mode 100644 index 00000000..2d5f93a6 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_home_battle.png b/android/app/src/main/res/drawable/icon_home_battle.png new file mode 100644 index 00000000..94f72ccd Binary files /dev/null and b/android/app/src/main/res/drawable/icon_home_battle.png differ diff --git a/android/app/src/main/res/drawable/icon_home_biome.png b/android/app/src/main/res/drawable/icon_home_biome.png new file mode 100644 index 00000000..3c72072b Binary files /dev/null and b/android/app/src/main/res/drawable/icon_home_biome.png differ diff --git a/android/app/src/main/res/drawable/icon_home_item.png b/android/app/src/main/res/drawable/icon_home_item.png new file mode 100644 index 00000000..e06ced56 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_home_item.png differ diff --git a/android/app/src/main/res/drawable/icon_info.xml b/android/app/src/main/res/drawable/icon_info.xml new file mode 100644 index 00000000..2c1bea68 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_poke.xml b/android/app/src/main/res/drawable/icon_poke.xml new file mode 100644 index 00000000..b0f61d35 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_poke.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_pokemon_selection_mine.png b/android/app/src/main/res/drawable/icon_pokemon_selection_mine.png new file mode 100644 index 00000000..a51a1dd6 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_pokemon_selection_mine.png differ diff --git a/android/app/src/main/res/drawable/icon_pokemon_selection_op.png b/android/app/src/main/res/drawable/icon_pokemon_selection_op.png new file mode 100644 index 00000000..f1b479a0 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_pokemon_selection_op.png differ diff --git a/android/app/src/main/res/drawable/icon_rain.xml b/android/app/src/main/res/drawable/icon_rain.xml new file mode 100644 index 00000000..034b5010 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_rain.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_search.xml b/android/app/src/main/res/drawable/icon_search.xml new file mode 100644 index 00000000..02f811a3 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_snow.xml b/android/app/src/main/res/drawable/icon_snow.xml new file mode 100644 index 00000000..d80ffbf3 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_snow.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_sun.xml b/android/app/src/main/res/drawable/icon_sun.xml new file mode 100644 index 00000000..c2c2528a --- /dev/null +++ b/android/app/src/main/res/drawable/icon_sun.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/icon_type_bug.xml b/android/app/src/main/res/drawable/icon_type_bug.xml new file mode 100644 index 00000000..3b98eb0c --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_bug.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_dark.xml b/android/app/src/main/res/drawable/icon_type_dark.xml new file mode 100644 index 00000000..17fe115a --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_dark.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_dragon.png b/android/app/src/main/res/drawable/icon_type_dragon.png new file mode 100644 index 00000000..c16a7a80 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_dragon.png differ diff --git a/android/app/src/main/res/drawable/icon_type_electric.xml b/android/app/src/main/res/drawable/icon_type_electric.xml new file mode 100644 index 00000000..a9818908 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_electric.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/icon_type_fairy.xml b/android/app/src/main/res/drawable/icon_type_fairy.xml new file mode 100644 index 00000000..b3d2b520 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_fairy.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_fighting.xml b/android/app/src/main/res/drawable/icon_type_fighting.xml new file mode 100644 index 00000000..35b79638 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_fighting.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_fire.xml b/android/app/src/main/res/drawable/icon_type_fire.xml new file mode 100644 index 00000000..0defbc9b --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_fire.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/icon_type_flying.xml b/android/app/src/main/res/drawable/icon_type_flying.xml new file mode 100644 index 00000000..55e3c693 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_flying.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/icon_type_ghost.xml b/android/app/src/main/res/drawable/icon_type_ghost.xml new file mode 100644 index 00000000..5965f4aa --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_ghost.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_grass.xml b/android/app/src/main/res/drawable/icon_type_grass.xml new file mode 100644 index 00000000..2cdb1982 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_grass.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_ground.png b/android/app/src/main/res/drawable/icon_type_ground.png new file mode 100644 index 00000000..645b4d41 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_ground.png differ diff --git a/android/app/src/main/res/drawable/icon_type_ice.xml b/android/app/src/main/res/drawable/icon_type_ice.xml new file mode 100644 index 00000000..94117174 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_ice.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_normal.xml b/android/app/src/main/res/drawable/icon_type_normal.xml new file mode 100644 index 00000000..e1fd100a --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_normal.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_poison.png b/android/app/src/main/res/drawable/icon_type_poison.png new file mode 100644 index 00000000..9207824c Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_poison.png differ diff --git a/android/app/src/main/res/drawable/icon_type_psychic.png b/android/app/src/main/res/drawable/icon_type_psychic.png new file mode 100644 index 00000000..e5c6bea3 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_psychic.png differ diff --git a/android/app/src/main/res/drawable/icon_type_rock.png b/android/app/src/main/res/drawable/icon_type_rock.png new file mode 100644 index 00000000..ce59bdde Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_rock.png differ diff --git a/android/app/src/main/res/drawable/icon_type_steel.xml b/android/app/src/main/res/drawable/icon_type_steel.xml new file mode 100644 index 00000000..be6768f2 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_steel.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/icon_type_stellar.png b/android/app/src/main/res/drawable/icon_type_stellar.png new file mode 100644 index 00000000..42dae703 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_stellar.png differ diff --git a/android/app/src/main/res/drawable/icon_type_unknown.png b/android/app/src/main/res/drawable/icon_type_unknown.png new file mode 100644 index 00000000..3e8f6a46 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_type_unknown.png differ diff --git a/android/app/src/main/res/drawable/icon_type_water.xml b/android/app/src/main/res/drawable/icon_type_water.xml new file mode 100644 index 00000000..b26af204 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_type_water.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/img_home_ability.png b/android/app/src/main/res/drawable/img_home_ability.png new file mode 100644 index 00000000..36e71cf8 Binary files /dev/null and b/android/app/src/main/res/drawable/img_home_ability.png differ diff --git a/android/app/src/main/res/drawable/img_home_dex.png b/android/app/src/main/res/drawable/img_home_dex.png new file mode 100644 index 00000000..04be5626 Binary files /dev/null and b/android/app/src/main/res/drawable/img_home_dex.png differ diff --git a/android/app/src/main/res/drawable/img_home_land_logo.png b/android/app/src/main/res/drawable/img_home_land_logo.png new file mode 100644 index 00000000..c3bb70bc Binary files /dev/null and b/android/app/src/main/res/drawable/img_home_land_logo.png differ diff --git a/android/app/src/main/res/drawable/img_home_logo.png b/android/app/src/main/res/drawable/img_home_logo.png new file mode 100644 index 00000000..96bf5855 Binary files /dev/null and b/android/app/src/main/res/drawable/img_home_logo.png differ diff --git a/android/app/src/main/res/drawable/img_home_tip.png b/android/app/src/main/res/drawable/img_home_tip.png new file mode 100644 index 00000000..ec4adfa6 Binary files /dev/null and b/android/app/src/main/res/drawable/img_home_tip.png differ diff --git a/android/app/src/main/res/drawable/img_home_type.png b/android/app/src/main/res/drawable/img_home_type.png new file mode 100644 index 00000000..fcdea584 Binary files /dev/null and b/android/app/src/main/res/drawable/img_home_type.png differ diff --git a/android/app/src/main/res/drawable/img_property_tmp_2.png b/android/app/src/main/res/drawable/img_property_tmp_2.png new file mode 100644 index 00000000..bdb71214 Binary files /dev/null and b/android/app/src/main/res/drawable/img_property_tmp_2.png differ diff --git a/android/app/src/main/res/drawable/img_tmp_loading.png b/android/app/src/main/res/drawable/img_tmp_loading.png new file mode 100644 index 00000000..4609316f Binary files /dev/null and b/android/app/src/main/res/drawable/img_tmp_loading.png differ diff --git a/android/app/src/main/res/drawable/img_type_empty_view_placeholder.xml b/android/app/src/main/res/drawable/img_type_empty_view_placeholder.xml new file mode 100644 index 00000000..b9988f58 --- /dev/null +++ b/android/app/src/main/res/drawable/img_type_empty_view_placeholder.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/progress_dex_stat.xml b/android/app/src/main/res/drawable/progress_dex_stat.xml new file mode 100644 index 00000000..6dc0d804 --- /dev/null +++ b/android/app/src/main/res/drawable/progress_dex_stat.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/rounded_searchbar_background.xml b/android/app/src/main/res/drawable/rounded_searchbar_background.xml new file mode 100644 index 00000000..2c4f8256 --- /dev/null +++ b/android/app/src/main/res/drawable/rounded_searchbar_background.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/selector_toggle_btn.xml b/android/app/src/main/res/drawable/selector_toggle_btn.xml new file mode 100644 index 00000000..c3a2423b --- /dev/null +++ b/android/app/src/main/res/drawable/selector_toggle_btn.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/shape_grey_80_fill_20_rect.xml b/android/app/src/main/res/drawable/shape_grey_80_fill_20_rect.xml new file mode 100644 index 00000000..f3e8ddd7 --- /dev/null +++ b/android/app/src/main/res/drawable/shape_grey_80_fill_20_rect.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/shape_pokemon_corner_radius.xml b/android/app/src/main/res/drawable/shape_pokemon_corner_radius.xml new file mode 100644 index 00000000..c24c9bb6 --- /dev/null +++ b/android/app/src/main/res/drawable/shape_pokemon_corner_radius.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/shape_red_20_fill_20_rect.xml b/android/app/src/main/res/drawable/shape_red_20_fill_20_rect.xml new file mode 100644 index 00000000..7459b9e4 --- /dev/null +++ b/android/app/src/main/res/drawable/shape_red_20_fill_20_rect.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/shape_toggle_thumb.xml b/android/app/src/main/res/drawable/shape_toggle_thumb.xml new file mode 100644 index 00000000..95a30a63 --- /dev/null +++ b/android/app/src/main/res/drawable/shape_toggle_thumb.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/tab_background.xml b/android/app/src/main/res/drawable/tab_background.xml new file mode 100644 index 00000000..5c88d719 --- /dev/null +++ b/android/app/src/main/res/drawable/tab_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/tab_background_selected.xml b/android/app/src/main/res/drawable/tab_background_selected.xml new file mode 100644 index 00000000..0d21dff4 --- /dev/null +++ b/android/app/src/main/res/drawable/tab_background_selected.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/tab_background_unselected.xml b/android/app/src/main/res/drawable/tab_background_unselected.xml new file mode 100644 index 00000000..3585eac0 --- /dev/null +++ b/android/app/src/main/res/drawable/tab_background_unselected.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/tab_border_background.xml b/android/app/src/main/res/drawable/tab_border_background.xml new file mode 100644 index 00000000..505bc3d8 --- /dev/null +++ b/android/app/src/main/res/drawable/tab_border_background.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/font/poke_font.xml b/android/app/src/main/res/font/poke_font.xml new file mode 100644 index 00000000..2001794e --- /dev/null +++ b/android/app/src/main/res/font/poke_font.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/font/pretendard_medium.otf b/android/app/src/main/res/font/pretendard_medium.otf new file mode 100644 index 00000000..ff907c42 Binary files /dev/null and b/android/app/src/main/res/font/pretendard_medium.otf differ diff --git a/android/app/src/main/res/font/pretendard_semibold.otf b/android/app/src/main/res/font/pretendard_semibold.otf new file mode 100644 index 00000000..fe81db7d Binary files /dev/null and b/android/app/src/main/res/font/pretendard_semibold.otf differ diff --git a/android/app/src/main/res/layout-land/activity_battle.xml b/android/app/src/main/res/layout-land/activity_battle.xml new file mode 100644 index 00000000..213e1251 --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_battle.xml @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout-land/activity_battle_selection.xml b/android/app/src/main/res/layout-land/activity_battle_selection.xml new file mode 100644 index 00000000..e0249840 --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_battle_selection.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout-land/activity_biome_detail.xml b/android/app/src/main/res/layout-land/activity_biome_detail.xml new file mode 100644 index 00000000..74f3e26c --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_biome_detail.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout-land/activity_home.xml b/android/app/src/main/res/layout-land/activity_home.xml new file mode 100644 index 00000000..d669f3e7 --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_home.xml @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout-land/activity_pokemon_detail.xml b/android/app/src/main/res/layout-land/activity_pokemon_detail.xml new file mode 100644 index 00000000..7e678ee3 --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_pokemon_detail.xml @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout-land/activity_type.xml b/android/app/src/main/res/layout-land/activity_type.xml new file mode 100644 index 00000000..f0317d9c --- /dev/null +++ b/android/app/src/main/res/layout-land/activity_type.xml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout-land/fragment_ability_detail.xml b/android/app/src/main/res/layout-land/fragment_ability_detail.xml new file mode 100644 index 00000000..9ff1e3b8 --- /dev/null +++ b/android/app/src/main/res/layout-land/fragment_ability_detail.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_ability.xml b/android/app/src/main/res/layout/activity_ability.xml new file mode 100644 index 00000000..4e2bbbee --- /dev/null +++ b/android/app/src/main/res/layout/activity_ability.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_battle.xml b/android/app/src/main/res/layout/activity_battle.xml new file mode 100644 index 00000000..9bacebdd --- /dev/null +++ b/android/app/src/main/res/layout/activity_battle.xml @@ -0,0 +1,352 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_battle_selection.xml b/android/app/src/main/res/layout/activity_battle_selection.xml new file mode 100644 index 00000000..015450ff --- /dev/null +++ b/android/app/src/main/res/layout/activity_battle_selection.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_biome.xml b/android/app/src/main/res/layout/activity_biome.xml new file mode 100644 index 00000000..6d3177be --- /dev/null +++ b/android/app/src/main/res/layout/activity_biome.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_biome_detail.xml b/android/app/src/main/res/layout/activity_biome_detail.xml new file mode 100644 index 00000000..7aeeacbc --- /dev/null +++ b/android/app/src/main/res/layout/activity_biome_detail.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_biome_guide.xml b/android/app/src/main/res/layout/activity_biome_guide.xml new file mode 100644 index 00000000..6644cf32 --- /dev/null +++ b/android/app/src/main/res/layout/activity_biome_guide.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_home.xml b/android/app/src/main/res/layout/activity_home.xml new file mode 100644 index 00000000..19be742f --- /dev/null +++ b/android/app/src/main/res/layout/activity_home.xml @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_item.xml b/android/app/src/main/res/layout/activity_item.xml new file mode 100644 index 00000000..0b24d7b7 --- /dev/null +++ b/android/app/src/main/res/layout/activity_item.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 86a5d977..00000000 --- a/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_network_error.xml b/android/app/src/main/res/layout/activity_network_error.xml new file mode 100644 index 00000000..a334085f --- /dev/null +++ b/android/app/src/main/res/layout/activity_network_error.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_pokemon_detail.xml b/android/app/src/main/res/layout/activity_pokemon_detail.xml new file mode 100644 index 00000000..8fe8bbdc --- /dev/null +++ b/android/app/src/main/res/layout/activity_pokemon_detail.xml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_pokemon_intro.xml b/android/app/src/main/res/layout/activity_pokemon_intro.xml new file mode 100644 index 00000000..4af2f035 --- /dev/null +++ b/android/app/src/main/res/layout/activity_pokemon_intro.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_pokemon_list.xml b/android/app/src/main/res/layout/activity_pokemon_list.xml new file mode 100644 index 00000000..32fa5325 --- /dev/null +++ b/android/app/src/main/res/layout/activity_pokemon_list.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_tip.xml b/android/app/src/main/res/layout/activity_tip.xml new file mode 100644 index 00000000..9cb534d4 --- /dev/null +++ b/android/app/src/main/res/layout/activity_tip.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_type.xml b/android/app/src/main/res/layout/activity_type.xml new file mode 100644 index 00000000..9ea65c22 --- /dev/null +++ b/android/app/src/main/res/layout/activity_type.xml @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/bottom_sheet_pokemon_filter.xml b/android/app/src/main/res/layout/bottom_sheet_pokemon_filter.xml new file mode 100644 index 00000000..5ed5496d --- /dev/null +++ b/android/app/src/main/res/layout/bottom_sheet_pokemon_filter.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/bottom_sheet_pokemon_sort.xml b/android/app/src/main/res/layout/bottom_sheet_pokemon_sort.xml new file mode 100644 index 00000000..356b2e3d --- /dev/null +++ b/android/app/src/main/res/layout/bottom_sheet_pokemon_sort.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_ability.xml b/android/app/src/main/res/layout/fragment_ability.xml new file mode 100644 index 00000000..7eb29d27 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_ability.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_ability_detail.xml b/android/app/src/main/res/layout/fragment_ability_detail.xml new file mode 100644 index 00000000..87daf2b3 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_ability_detail.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_biome_boss_pokemon.xml b/android/app/src/main/res/layout/fragment_biome_boss_pokemon.xml new file mode 100644 index 00000000..2f0ed2f0 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_biome_boss_pokemon.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_biome_gym_pokemon.xml b/android/app/src/main/res/layout/fragment_biome_gym_pokemon.xml new file mode 100644 index 00000000..849b688e --- /dev/null +++ b/android/app/src/main/res/layout/fragment_biome_gym_pokemon.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_biome_next_biome.xml b/android/app/src/main/res/layout/fragment_biome_next_biome.xml new file mode 100644 index 00000000..1a6b586f --- /dev/null +++ b/android/app/src/main/res/layout/fragment_biome_next_biome.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_biome_wild_pokemon.xml b/android/app/src/main/res/layout/fragment_biome_wild_pokemon.xml new file mode 100644 index 00000000..5516351b --- /dev/null +++ b/android/app/src/main/res/layout/fragment_biome_wild_pokemon.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_pokemon_evolution.xml b/android/app/src/main/res/layout/fragment_pokemon_evolution.xml new file mode 100644 index 00000000..faab4b58 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_pokemon_evolution.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_pokemon_information.xml b/android/app/src/main/res/layout/fragment_pokemon_information.xml new file mode 100644 index 00000000..6e783116 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_pokemon_information.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_pokemon_selection.xml b/android/app/src/main/res/layout/fragment_pokemon_selection.xml new file mode 100644 index 00000000..fe278c77 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_pokemon_selection.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_pokemon_skills.xml b/android/app/src/main/res/layout/fragment_pokemon_skills.xml new file mode 100644 index 00000000..70c2a2c9 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_pokemon_skills.xml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_pokemon_stat.xml b/android/app/src/main/res/layout/fragment_pokemon_stat.xml new file mode 100644 index 00000000..b5330cf2 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_pokemon_stat.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_skill_selection.xml b/android/app/src/main/res/layout/fragment_skill_selection.xml new file mode 100644 index 00000000..a8093e68 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_skill_selection.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_type_selection_bottom_sheet.xml b/android/app/src/main/res/layout/fragment_type_selection_bottom_sheet.xml new file mode 100644 index 00000000..bf550d1f --- /dev/null +++ b/android/app/src/main/res/layout/fragment_type_selection_bottom_sheet.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_ability_description.xml b/android/app/src/main/res/layout/item_ability_description.xml new file mode 100644 index 00000000..202103e2 --- /dev/null +++ b/android/app/src/main/res/layout/item_ability_description.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_ability_detail_pokemon.xml b/android/app/src/main/res/layout/item_ability_detail_pokemon.xml new file mode 100644 index 00000000..5cbf4712 --- /dev/null +++ b/android/app/src/main/res/layout/item_ability_detail_pokemon.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_ability_title.xml b/android/app/src/main/res/layout/item_ability_title.xml new file mode 100644 index 00000000..b35a48a4 --- /dev/null +++ b/android/app/src/main/res/layout/item_ability_title.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_battle_pokemon_selection.xml b/android/app/src/main/res/layout/item_battle_pokemon_selection.xml new file mode 100644 index 00000000..ba485fe7 --- /dev/null +++ b/android/app/src/main/res/layout/item_battle_pokemon_selection.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_battle_skill_selection.xml b/android/app/src/main/res/layout/item_battle_skill_selection.xml new file mode 100644 index 00000000..13d4fe09 --- /dev/null +++ b/android/app/src/main/res/layout/item_battle_skill_selection.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_biome.xml b/android/app/src/main/res/layout/item_biome.xml new file mode 100644 index 00000000..ed55cc71 --- /dev/null +++ b/android/app/src/main/res/layout/item_biome.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_biome_gym.xml b/android/app/src/main/res/layout/item_biome_gym.xml new file mode 100644 index 00000000..705d0b7f --- /dev/null +++ b/android/app/src/main/res/layout/item_biome_gym.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_biome_map.xml b/android/app/src/main/res/layout/item_biome_map.xml new file mode 100644 index 00000000..726d30ae --- /dev/null +++ b/android/app/src/main/res/layout/item_biome_map.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_biome_next_biomes.xml b/android/app/src/main/res/layout/item_biome_next_biomes.xml new file mode 100644 index 00000000..7c8ec68f --- /dev/null +++ b/android/app/src/main/res/layout/item_biome_next_biomes.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_biome_pokemon.xml b/android/app/src/main/res/layout/item_biome_pokemon.xml new file mode 100644 index 00000000..6154f9e6 --- /dev/null +++ b/android/app/src/main/res/layout/item_biome_pokemon.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_pokemon_detail_evolution.xml b/android/app/src/main/res/layout/item_pokemon_detail_evolution.xml new file mode 100644 index 00000000..49ca1c42 --- /dev/null +++ b/android/app/src/main/res/layout/item_pokemon_detail_evolution.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/item_pokemon_detail_information_biome.xml b/android/app/src/main/res/layout/item_pokemon_detail_information_biome.xml new file mode 100644 index 00000000..93d2dff6 --- /dev/null +++ b/android/app/src/main/res/layout/item_pokemon_detail_information_biome.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_pokemon_detail_skill.xml b/android/app/src/main/res/layout/item_pokemon_detail_skill.xml new file mode 100644 index 00000000..6b6209f4 --- /dev/null +++ b/android/app/src/main/res/layout/item_pokemon_detail_skill.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/item_pokemon_list_pokemon.xml b/android/app/src/main/res/layout/item_pokemon_list_pokemon.xml new file mode 100644 index 00000000..d396d08e --- /dev/null +++ b/android/app/src/main/res/layout/item_pokemon_list_pokemon.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_pokemon_sort.xml b/android/app/src/main/res/layout/item_pokemon_sort.xml new file mode 100644 index 00000000..f2f1d668 --- /dev/null +++ b/android/app/src/main/res/layout/item_pokemon_sort.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/item_spinner_weather.xml b/android/app/src/main/res/layout/item_spinner_weather.xml new file mode 100644 index 00000000..202c03b7 --- /dev/null +++ b/android/app/src/main/res/layout/item_spinner_weather.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_stat.xml b/android/app/src/main/res/layout/item_stat.xml new file mode 100644 index 00000000..5727cc2f --- /dev/null +++ b/android/app/src/main/res/layout/item_stat.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_type.xml b/android/app/src/main/res/layout/item_type.xml new file mode 100644 index 00000000..89701697 --- /dev/null +++ b/android/app/src/main/res/layout/item_type.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_type_parallelogram.xml b/android/app/src/main/res/layout/item_type_parallelogram.xml new file mode 100644 index 00000000..191ba857 --- /dev/null +++ b/android/app/src/main/res/layout/item_type_parallelogram.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_type_result.xml b/android/app/src/main/res/layout/item_type_result.xml new file mode 100644 index 00000000..221ec861 --- /dev/null +++ b/android/app/src/main/res/layout/item_type_result.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_type_right_name.xml b/android/app/src/main/res/layout/item_type_right_name.xml new file mode 100644 index 00000000..b1c4e2d3 --- /dev/null +++ b/android/app/src/main/res/layout/item_type_right_name.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_type_selection.xml b/android/app/src/main/res/layout/item_type_selection.xml new file mode 100644 index 00000000..350646eb --- /dev/null +++ b/android/app/src/main/res/layout/item_type_selection.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/view_group_pokemon_evolution.xml b/android/app/src/main/res/layout/view_group_pokemon_evolution.xml new file mode 100644 index 00000000..5568f148 --- /dev/null +++ b/android/app/src/main/res/layout/view_group_pokemon_evolution.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/menu_pokemon_list_pokemon_search.xml b/android/app/src/main/res/menu/menu_pokemon_list_pokemon_search.xml new file mode 100644 index 00000000..b4bbf0c2 --- /dev/null +++ b/android/app/src/main/res/menu/menu_pokemon_list_pokemon_search.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/menu_toolbar.xml b/android/app/src/main/res/menu/menu_toolbar.xml new file mode 100644 index 00000000..16fde97b --- /dev/null +++ b/android/app/src/main/res/menu/menu_toolbar.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 56% rename from android/app/src/main/res/mipmap-anydpi/ic_launcher.xml rename to android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755b..7353dbd1 100644 --- a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_poke_icon.xml similarity index 56% rename from android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml rename to android/app/src/main/res/mipmap-anydpi-v26/ic_poke_icon.xml index 6f3b755b..7353dbd1 100644 --- a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_poke_icon.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78e..2a0e3463 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1..00000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_poke_icon.webp b/android/app/src/main/res/mipmap-hdpi/ic_poke_icon.webp new file mode 100644 index 00000000..decd0a3e Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_poke_icon.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d64..90f9a37c 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da..00000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_poke_icon.webp b/android/app/src/main/res/mipmap-mdpi/ic_poke_icon.webp new file mode 100644 index 00000000..dfd25b4c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_poke_icon.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a3070..3db03a60 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956..00000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_poke_icon.webp b/android/app/src/main/res/mipmap-xhdpi/ic_poke_icon.webp new file mode 100644 index 00000000..4fb38c8a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_poke_icon.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77f..5510556b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f508..00000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_poke_icon.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_poke_icon.webp new file mode 100644 index 00000000..86d068c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_poke_icon.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d6427..d5e7bac3 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37..00000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_poke_icon.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_poke_icon.webp new file mode 100644 index 00000000..99306d67 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_poke_icon.webp differ diff --git a/android/app/src/main/res/values-land/dimensions.xml b/android/app/src/main/res/values-land/dimensions.xml new file mode 100644 index 00000000..418ef80d --- /dev/null +++ b/android/app/src/main/res/values-land/dimensions.xml @@ -0,0 +1,18 @@ + + + + + 28dp + 28dp + 16dp + 20dp + + + 28dp + 16sp + 28dp + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml index b0e4fe1d..bf9ba340 100644 --- a/android/app/src/main/res/values-night/themes.xml +++ b/android/app/src/main/res/values-night/themes.xml @@ -1,7 +1,14 @@ - + - \ No newline at end of file + diff --git a/android/app/src/main/res/values-sw600dp/dimens.xml b/android/app/src/main/res/values-sw600dp/dimens.xml new file mode 100644 index 00000000..0f2593f2 --- /dev/null +++ b/android/app/src/main/res/values-sw600dp/dimens.xml @@ -0,0 +1,70 @@ + + + + 32sp + + + 250dp + 250dp + 100dp + 100dp + 42dp + 42dp + 24sp + 100dp + + + 153dp + 100dp + 20sp + 20sp + + + 20sp + 300dp + 150dp + 20sp + 100dp + 200dp + 24sp + + + 32sp + 24sp + 28sp + 12dp + + + 28dp + 156dp + 96dp + 36dp + + 24dp + 52dp + 40dp + 16dp + + + 160dp + 12dp + 24dp + + + 24dp + + + 24dp + + 24dp + 28dp + 12dp + 400dp + 200dp + 3 + 18dp + + + 20sp + + diff --git a/android/app/src/main/res/values-sw600dp/type.xml b/android/app/src/main/res/values-sw600dp/type.xml new file mode 100644 index 00000000..e5c23ffa --- /dev/null +++ b/android/app/src/main/res/values-sw600dp/type.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 00000000..274f3301 --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index c8524cd9..ec1719e2 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,5 +1,67 @@ - #FF000000 + #000000 #FFFFFFFF - \ No newline at end of file + + #FDFDFDFD + #000000 + #242627 + #383939 + #4B4B4B + #717171 + #969696 + #DCDCDC + #6C7EE2 + #5981F2 + #182E6A + #74CC73 + #F66868 + #E1CA13 + + #BF242627 + + #459836 + #DDBC29 + #E3901E + #E3613F + #75A9D0 + #73B1CC + #72D5FB + #3199E2 + #016ECA + #b567cf + #704572 + #E87236 + #FF6675 + #9f9f27 + #C8B686 + #828282 + #4E4747 + #F5B4FA + #242627 + + + #D63A45 + #FDA832 + #F4C53D + #4AA0DD + #3568FF + #5FC253 + #964DF0 + + + #00000000 + + + @color/poke_grey_65 + @color/poke_grey_75 + + @color/poke_white + @color/poke_white + + @color/poke_white + @color/poke_grey_80 + + #151515 + #151515 + diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..d3026f2b --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,99 @@ + + + + 150dp + 150dp + 60dp + 60dp + 24dp + 25dp + 15sp + 20dp + + + 102dp + 67dp + 15sp + 15sp + + 16sp + 200dp + 100dp + 11sp + 50dp + 100dp + 16sp + 12sp + 16dp + + 12dp + 52dp + 32dp + 12dp + 4dp + + + 1dp + 8dp + 20sp + + + + 40dp + 20dp + 28dp + 28dp + 16dp + 20dp + 28dp + 16sp + + + 72dp + 80dp + 28dp + + + 32dp + 32dp + 8dp + 20dp + 8dp + + + 4dp + 16dp + + 32dp + 24dp + + 4dp + 12dp + 12dp + + + 100dp + 8dp + 12dp + + + 12dp + + + 16dp + + 12dp + 18dp + 4dp + + 200dp + 100dp + + 2 + 8dp + + + + 12sp + + diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml new file mode 100644 index 00000000..78554f9e --- /dev/null +++ b/android/app/src/main/res/values/dimensions.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..dcdf0321 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #151515 + \ No newline at end of file diff --git a/android/app/src/main/res/values/splash.xml b/android/app/src/main/res/values/splash.xml new file mode 100644 index 00000000..3204b62d --- /dev/null +++ b/android/app/src/main/res/values/splash.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c2e767a2..dc4936a3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,151 @@ - PokeRogueHelper - \ No newline at end of file + PokรฉRogue Helper + + ์ƒ์„ฑ ๋ณด๊ธฐ + ๋‚ด ํƒ€์ž… + ์ƒ๋Œ€ ํƒ€์ž… + ์ƒ์„ฑ ๊ฒฐ๊ณผ + * 1๋ฐฐ๋Š” ํ‘œ์‹œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +  ์ด(๊ฐ€) +  ์—๊ฒŒ +  ํƒ€์ž… + ๊ฐ•ํ•œ + ์•ฝํ•œ + ๋ฌดํšจํ•œ + ๋Œ€๋“ฑํ•œ + + + ํฌ์ผ“๋กœ๊ทธ ๋ฐ”๋กœ๊ฐ€๊ธฐ + ํ”ผ๋“œ๋ฐฑ ๋ณด๋‚ด๊ธฐ + https://pokerogue.net/ + https://namu.wiki/w/Pok%C3%A9Rogue + https://docs.google.com/forms/d/e/1FAIpQLScd-Kp5zCzrcWKO6eXR7Ujgm0TscGrwuCPNCX1na0enhtVZaw/viewform + ์ƒ์„ฑ ๋ณด๊ธฐ + ํฌ์ผ“๋ชฌ ๋„๊ฐ + ํŠน์„ฑ ๋„๊ฐ + ๊ฟ€ํŒ + ๋ฐฐํ‹€ ๊ตฌ๋„ + ๋ฐ”์ด์˜ด ๋„๊ฐ + ์•„์ดํ…œ ๋„๊ฐ + + + ํฌ์ผ“๋ชฌ ๋„๊ฐ + %03d/%03d + %s #%d + ํฌ์ผ“๋ชฌ ์ด๋ฆ„์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”. + ์ž˜๋ชป๋œ containerId๊ฐ’(-1) ์ž…๋‹ˆ๋‹ค. + Loadingโ€ฆ + ํ•ด๋‹นํ•˜๋Š” ํฌ์ผ“๋ชฌ์ด ์—†์–ด์š” + + ํฌ์ผ“๋ชฌ ๋„๊ฐ + %03d/%03d + %s #%d + ํฌ์ผ“๋ชฌ ์ด๋ฆ„์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”. + ์ž˜๋ชป๋œ containerId๊ฐ’(-1) ์ž…๋‹ˆ๋‹ค. + Loadingโ€ฆ + ํ•ด๋‹นํ•˜๋Š” ํฌ์ผ“๋ชฌ์ด ์—†์–ด์š” + ํ•„ํ„ฐ %s + ๋ชจ๋“  ์„ธ๋Œ€ + %d์„ธ๋Œ€ + ํฌ์ผ“๋ชฌ ์ •๋ ฌ + + + ๊ธฐ๋ณธ ์Šคํƒฏ + + + ๋Šฅ๋ ฅ์น˜ + ์ง„ํ™” ์ •๋ณด + ๊ธฐ์ˆ  ์ •๋ณด + ์ •๋ณด + + + ์ƒ๋Œ€ ํฌ์ผ“๋ชฌ์œผ๋กœ + ๋‚ด ํฌ์ผ“๋ชฌ์œผ๋กœ + + + ๋ ˆ๋ฒจ + ๊ธฐ์ˆ ๋ช… + ์œ„๋ ฅ + ๋ช…์ค‘๋ฅ  + ํƒ€์ž… + ๋ถ„๋ฅ˜ + + ์•Œ ๊ธฐ์ˆ  + ์ž๋ ฅ์œผ๋กœ ๋ฐฐ์šฐ๋Š” ๊ธฐ์ˆ  + + + ์ฒด์ค‘ + ํ‚ค + ๋ฐ”์ด์˜ด + %.1f kg + %.1f m + + + + ์ง„ํ™” ์ฒด์ธ์ด ์—†๋Š” ํฌ์ผ“๋ชฌ์ž…๋‹ˆ๋‹ค. + ๋ ˆ๋ฒจ %d + + + ํŠน์„ฑ ๋„๊ฐ + ํŠน์„ฑ ์ด๋ฆ„์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”. + ์ž˜๋ชป๋œ abilityId๊ฐ’(-1) ์ž…๋‹ˆ๋‹ค. + ์ž˜๋ชป๋œ containerId๊ฐ’(-1) ์ž…๋‹ˆ๋‹ค. + + + ๋ฐ”์ด์˜ด ๋„๊ฐ + https://wiki.pokerogue.net/ko:biomes:biomes + ๋ฐ”์ด์˜ด ์ด๋ฆ„์œผ๋กœ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”. + + ์ฒด์œก๊ด€ + ๋ณด์Šค + ์•ผ์ƒ + ๋‹ค์Œ ๋ฐ”์ด์˜ด + + ๋ฐฐํ‹€ ๋ชจ๋“œ ์„ค์ • + "ํฌ์ผ“๋ชฌ ํด๋ฆญ ์‹œ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ƒ์„ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.\n ๋ฐฐํ‹€ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ ค๋ฉด ๋ฐฐํ‹€ ๋ชจ๋“œ๋ฅผ ์„ค์ •ํ•ด์ฃผ์„ธ์š”!" + + + ๋“ฑ์žฅํ•˜๋Š” ์ฒด์œก๊ด€ ๊ด€์žฅ์ด ์—†์–ด์š” + + + ๋“ฑ์žฅํ•˜๋Š” ๋ณด์Šค ํฌ์ผ“๋ชฌ์ด ์—†์–ด์š” + + + %.1f%% + + + ์•„์ดํ…œ ๋„๊ฐ + + + ๋ฐฐํ‹€ ๊ตฌ๋„ + ๊ณ„์‚ฐ ๊ฒฐ๊ณผ + ๊ณ„์‚ฐ๋œ ์œ„๋ ฅ + ๊ธฐ์ˆ  ์œ„๋ ฅ + ๋ฐฐ์ˆ˜ + ๋ช…์ค‘ ํ™•๋ฅ  : %s + ๋‚ด ํฌ์ผ“๋ชฌ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”! + ์ƒ๋Œ€ ํฌ์ผ“๋ชฌ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”! + ๊ธฐ์ˆ ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”! + ์„ ํƒ ์™„๋ฃŒ + ๊ธฐ์ˆ ์„ ๊ฒ€์ƒ‰ํ•˜์„ธ์š” + ํฌ์ผ“๋ชฌ์„ ๊ฒ€์ƒ‰ํ•˜์„ธ์š” + ํฌ์ผ“๋ชฌ์„ ์„ ํƒํ•˜์„ธ์š” + ๋‹ค์Œ + ์ด์ „ + * + = + + + ์•Œ์ˆ˜์—†๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.. + ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ ์ด๋ฏธ์ง€ + ์ธํ„ฐ๋„ท์— ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์ง€ ์•Š์•„์š”! + ์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ์ด ๋ถˆ์•ˆ์ •ํ•ด์š”.\nWi-Fi๋ฅผ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํ™•์ธํ•ด ์ฃผ์„ธ์š”!! + ๋‹ค์‹œ ์‹œ๋„ํ•˜๊ธฐ + ์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”. + + + ์—…๋ฐ์ดํŠธ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + ์žฌ์‹œ์ž‘ + ๋‹ค์šด๋กœ๋“œ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. + diff --git a/android/app/src/main/res/values/style.xml b/android/app/src/main/res/values/style.xml new file mode 100644 index 00000000..f54ab88a --- /dev/null +++ b/android/app/src/main/res/values/style.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index ef97a0bd..f965c2e9 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,9 +1,16 @@ - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..eb7159cf --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 10.0.2.2 + + \ No newline at end of file diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityActivityTest.kt new file mode 100644 index 00000000..a243615b --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityActivityTest.kt @@ -0,0 +1,32 @@ +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.ability.AbilityActivity +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class AbilityActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + private val activityScenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + activityScenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityFragmentTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityFragmentTest.kt new file mode 100644 index 00000000..d652e09a --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityFragmentTest.kt @@ -0,0 +1,34 @@ +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.ability.AbilityFragment +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class AbilityFragmentTest { + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Ignore("Issue: launchFragmentInContainer") + @Test + fun `Fragment ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + val scenario = + launchFragmentInContainer(themeResId = R.style.Theme_PokeRogueHelper) + + scenario.onFragment { fragment -> + fragment.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityViewModelTest.kt new file mode 100644 index 00000000..bb669337 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/ability/AbilityViewModelTest.kt @@ -0,0 +1,58 @@ +package poke.rogue.helper.presentation.ability + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.presentation.ability.model.AbilityUiModel +import poke.rogue.helper.presentation.ability.model.toUi +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.CoroutinesTestExtension +import poke.rogue.helper.testing.data.repository.FakeAbilityRepository + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutinesTestExtension::class) +class AbilityViewModelTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + modules(testViewModelModule) + } + private val viewModel: AbilityViewModel + get() = get() + + @Test + fun `๋ชจ๋“  ํŠน์„ฑ์„ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // when + val uiState = viewModel.uiState.first { it is AbilityUiState.Success } + + // then + val abilities = (uiState as AbilityUiState.Success>).data + abilities shouldBe FakeAbilityRepository().abilities().map { it.toUi() } + } + + @Test + fun `ํŠน์„ฑ id๊ฐ’์œผ๋กœ ํŠน์„ฑ ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + // given + val abilityId = "15L" + + // when + launch { + viewModel.navigateToDetail(abilityId) + } + + // then + val actualId = viewModel.navigationToDetailEvent.first() + actualId shouldBe abilityId + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragmentTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragmentTest.kt new file mode 100644 index 00000000..e6e44c99 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragmentTest.kt @@ -0,0 +1,34 @@ +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.ability.detail.AbilityDetailFragment +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class AbilityDetailFragmentTest { + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Ignore("Issue: launchFragmentInContainer") + @Test + fun `Fragment ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + val scenario = + launchFragmentInContainer(themeResId = R.style.Theme_PokeRogueHelper) + + scenario.onFragment { fragment -> + fragment.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewModelTest.kt new file mode 100644 index 00000000..03f43fb1 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailViewModelTest.kt @@ -0,0 +1,65 @@ +package poke.rogue.helper.presentation.ability.detail + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.presentation.ability.model.toUi +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.CoroutinesTestExtension + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutinesTestExtension::class) +class AbilityDetailViewModelTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + modules(testViewModelModule) + } + private val viewModel: AbilityDetailViewModel + get() = get() + + @Test + fun `ํŠน์„ฑ id๊ฐ’์œผ๋กœ ํŠน์„ฑ ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // given + val abilityId = "1L" + + // when + viewModel.updateAbilityDetail(abilityId) + val abilityDetail = + viewModel.abilityDetail.first { + it is AbilityDetailUiState.Success + } + val detail = (abilityDetail as AbilityDetailUiState.Success).data.toUi() + + // then + detail.title shouldBe "์•…์ทจ" + detail.description shouldBe "์•…์ทจ๋ฅผ ํ’๊ฒจ์„œ ๊ณต๊ฒฉํ–ˆ์„ ๋•Œ ์ƒ๋Œ€๊ฐ€ ํ’€์ฃฝ์„ ๋•Œ๊ฐ€ ์žˆ๋‹ค." + } + + @Test + fun `ํฌ์ผ“๋ชฌ id ๊ฐ’์œผ๋กœ ํฌ์ผ“๋ชฌ ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + // given + Dispatchers.setMain(StandardTestDispatcher()) + val pokemonId = "1L" + + // when + viewModel.navigateToPokemonDetail(pokemonId) + + // then + val actualId = viewModel.navigationToPokemonDetailEvent.first() + actualId shouldBe pokemonId + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/battle/BattleActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/battle/BattleActivityTest.kt new file mode 100644 index 00000000..9ea5cabe --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/battle/BattleActivityTest.kt @@ -0,0 +1,41 @@ +package poke.rogue.helper.presentation.battle + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class BattleActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + val scenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } + + @Test + fun `ViewModel ์ฃผ์ž… ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.getViewModel().shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivityTest.kt new file mode 100644 index 00000000..196a7208 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivityTest.kt @@ -0,0 +1,87 @@ +package poke.rogue.helper.presentation.battle.selection + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.viewpager2.widget.ViewPager2 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.battle.model.SelectionData +import poke.rogue.helper.presentation.battle.model.SelectionMode +import poke.rogue.helper.presentation.battle.selection.pokemon.PokemonSelectionFragment +import poke.rogue.helper.presentation.battle.selection.pokemon.PokemonSelectionViewModel +import poke.rogue.helper.presentation.battle.selection.skill.SkillSelectionFragment +import poke.rogue.helper.presentation.battle.selection.skill.SkillSelectionViewModel +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class BattleSelectionActivityTest { + private val intent = + BattleSelectionActivity.intent( + ApplicationProvider.getApplicationContext(), + SelectionMode.POKEMON_AND_SKILL, + SelectionData.NoSelection, + ) + + @get:Rule + val activityRule = activityScenarioRule(intent) + val scenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } + + @Test + fun `ViewModel ์ฃผ์ž… ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.getViewModel().shouldNotBeNull() + } + } + + @Test + fun `ํฌ์ผ“๋ชฌ ์„ ํƒ Fragment ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + val pokemonSelectionFragment = + activity.supportFragmentManager.fragments.find { it is PokemonSelectionFragment } + pokemonSelectionFragment.shouldNotBeNull() + + val pokemonSelectionViewModel = + pokemonSelectionFragment.getViewModel() + pokemonSelectionViewModel.shouldNotBeNull() + } + } + + @Test + fun `์Šคํ‚ฌ ์„ ํƒ Fragment ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + val viewPager = activity.findViewById(R.id.pager_battle_selection) + viewPager.currentItem = SelectionStep.SKILL_SELECTION.ordinal + + viewPager.post { + val skillSelectionFragment = + activity.supportFragmentManager.fragments.find { it is SkillSelectionFragment } + skillSelectionFragment.shouldNotBeNull() + + val skillSelectionViewModel = activity.getViewModel() + skillSelectionViewModel.shouldNotBeNull() + } + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/biome/BiomeActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/biome/BiomeActivityTest.kt new file mode 100644 index 00000000..d1cb771b --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/biome/BiomeActivityTest.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.biome + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class BiomeActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + private val activityScenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + activityScenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/biome/BiomeViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/biome/BiomeViewModelTest.kt new file mode 100644 index 00000000..3a74adad --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/biome/BiomeViewModelTest.kt @@ -0,0 +1,102 @@ +package poke.rogue.helper.presentation.biome + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.data.repository.BiomeRepository +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.CoroutinesTestExtension + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutinesTestExtension::class) +class BiomeViewModelTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + modules(testViewModelModule) + } + + private val viewModel: BiomeViewModel + get() = get() + + @Test + fun `๋ทฐ๋ชจ๋ธ์ด ์ƒ์„ฑ๋  ๋•Œ,๋ชจ๋“  ๋ฐ”์ด์˜ด ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // given,when + val repository = getKoin().get() + val biomes = viewModel.biomes.first { it is BiomeUiState.Success } + val actualBiomes = (biomes as BiomeUiState.Success).data + + // then + val expectBiomes = repository.biomes() + actualBiomes shouldBe expectBiomes + } + + @Test + fun `๋ฐ”์ด์˜ด ID๊ฐ’์œผ๋กœ ๋ฐ”์ด์˜ด ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + // given + Dispatchers.setMain(StandardTestDispatcher()) + val biomeId = "grass" + + // when + viewModel.navigateToDetail(biomeId) + val actualId = viewModel.navigationToDetailEvent.first() + + // then + actualId shouldBe biomeId + } + + @Test + fun `๋ฐ”์ด์˜ด ๊ฐ€์ด๋“œ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + // given + Dispatchers.setMain(StandardTestDispatcher()) + + // when + viewModel.navigateToGuide() + val actual = viewModel.navigateToGuideEvent.first() + + // then + actual shouldBe Unit + } + + @Test + fun `์˜ฌ๋ฐ”๋ฅธ ๋ฐ”์ด์˜ด ์ด๋ฆ„์„ ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ, ํ•ด๋‹นํ•˜๋Š” ๋ฐ”์ด์˜ด์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = + runTest { + // given + val repository = getKoin().get() + + // when + val biome = repository.biomes("์•…์ง€") + + // then + val actual = biome.find { it.name == "์•…์ง€" }?.name + val expect = "์•…์ง€" + actual shouldBe expect + } + + @Test + fun `์ž˜๋ชป๋œ ๋ฐ”์ด์˜ด ์ด๋ฆ„์„ ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ, ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = + runTest { + // given + val repository = getKoin().get() + + // when + val biome = repository.biomes("์ž˜๋ชป๋œ ์ด๋ฆ„") + + // then + biome.size shouldBe 0 + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivityTest.kt new file mode 100644 index 00000000..133b4c3a --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivityTest.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.biome.detail + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class BiomeDetailActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + private val activityScenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + activityScenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListActivityTest.kt new file mode 100644 index 00000000..a33014c3 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListActivityTest.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.dex + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class PokemonListActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + val scenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt new file mode 100644 index 00000000..1359c14c --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/PokemonListViewModelTest.kt @@ -0,0 +1,59 @@ +package poke.rogue.helper.presentation.dex + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.presentation.dex.model.PokemonUiModel +import poke.rogue.helper.presentation.dex.model.toUi +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.CoroutinesTestExtension +import poke.rogue.helper.testing.data.repository.FakeDexRepository + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutinesTestExtension::class) +class PokemonListViewModelTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + modules(testViewModelModule) + } + + private val viewModel: PokemonListViewModel + get() = get() + + @Test + fun `๋ชจ๋“  ํฌ์ผ“๋ชฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // when + val pokemons = + viewModel.uiState.first { uiState -> + uiState.pokemons.isNotEmpty() + }.pokemons + + // then + pokemons shouldBe FakeDexRepository.POKEMONS.map(Pokemon::toUi) + } + + @Test + fun `ํฌ์ผ“๋ชฌ ์ด๋ฆ„์œผ๋กœ ์ฟผ๋ฆฌ๋œ ํฌ์ผ“๋ชฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // when + viewModel.queryName("๋ฆฌ์ž") + val queriedPokemons = + viewModel.uiState.first { uiState -> + uiState.pokemons.isNotEmpty() + }.pokemons + // then + val actualIds = queriedPokemons.map(PokemonUiModel::id) + actualIds shouldBe listOf("5", "6") + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivityTest.kt new file mode 100644 index 00000000..da996c41 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivityTest.kt @@ -0,0 +1,52 @@ +package poke.rogue.helper.presentation.dex.detail + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class PokemonDetailActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + private val scenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค`() { + scenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } + + @Test + fun `์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ตฌ์„ฑ ๋ณ€๊ฒฝ๋˜์–ด๋„ ๊ฐ™์€ ๋ทฐ๋ชจ๋ธ ์ธ์Šคํ„ด์Šค ์‚ฌ์šฉ`() { + // given + var previewViewModel: PokemonDetailViewModel? = null + scenario.onActivity { activity -> + previewViewModel = activity.getViewModel() + } + + // when + scenario.recreate() + + // then + scenario.onActivity { + it.getViewModel() shouldBe previewViewModel + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModelTest.kt new file mode 100644 index 00000000..7b6d6971 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModelTest.kt @@ -0,0 +1,106 @@ +package poke.rogue.helper.presentation.dex.detail + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.component.get +import org.koin.test.KoinTest +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.R +import poke.rogue.helper.data.model.PokemonBiome +import poke.rogue.helper.data.model.PokemonDetailSkills +import poke.rogue.helper.data.model.PokemonSkill +import poke.rogue.helper.presentation.dex.model.EvolutionsUiModel +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.toUi +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.testing.CoroutinesTestExtension + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutinesTestExtension::class) +class PokemonDetailViewModelTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + modules(testViewModelModule) + } + + private val viewModel: PokemonDetailViewModel + get() = get() + + @Test + fun `ํฌ์ผ“๋ชฌ ์ƒ์„ธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ ์ฒ˜์Œ์€ ๋กœ๋”ฉ ์ƒํƒœ์ด๋‹ค`() = + runTest { + // when + val expectedPokemonDetailUiState = viewModel.uiState + + // then + expectedPokemonDetailUiState.value shouldBe PokemonDetailUiState.IsLoading + } + + @Test + fun `ํฌ์ผ“๋ชฌ ์ƒ์„ธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // when + viewModel.updatePokemonDetail(pokemonId = "1") + + // then + val pokemonDetailUiState = + viewModel.uiState.first { uiState -> + uiState is PokemonDetailUiState.Success + } + + pokemonDetailUiState shouldBe + PokemonDetailUiState.Success( + pokemon = + PokemonUiModel( + id = "1", + dexNumber = 1, + name = "์ด์ƒํ•ด์”จ", + imageUrl = + "https://raw.githubusercontent.com" + + "/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png", + types = + listOf( + TypeUiModel.GRASS, + TypeUiModel.POISON, + ), + ), + stats = + listOf( + StatUiModel("HP", 45, 255, R.color.stat_hp), + StatUiModel("๊ณต๊ฒฉ", 49, 190, R.color.stat_attack), + StatUiModel("๋ฐฉ์–ด", 49, 250, R.color.stat_defense), + StatUiModel("ํŠน์ˆ˜๊ณต๊ฒฉ", 65, 194, R.color.stat_special_attack), + StatUiModel("ํŠน์ˆ˜๋ฐฉ์–ด", 65, 250, R.color.stat_special_defense), + StatUiModel("์Šคํ”ผ๋“œ", 45, 200, R.color.stat_speed), + StatUiModel("์ข…์กฑ๊ฐ’", 318, 800, R.color.stat_total), + ), + abilities = + listOf( + PokemonDetailAbilityUiModel("10", "๊ทธ๋ž˜์Šค๋ฉ”์ด์ปค", true, false), + PokemonDetailAbilityUiModel("450", "์‹ฌ๋ก", false, false), + PokemonDetailAbilityUiModel("419", "์—ฝ๋ก์†Œ", false, true), + ), + evolutions = + EvolutionsUiModel.DUMMY_PICAKCHU_EVOLUTION, + skills = + PokemonDetailSkills( + selfLearn = PokemonSkill.FAKE_SELF_LEARN_SKILLS, + eggLearn = PokemonSkill.FAKE_EGG_LEARN_SKILLS, + tmLearn = PokemonSkill.FAKE_SELF_LEARN_SKILLS, + ), + height = 0.7f, + weight = 6.9f, + biomes = PokemonBiome.DUMMYS.toUi(), + ) + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragmentTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragmentTest.kt new file mode 100644 index 00000000..088eedca --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragmentTest.kt @@ -0,0 +1,63 @@ +package poke.rogue.helper.presentation.dex.detail.evolution + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class PokemonEvolutionFragmentTest { + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค`() { + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + scenario.onFragment { fragment -> + fragment.shouldNotBeNull() + } + } + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ ๊ตฌ์„ฑ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ฐ™์€ ๋ทฐ๋ชจ๋ธ ์ธ์Šคํ„ด์Šค`() { + // given + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + var previewViewModel: PokemonDetailViewModel? = null + scenario.onFragment { fragment -> + previewViewModel = fragment.getViewModel() + } + + // when + scenario.recreate() + + // then + scenario.onFragment { fragment -> + fragment.getViewModel() shouldBe previewViewModel + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragmentTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragmentTest.kt new file mode 100644 index 00000000..9346dc63 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragmentTest.kt @@ -0,0 +1,63 @@ +package poke.rogue.helper.presentation.dex.detail.information + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class PokemonInformationFragmentTest { + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค`() { + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + scenario.onFragment { fragment -> + fragment.shouldNotBeNull() + } + } + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ ๊ตฌ์„ฑ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ฐ™์€ ๋ทฐ๋ชจ๋ธ ์ธ์Šคํ„ด์Šค`() { + // given + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + var previewViewModel: PokemonDetailViewModel? = null + scenario.onFragment { fragment -> + previewViewModel = fragment.getViewModel() + } + + // when + scenario.recreate() + + // then + scenario.onFragment { fragment -> + fragment.getViewModel() shouldBe previewViewModel + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragmentTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragmentTest.kt new file mode 100644 index 00000000..fa02b739 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragmentTest.kt @@ -0,0 +1,63 @@ +package poke.rogue.helper.presentation.dex.detail.skill + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class PokemonDetailSkillFragmentTest { + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค`() { + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + scenario.onFragment { fragment -> + fragment.shouldNotBeNull() + } + } + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ ๊ตฌ์„ฑ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ฐ™์€ ๋ทฐ๋ชจ๋ธ ์ธ์Šคํ„ด์Šค`() { + // given + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + var previewViewModel: PokemonDetailViewModel? = null + scenario.onFragment { fragment -> + previewViewModel = fragment.getViewModel() + } + + // when + scenario.recreate() + + // then + scenario.onFragment { fragment -> + fragment.getViewModel() shouldBe previewViewModel + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragmentTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragmentTest.kt new file mode 100644 index 00000000..0b6697c8 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragmentTest.kt @@ -0,0 +1,63 @@ +package poke.rogue.helper.presentation.dex.detail.stat + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.R +import poke.rogue.helper.presentation.dex.detail.PokemonDetailViewModel +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class PokemonStatFragmentTest { + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค`() { + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + scenario.onFragment { fragment -> + fragment.shouldNotBeNull() + } + } + + @Test + @Ignore("Issue: launchFragmentInContainer") + fun `ํ”„๋ž˜๊ทธ๋จผํŠธ ๊ตฌ์„ฑ ๋ณ€๊ฒฝ ์‹œ์—๋„ ๊ฐ™์€ ๋ทฐ๋ชจ๋ธ ์ธ์Šคํ„ด์Šค`() { + // given + val scenario = + launchFragmentInContainer( + themeResId = R.style.Theme_PokeRogueHelper, + ) + + var previewViewModel: PokemonDetailViewModel? = null + scenario.onFragment { fragment -> + previewViewModel = fragment.getViewModel() + } + + // when + scenario.recreate() + + // then + scenario.onFragment { fragment -> + fragment.getViewModel() shouldBe previewViewModel + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModelTest.kt new file mode 100644 index 00000000..d42b5fe5 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModelTest.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.dex.model + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + +class EvolutionsUiModelTest { + @Test + fun `ํฌ์ผ“๋ชฌ ๊ฐœ์ฒด์˜ ์ง„ํ™” ์ฒด์ธ์ด ์žˆ๋‹ค `() { + // given + val evolutionsUiModel = + EvolutionsUiModel( + SingleEvolutionUiModel( + pokemonId = "psyduck", + pokemonName = "๊ณ ๋ผํŒŒ๋•", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/54.png", + depth = 0, + ), + SingleEvolutionUiModel( + pokemonId = "golduck", + pokemonName = "๊ณจ๋•", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/55.png", + depth = 1, + level = 33, + ), + ) + + // when + val expected = evolutionsUiModel.hasEvolutionChain() + + // then + expected shouldBe true + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/di/TestViewModelModule.kt b/android/app/src/test/java/poke/rogue/helper/presentation/di/TestViewModelModule.kt new file mode 100644 index 00000000..bf90f6ba --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/di/TestViewModelModule.kt @@ -0,0 +1,45 @@ +package poke.rogue.helper.presentation.di + +import org.koin.core.module.dsl.singleOf +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.type.TypeViewModel +import poke.rogue.helper.testing.di.testingModule + +val testViewModelModule = + module { + includes(testingModule) + + singleOf(::PokemonListViewModel) + singleOf(::HomeViewModel) + singleOf(::AbilityViewModel) + singleOf(::AbilityDetailViewModel) + viewModelOf(::TypeViewModel) + 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()) + } + singleOf(::BiomeViewModel) + singleOf(::BiomeDetailViewModel) + singleOf(::PokemonDetailViewModel) + } diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt new file mode 100644 index 00000000..3e67ccc1 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.presentation.home + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class HomeActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + private val activityScenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + activityScenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/home/HomeViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/home/HomeViewModelTest.kt new file mode 100644 index 00000000..2494607a --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/home/HomeViewModelTest.kt @@ -0,0 +1,84 @@ +package poke.rogue.helper.presentation.home + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.CoroutinesTestExtension + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutinesTestExtension::class) +class HomeViewModelTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + modules(testViewModelModule) + } + + private val viewModel: HomeViewModel + get() = get() + + private val testDispatcher = StandardTestDispatcher() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @Test + fun `ํฌ์ผ“๋ชฌ ๋„๊ฐ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + viewModel.navigateToDex() + + val event = viewModel.navigationEvent.first() + event shouldBe HomeNavigateEvent.ToDex + } + + @Test + fun `ํฌ์ผ“๋ชฌ ๋ฐฐํ‹€ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + viewModel.navigateToBattle() + + val event = viewModel.navigationEvent.first() + event shouldBe HomeNavigateEvent.ToBattle + } + + @Test + fun `ํŠน์„ฑ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + viewModel.navigateToAbility() + + val event = viewModel.navigationEvent.first() + event shouldBe HomeNavigateEvent.ToAbility + } + + @Test + fun `ํฌ์ผ“๋กœ๊ทธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + viewModel.navigateToPokeRogue() + + val event = viewModel.navigationEvent.first() + event shouldBe HomeNavigateEvent.ToLogo + } + + @Test + fun `๋ฐ”์ด์˜ด ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•œ๋‹ค`() = + runTest { + viewModel.navigateToBiome() + + val event = viewModel.navigationEvent.first() + event shouldBe HomeNavigateEvent.ToBiome + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/type/TypeActivityTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/type/TypeActivityTest.kt new file mode 100644 index 00000000..4a5ca7a2 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/type/TypeActivityTest.kt @@ -0,0 +1,41 @@ +package poke.rogue.helper.presentation.type + +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.robolectric.annotation.Config +import poke.rogue.helper.presentation.di.testViewModelModule +import poke.rogue.helper.testing.TestApplication +import poke.rogue.helper.testing.rule.KoinAndroidUnitTestRule + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApplication::class) +class TypeActivityTest { + @get:Rule + val activityRule = activityScenarioRule() + val scenario get() = activityRule.scenario + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testViewModelModule, + ) + + @Test + fun `Activity ์‹คํ–‰ ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.shouldNotBeNull() + } + } + + @Test + fun `ViewModel ์ฃผ์ž… ํ…Œ์ŠคํŠธ`() { + scenario.onActivity { activity -> + activity.getViewModel().shouldNotBeNull() + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/type/TypeViewModelTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/type/TypeViewModelTest.kt new file mode 100644 index 00000000..8bab1518 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/type/TypeViewModelTest.kt @@ -0,0 +1,152 @@ +package poke.rogue.helper.presentation.type + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import poke.rogue.helper.data.repository.TypeRepository +import poke.rogue.helper.presentation.type.model.MatchedResultUiModel +import poke.rogue.helper.presentation.type.model.MatchedTypesUiModel +import poke.rogue.helper.presentation.type.model.SelectorType +import poke.rogue.helper.presentation.type.model.TypeUiModel +import poke.rogue.helper.testing.CoroutinesTestExtension +import poke.rogue.helper.testing.data.repository.FakeTypeRepository + +@ExperimentalCoroutinesApi +@ExtendWith(CoroutinesTestExtension::class) +class TypeViewModelTest { + private lateinit var typeRepository: TypeRepository + private lateinit var viewModel: TypeViewModel + + @BeforeEach + fun setup() { + typeRepository = FakeTypeRepository() + viewModel = TypeViewModel(typeRepository) + } + + @Test + fun `ViewModel์ด ์ดˆ๊ธฐํ™” ๋  ๋•Œ, ๋ชจ๋“  ํƒ€์ž… ์„ ํƒ ์ƒํƒœ๋Š” Empty ์ด๋‹ค `() = + runTest { + val expected = TypeSelectionUiState.Empty + val actualStates = viewModel.typeSelectionStates.value + + actualStates.myType shouldBe expected + actualStates.opponentType1 shouldBe expected + actualStates.opponentType2 shouldBe expected + } + + @Test + fun `๋‚ด ํƒ€์ž…์„ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ, ์„ ํƒ๋œ ํƒ€์ž…์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค`() = + runTest { + // given + val selectedMyType = TypeUiModel.FAIRY + + // when + viewModel.selectType(SelectorType.MINE, selectedMyType) + + // then + val expected = TypeSelectionUiState.Selected(TypeUiModel.FAIRY) + val actual = viewModel.typeSelectionStates.value.myType + actual shouldBe expected + } + + @Test + fun `๋‚ด ํƒ€์ž…๋งŒ ์„ ํƒ๋œ ๊ฒฝ์šฐ, ๋‚ด ํƒ€์ž…์— ๋Œ€ํ•œ ๋ชจ๋“  ์ƒ์„ฑ ๊ฒฐ๊ณผ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // given + val selectedMyType = TypeUiModel.FAIRY + + // when + viewModel.selectType(SelectorType.MINE, selectedMyType) + + // then + launch { + viewModel.type.collect { actual -> + val expected = + listOf( + MatchedTypesUiModel( + TypeUiModel.FAIRY, + true, + MatchedResultUiModel.STRONG, + listOf(TypeUiModel.ICE, TypeUiModel.DRAGON), + ), + MatchedTypesUiModel( + TypeUiModel.FAIRY, + true, + MatchedResultUiModel.WEAK, + listOf(TypeUiModel.FIRE, TypeUiModel.POISON), + ), + ) + actual shouldBe expected + cancel() + } + } + } + + @Test + fun `์ƒ๋Œ€ ํƒ€์ž… 1๊ฐœ๋งŒ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ, ํ•ด๋‹น ํƒ€์ž…์— ๋Œ€ํ•œ ๋ชจ๋“  ์ƒ์„ฑ ๊ฒฐ๊ณผ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // given + val selectedType = TypeUiModel.FAIRY + + // when + viewModel.selectType(SelectorType.OPPONENT1, selectedType) + + // then + launch { + viewModel.type.collect { actual -> + val expected = + listOf( + MatchedTypesUiModel( + TypeUiModel.FAIRY, + false, + MatchedResultUiModel.STRONG, + listOf(TypeUiModel.POISON, TypeUiModel.STEEL), + ), + MatchedTypesUiModel( + TypeUiModel.FAIRY, + false, + MatchedResultUiModel.NORMAL, + listOf(TypeUiModel.WATER, TypeUiModel.GRASS), + ), + ) + actual shouldBe expected + cancel() + } + } + } + + @Test + fun `๋‚ด ํƒ€์ž…๊ณผ ์ƒ๋Œ€ ํƒ€์ž… ๋ชจ๋‘๋ฅผ ์„ ํƒํ•˜๋ฉด, ๋‘ ํƒ€์ž…์˜ ์ƒ์„ฑ ๊ฒฐ๊ณผ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // given + val myType = TypeUiModel.FAIRY + val opponentType = TypeUiModel.FIGHTING + + // when + viewModel.selectType(SelectorType.MINE, myType) + viewModel.selectType(SelectorType.OPPONENT1, opponentType) + + // then + launch { + viewModel.type.collect { actual -> + val expected = + listOf( + MatchedTypesUiModel( + TypeUiModel.FAIRY, + true, + MatchedResultUiModel.STRONG, + listOf(TypeUiModel.FIGHTING), + ), + ) + + actual shouldBe expected + cancel() + } + } + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/presentation/util/event/EventFlowTest.kt b/android/app/src/test/java/poke/rogue/helper/presentation/util/event/EventFlowTest.kt new file mode 100644 index 00000000..8f83ab89 --- /dev/null +++ b/android/app/src/test/java/poke/rogue/helper/presentation/util/event/EventFlowTest.kt @@ -0,0 +1,59 @@ +package poke.rogue.helper.presentation.util.event + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class EventFlowTest { + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `EventFlow ๋Š” ์†Œ๋น„ํ•  ๋•Œ๊นŒ์ง€ element ๊ฐ€ ์‚ญ์ œ๋˜์ง€ ์•Š๋Š”๋‹ค`() = + runTest(UnconfinedTestDispatcher()) { + // given + val eventFlow = MutableEventFlow() + // when + eventFlow.emit(1) + delay(10) + // then + eventFlow + .onEach { + println(">>> onEach: $it") + it shouldBe 1 + } + .launchIn(backgroundScope) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `EventFlow ๋Š” element ๋ฅผ ๊ณต์œ ํ•˜์ง€ ์•Š๋Š”๋‹ค`() = + runTest { + // given + val eventFlow = MutableEventFlow() + // when + eventFlow.emit(1) + delay(10) + // then + backgroundScope.launch { + launch { + eventFlow.collect { + println(">>> collect: $it") + it shouldBe 1 + } + } + launch { + eventFlow.collect { + println(">>> Never Collect AnyThing") + it shouldBe Int.MAX_VALUE + } + } + } + advanceTimeBy(100) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index a0985efc..287058a3 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,46 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + dependencies { + classpath(libs.kotlin.gradleplugin) + classpath(libs.agp) + classpath(libs.ktlint) + } +} + plugins { - alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.jetbrainsKotlinAndroid) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.kapt) apply false + alias(libs.plugins.kotlinx.serialization) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.room) apply false + alias(libs.plugins.android.junit5) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics.plugin) apply false +} + +subprojects { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + tasks { + withType { + useJUnitPlatform() + } + + withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + } + } } \ No newline at end of file diff --git a/android/data/.gitignore b/android/data/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts new file mode 100644 index 00000000..da837c70 --- /dev/null +++ b/android/data/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) +} + +android { + namespace = "poke.rogue.helper.data" + 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() + } +} + +dependencies { + implementation(project(":local")) + implementation(project(":remote")) + implementation(project(":stringmatcher")) + implementation(project(":analytics")) + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin) + // third-party + implementation(libs.timber) + implementation(libs.glide) + // koin + implementation(platform(libs.koin.bom)) + implementation(libs.koin.core) + testImplementation(libs.koin.test.junit5) + // test + testImplementation(libs.bundles.unit.test) + testImplementation(libs.kotlin.test) +} diff --git a/android/data/consumer-rules.pro b/android/data/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/data/proguard-rules.pro b/android/data/proguard-rules.pro new file mode 100644 index 00000000..ff59496d --- /dev/null +++ b/android/data/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/data/src/main/AndroidManifest.xml b/android/data/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/android/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/data/src/main/java/poke/rogue/helper/data/cache/GlideImageCacher.kt b/android/data/src/main/java/poke/rogue/helper/data/cache/GlideImageCacher.kt new file mode 100644 index 00000000..4b97e52a --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/cache/GlideImageCacher.kt @@ -0,0 +1,73 @@ +package poke.rogue.helper.data.cache + +import android.content.Context +import android.graphics.drawable.Drawable +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import poke.rogue.helper.data.exception.UnknownException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class GlideImageCacher(private val context: Context) : ImageCacher { + override suspend fun cacheImages(urls: List) = + coroutineScope { + urls.forEach { url -> + launch { + cacheImage(url) + } + } + } + + /** + * ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—…์—์„œ๋Š” submit() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉ, ์ด ๊ฒฝ์šฐ RequestListener๋„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ํ˜ธ์ถœ + * UI ์ž‘์—…์„ ํ•  ๋•Œ๋Š” into() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉ, ์ด ๊ฒฝ์šฐ Main ์Šค๋ ˆ๋“œ์—์„œ RequestListener ์ด ํ˜ธ์ถœ + */ + private suspend fun cacheImage(url: String) = + suspendCancellableCoroutine { con -> + val requestListener = + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + con.resumeWithException( + e ?: UnknownException(IllegalStateException("์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹คํŒจ url: $url")), + ) + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + con.resume(Unit) + return false + } + } + + val target = + Glide.with(context) + .load(url) + .listener(requestListener) + .submit() + + con.invokeOnCancellation { + Glide.with(context).clear(target) + } + } + + override suspend fun clear() { + Glide.get(context).clearMemory() + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/cache/ImageCacher.kt b/android/data/src/main/java/poke/rogue/helper/data/cache/ImageCacher.kt new file mode 100644 index 00000000..09733328 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/cache/ImageCacher.kt @@ -0,0 +1,7 @@ +package poke.rogue.helper.data.cache + +interface ImageCacher { + suspend fun cacheImages(urls: List) + + suspend fun clear() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalBattleDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalBattleDataSource.kt new file mode 100644 index 00000000..50c86c6d --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalBattleDataSource.kt @@ -0,0 +1,30 @@ +package poke.rogue.helper.data.datasource + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import poke.rogue.helper.data.model.PokemonWithSkillIds +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.local.datastore.BattleDataStore + +class LocalBattleDataSource(private val battleDataStore: BattleDataStore) { + suspend fun savePokemonWithSkill( + pokemonId: String, + skillId: String, + ) { + battleDataStore.savePokemonWithSkill(pokemonId, skillId) + } + + suspend fun savePokemon(pokemonId: String) { + battleDataStore.savePokemon(pokemonId) + } + + suspend fun saveWeather(weatherId: String) { + battleDataStore.saveWeather(weatherId) + } + + fun weatherIdStream(): Flow = battleDataStore.weatherId() + + fun pokemonWithSkillStream(): Flow = battleDataStore.pokemonWithSkillId().map { it?.toData() } + + fun pokemonIdStream(): Flow = battleDataStore.pokemonId() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalDexDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalDexDataSource.kt new file mode 100644 index 00000000..15c2a659 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalDexDataSource.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.data.model.toEntity +import poke.rogue.helper.local.dao.PokemonDao + +class LocalDexDataSource( + private val pokemonDao: PokemonDao, + private val logger: AnalyticsLogger, +) { + suspend fun pokemons(): List = + runCatching { + pokemonDao.pokemons().map { it.toData() } + }.onFailure { + logger.logError(it, "LocalDexDataSource - pokemons() ์—์„œ ๋ฐœ์ƒ") + }.getOrThrow() + + suspend fun savePokemons(pokemons: List): Unit = + runCatching { + pokemonDao.savePokemons(pokemons.map { it.toEntity() }) + }.onFailure { + logger.logError(it, "LocalDexDataSource - savePokemons() ์—์„œ ๋ฐœ์ƒ") + }.getOrThrow() + + suspend fun clear(): Unit = + runCatching { + pokemonDao.clear() + }.onFailure { + logger.logError(it, "LocalDexDataSource - clearPokemons() ์—์„œ ๋ฐœ์ƒ") + }.getOrThrow() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalNavigationDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalNavigationDataSource.kt new file mode 100644 index 00000000..5dfc4abc --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalNavigationDataSource.kt @@ -0,0 +1,14 @@ +package poke.rogue.helper.data.datasource + +import kotlinx.coroutines.flow.Flow +import poke.rogue.helper.local.datastore.NavigationModeDataStore + +class LocalNavigationDataSource( + private val dataStore: NavigationModeDataStore, +) { + suspend fun saveNavigationMode(isBattleNavigationMode: Boolean) { + dataStore.saveNavigationMode(isBattleNavigationMode) + } + + fun isBattleNavigationModeStream(): Flow = dataStore.isBattleNavigationMode() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalTypeDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalTypeDataSource.kt new file mode 100644 index 00000000..c21919c0 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalTypeDataSource.kt @@ -0,0 +1,50 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.data.model.MatchedResult +import poke.rogue.helper.data.model.MatchedTypes +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.model.TypeMatchedTable + +class LocalTypeDataSource { + fun matchedTypesAgainstAttackingType(attackingTypeId: Int): List { + val attackingType = Type.fromId(attackingTypeId) + val result = + TypeMatchedTable.typeMatchedTable.getValue(attackingType).entries.groupBy( + { it.value }, + { it.key }, + ) + + return result.toMatchedTypesList() + } + + fun matchedTypesAgainstDefendingType(defendingTypeId: Int): List { + val defendingType = Type.fromId(defendingTypeId) + val result = + TypeMatchedTable.typeMatchedTable.entries.groupBy( + keySelector = { (_, defenderMap) -> defenderMap.getValue(defendingType) }, + valueTransform = { (attackerType, _) -> attackerType }, + ) + + return result.toMatchedTypesList() + } + + fun matchedTypes( + attackingTypeId: Int, + defendingTypeIds: List, + ): List { + val attackingType = Type.fromId(attackingTypeId) + val defendingTypes = defendingTypeIds.map { Type.fromId(it) } + val result = + defendingTypes.groupBy { defendingType -> + TypeMatchedTable.typeMatchedTable.getValue(attackingType).getValue(defendingType) + } + return result.toMatchedTypesList() + } + + fun allTypes(): List = Type.entries.toList() + + private fun Map>.toMatchedTypesList(): List = + this.entries.map { (matchedResult, types) -> + MatchedTypes(matchedResult, types) + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalVersionDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalVersionDataSource.kt new file mode 100644 index 00000000..b5193ccc --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/LocalVersionDataSource.kt @@ -0,0 +1,11 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.local.datastore.VersionDataStore + +class LocalVersionDataSource( + private val versionDataStore: VersionDataStore, +) { + fun databaseVersionStream() = versionDataStore.databaseVersionStream() + + suspend fun saveDatabaseVersion(version: Int) = versionDataStore.saveDatabaseVersion(version) +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteAbilityDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteAbilityDataSource.kt new file mode 100644 index 00000000..bf1e5e57 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteAbilityDataSource.kt @@ -0,0 +1,31 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.exception.getOrThrow +import poke.rogue.helper.data.exception.onFailure +import poke.rogue.helper.data.model.Ability +import poke.rogue.helper.data.model.AbilityDetail +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.remote.dto.response.ability.AbilityResponse +import poke.rogue.helper.remote.service.AbilityService + +class RemoteAbilityDataSource( + private val abilityService: AbilityService, + private val logger: AnalyticsLogger, +) { + suspend fun abilities(): List = + abilityService.abilities() + .onFailure { + logger.logError(throwable, "abilityService - abilities() ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .map(AbilityResponse::toData) + + suspend fun abilityDetail(id: String): AbilityDetail = + abilityService.ability(id) + .onFailure { + logger.logError(throwable, "abilityService - ability($id) ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .toData() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteBattleDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteBattleDataSource.kt new file mode 100644 index 00000000..40c92f29 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteBattleDataSource.kt @@ -0,0 +1,49 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.exception.getOrThrow +import poke.rogue.helper.data.exception.onFailure +import poke.rogue.helper.data.model.BattlePrediction +import poke.rogue.helper.data.model.BattleSkill +import poke.rogue.helper.data.model.Weather +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.remote.service.BattleService + +class RemoteBattleDataSource( + private val battleService: BattleService, + private val logger: AnalyticsLogger, +) { + suspend fun weathers(): List = + battleService.weathers() + .onFailure { + logger.logError(throwable, "battleService - weathers() ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .map { it.toData() } + + suspend fun availableSkills(dexNumber: Long): List = + battleService.availableSkills(dexNumber) + .onFailure { + logger.logError(throwable, "battleService - availableSkills($dexNumber) ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .map { it.toData() } + + suspend fun calculatedBattlePrediction( + weatherId: String, + myPokemonId: String, + mySkillId: String, + opponentPokemonId: String, + ): BattlePrediction = + battleService.calculatedBattlePrediction( + weatherId, + myPokemonId, + mySkillId, + opponentPokemonId, + ).onFailure { + logger.logError( + throwable, + "battleService - calculatedBattlePrediction($weatherId, $myPokemonId, $mySkillId, $opponentPokemonId) ์—์„œ ๋ฐœ์ƒ", + ) + }.getOrThrow().toData() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteBiomeDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteBiomeDataSource.kt new file mode 100644 index 00000000..0ab9f6cd --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteBiomeDataSource.kt @@ -0,0 +1,30 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.exception.getOrThrow +import poke.rogue.helper.data.exception.onFailure +import poke.rogue.helper.data.model.Biome +import poke.rogue.helper.data.model.BiomeDetail +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.remote.service.BiomeService + +class RemoteBiomeDataSource( + private val biomeService: BiomeService, + private val logger: AnalyticsLogger, +) { + suspend fun biomes(): List = + biomeService.biomes() + .onFailure { + logger.logError(throwable, "biomeService - biomes() ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .map { it.toData() } + + suspend fun biomeDetail(id: String): BiomeDetail = + biomeService.biome(id) + .onFailure { + logger.logError(throwable, "biomeService - biome($id) ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .toData() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteDexDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteDexDataSource.kt new file mode 100644 index 00000000..8790acbe --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteDexDataSource.kt @@ -0,0 +1,40 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.exception.getOrThrow +import poke.rogue.helper.data.exception.onFailure +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonDetail +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse2 +import poke.rogue.helper.remote.service.PokeDexService + +class RemoteDexDataSource( + private val pokeDexService: PokeDexService, + private val logger: AnalyticsLogger, +) { + @Deprecated("pokemons2() ์‚ฌ์šฉ ๊ถŒ์žฅ - ๊ทผ๋ฐ ์„œ๋ฒ„์—์„œ ์•„์ง ๋ฐ์ดํ„ฐ ์•ˆ์˜ด") + suspend fun pokemons(): List = + pokeDexService.pokemons() + .onFailure { + logger.logError(throwable, "pokeDexService - pokemons() ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .toData() + + suspend fun pokemons2(): List = + pokeDexService.pokemons2() + .onFailure { + logger.logError(throwable, "pokeDexService - pokemons2() ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .map(PokemonResponse2::toData) + + suspend fun pokemon(id: String): PokemonDetail = + pokeDexService.pokemon(id) + .onFailure { + logger.logError(throwable, "pokeDexService - pokemon2($id) ์—์„œ ๋ฐœ์ƒ") + } + .getOrThrow() + .toData(id) +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteVersionDataSource.kt b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteVersionDataSource.kt new file mode 100644 index 00000000..e5c5e14c --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/datasource/RemoteVersionDataSource.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.data.datasource + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.exception.getOrThrow +import poke.rogue.helper.data.exception.onFailure +import poke.rogue.helper.remote.service.VersionService + +class RemoteVersionDataSource( + private val versionService: VersionService, + private val logger: AnalyticsLogger, +) { + suspend fun databaseVersion(): Int = + versionService.databaseVersion() + .onFailure { + logger.logError(throwable, "RemoteVersionDataSource - databaseVersion() ์—์„œ ๋ฐœ์ƒ") + }.getOrThrow().version +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/di/CacheModule.kt b/android/data/src/main/java/poke/rogue/helper/data/di/CacheModule.kt new file mode 100644 index 00000000..b25f66c1 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/di/CacheModule.kt @@ -0,0 +1,13 @@ +package poke.rogue.helper.data.di + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import poke.rogue.helper.data.cache.GlideImageCacher +import poke.rogue.helper.data.cache.ImageCacher + +internal val cacheModule + get() = + module { + singleOf(::GlideImageCacher).bind() + } diff --git a/android/data/src/main/java/poke/rogue/helper/data/di/DataModule.kt b/android/data/src/main/java/poke/rogue/helper/data/di/DataModule.kt new file mode 100644 index 00000000..f0119590 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/di/DataModule.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.data.di + +import org.koin.dsl.module +import poke.rogue.helper.analytics.di.analyticsModule +import poke.rogue.helper.local.di.localModule +import poke.rogue.helper.remote.di.remoteModule + +val dataModule + get() = + module { + includes( + localModule, + remoteModule, + dataSourceModule, + repositoryModule, + analyticsModule, + cacheModule, + ) + } diff --git a/android/data/src/main/java/poke/rogue/helper/data/di/DataSourceModule.kt b/android/data/src/main/java/poke/rogue/helper/data/di/DataSourceModule.kt new file mode 100644 index 00000000..6231b56d --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/di/DataSourceModule.kt @@ -0,0 +1,30 @@ +package poke.rogue.helper.data.di + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import poke.rogue.helper.data.datasource.LocalBattleDataSource +import poke.rogue.helper.data.datasource.LocalDexDataSource +import poke.rogue.helper.data.datasource.LocalNavigationDataSource +import poke.rogue.helper.data.datasource.LocalTypeDataSource +import poke.rogue.helper.data.datasource.LocalVersionDataSource +import poke.rogue.helper.data.datasource.RemoteAbilityDataSource +import poke.rogue.helper.data.datasource.RemoteBattleDataSource +import poke.rogue.helper.data.datasource.RemoteBiomeDataSource +import poke.rogue.helper.data.datasource.RemoteDexDataSource +import poke.rogue.helper.data.datasource.RemoteVersionDataSource + +internal val dataSourceModule + get() = + module { + singleOf(::LocalBattleDataSource) + singleOf(::LocalDexDataSource) + singleOf(::LocalTypeDataSource) + singleOf(::LocalNavigationDataSource) + singleOf(::LocalVersionDataSource) + + singleOf(::RemoteBattleDataSource) + singleOf(::RemoteAbilityDataSource) + singleOf(::RemoteDexDataSource) + singleOf(::RemoteBiomeDataSource) + singleOf(::RemoteVersionDataSource) + } diff --git a/android/data/src/main/java/poke/rogue/helper/data/di/RepositoryModule.kt b/android/data/src/main/java/poke/rogue/helper/data/di/RepositoryModule.kt new file mode 100644 index 00000000..4db067e6 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/di/RepositoryModule.kt @@ -0,0 +1,25 @@ +package poke.rogue.helper.data.di + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import poke.rogue.helper.data.repository.AbilityRepository +import poke.rogue.helper.data.repository.BattleRepository +import poke.rogue.helper.data.repository.BiomeRepository +import poke.rogue.helper.data.repository.DefaultAbilityRepository +import poke.rogue.helper.data.repository.DefaultBattleRepository +import poke.rogue.helper.data.repository.DefaultBiomeRepository +import poke.rogue.helper.data.repository.DefaultDexRepository +import poke.rogue.helper.data.repository.DefaultTypeRepository +import poke.rogue.helper.data.repository.DexRepository +import poke.rogue.helper.data.repository.TypeRepository + +internal val repositoryModule + get() = + module { + singleOf(::DefaultBattleRepository).bind() + singleOf(::DefaultAbilityRepository).bind() + singleOf(::DefaultDexRepository).bind() + singleOf(::DefaultBiomeRepository).bind() + singleOf(::DefaultTypeRepository).bind() + } diff --git a/android/data/src/main/java/poke/rogue/helper/data/exception/ApiResponseExtensions.kt b/android/data/src/main/java/poke/rogue/helper/data/exception/ApiResponseExtensions.kt new file mode 100644 index 00000000..8ba1c388 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/exception/ApiResponseExtensions.kt @@ -0,0 +1,78 @@ +package poke.rogue.helper.data.exception + +import poke.rogue.helper.remote.dto.base.ApiResponse +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +fun ApiResponse.getOrNull(): T? { + return when (this) { + is ApiResponse.Success -> data + is ApiResponse.Failure -> null + } +} + +fun ApiResponse.getOrElse(defaultValue: T): T { + return when (this) { + is ApiResponse.Success -> data + is ApiResponse.Failure -> defaultValue + } +} + +inline fun ApiResponse.getOrElse(defaultValue: () -> T): T { + return when (this) { + is ApiResponse.Success -> data + is ApiResponse.Failure -> defaultValue() + } +} + +fun ApiResponse.getOrThrow(): T { + return when (this) { + is ApiResponse.Success -> data + is ApiResponse.Failure -> { + when (this) { + is ApiResponse.Failure.HttpException -> throw HttpException(code, throwable) + is ApiResponse.Failure.NetworkException -> throw NetworkException(throwable) + is ApiResponse.Failure.UnknownError -> throw UnknownException(throwable) + } + } + } +} + +@OptIn(ExperimentalContracts::class) +inline fun ApiResponse.onSuccess(crossinline onResult: ApiResponse.Success.() -> Unit): ApiResponse { + contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) } + if (this is ApiResponse.Success) { + onResult(this) + } + return this +} + +@OptIn(ExperimentalContracts::class) +inline fun ApiResponse.onFailure(crossinline onResult: ApiResponse.Failure.() -> Unit): ApiResponse { + contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) } + if (this is ApiResponse.Failure) { + onResult(this) + } + return this +} + +@OptIn(ExperimentalContracts::class) +inline fun ApiResponse.onHttpException(crossinline onResult: ApiResponse.Failure.HttpException.() -> Unit): ApiResponse { + contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) } + if (this is ApiResponse.Failure.HttpException) { + onResult(this) + } + return this +} + +@OptIn(ExperimentalContracts::class) +inline fun ApiResponse.onNetworkException( + crossinline onResult: ApiResponse.Failure.NetworkException.() -> Unit, +): ApiResponse { + contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) } + if (this is ApiResponse.Failure.NetworkException) { + onResult(this) + } + return this +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/exception/PokeException.kt b/android/data/src/main/java/poke/rogue/helper/data/exception/PokeException.kt new file mode 100644 index 00000000..86a044b9 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/exception/PokeException.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.data.exception + +sealed class PokeException(val error: Throwable) : RuntimeException(error) + +class HttpException(code: Int, throwable: Throwable) : PokeException(throwable) { + init { + require(code in ERROR_CODE_RANGE) + } + + companion object { + private val ERROR_CODE_RANGE = 100..599 + } +} + +class NetworkException(throwable: Throwable) : PokeException(throwable) + +class UnknownException(throwable: Throwable) : PokeException(throwable) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Ability.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Ability.kt new file mode 100644 index 00000000..ab0b86b7 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Ability.kt @@ -0,0 +1,16 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.ability.AbilityResponse + +data class Ability( + val id: String = "0", + val title: String, + val description: String, +) + +fun AbilityResponse.toData(): Ability = + Ability( + id = id, + title = name, + description = description, + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/AbilityDetail.kt b/android/data/src/main/java/poke/rogue/helper/data/model/AbilityDetail.kt new file mode 100644 index 00000000..5d86ae13 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/AbilityDetail.kt @@ -0,0 +1,25 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.ability.AbilityDetailResponse +import poke.rogue.helper.remote.dto.response.ability.AbilityDetailResponse2 +import poke.rogue.helper.remote.dto.response.ability.AbilityPokemonResponse + +class AbilityDetail( + val title: String, + val description: String, + val pokemons: List, +) + +fun AbilityDetailResponse.toData(): AbilityDetail = + AbilityDetail( + title = title, + description = description, + pokemons = pokemons.toData(), + ) + +fun AbilityDetailResponse2.toData(): AbilityDetail = + AbilityDetail( + title = name, + description = description, + pokemons = pokemons.map(AbilityPokemonResponse::toData), + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/BattlePrediction.kt b/android/data/src/main/java/poke/rogue/helper/data/model/BattlePrediction.kt new file mode 100644 index 00000000..920cdc2c --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/BattlePrediction.kt @@ -0,0 +1,8 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.battle.BattlePredictionResponse + +data class BattlePrediction(val power: Int, val accuracy: Double, val multiplier: Double, val calculatedResult: Double) + +fun BattlePredictionResponse.toData(): BattlePrediction = + BattlePrediction(power = power, accuracy = accuracy, multiplier = multiplier, calculatedResult = power * multiplier) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/BattleSkill.kt b/android/data/src/main/java/poke/rogue/helper/data/model/BattleSkill.kt new file mode 100644 index 00000000..b7936340 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/BattleSkill.kt @@ -0,0 +1,24 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.battle.PokemonSkillResponse + +data class BattleSkill( + val id: String, + val name: String, + val type: Type, + val categoryLogo: String, + val power: Int, + val accuracy: Int, + val effect: String, +) + +fun PokemonSkillResponse.toData(): BattleSkill = + BattleSkill( + id = id, + name = name, + type = Type.valueOf(typeEngName.uppercase()), + categoryLogo = categoryLogo, + power = power, + accuracy = accuracy, + effect = effect, + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Biome.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Biome.kt new file mode 100644 index 00000000..bde10bcf --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Biome.kt @@ -0,0 +1,20 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.biomes.BiomesResponse + +data class Biome( + val id: String, + val name: String, + val image: String, + val pokemonType: List, + val gymLeaderType: List, +) + +fun BiomesResponse.toData(): Biome = + Biome( + id = id, + name = name, + image = image, + pokemonType = pokemonTypes.toData(), + gymLeaderType = gymLeaderTypes.toData(), + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/BiomeDetail.kt b/android/data/src/main/java/poke/rogue/helper/data/model/BiomeDetail.kt new file mode 100644 index 00000000..4b902425 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/BiomeDetail.kt @@ -0,0 +1,28 @@ +package poke.rogue.helper.data.model + +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.data.model.biome.toData +import poke.rogue.helper.remote.dto.response.biomes.BiomeDetailResponse + +data class BiomeDetail( + val id: String, + val name: String, + val image: String, + val wildPokemons: List, + val bossPokemons: List, + val gymPokemons: List, + val nextBiomes: List, +) + +fun BiomeDetailResponse.toData(): BiomeDetail = + BiomeDetail( + id = id, + name = name, + image = image, + wildPokemons = wildPokemons.toData(), + bossPokemons = bossPokemons.toData(), + gymPokemons = gymPokemons.toData(), + nextBiomes = nextBiomes.toData(), + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Evolution.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Evolution.kt new file mode 100644 index 00000000..18d20ef8 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Evolution.kt @@ -0,0 +1,210 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.pokemon.EvolutionResponse +import poke.rogue.helper.remote.dto.response.pokemon.EvolutionsResponse + +data class Evolution( + val pokemonId: String, + val pokemonName: String, + val imageUrl: String, + val depth: Int, + val level: Int = LEVEL_DOES_NOT_MATTER, + val item: String? = null, + val condition: String? = null, +) { + companion object { + const val LEVEL_DOES_NOT_MATTER = 1 + + val DUMMY_PICHU = + Evolution( + pokemonId = "pichu", + pokemonName = "ํ”ผ์ธ„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/172.png", + depth = 0, + ) + + val DUMMY_PIKACHU = + Evolution( + pokemonId = "pikachu{Normal}", + pokemonName = "ํ”ผ์นด์ธ„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png", + depth = 1, + condition = "์นœ๋ฐ€๋„ 90", + ) + + val DUMMY_RAICHU = + Evolution( + pokemonId = "raichu{Normal}", + pokemonName = "๋ผ์ด์ธ„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/26.png", + depth = 2, + item = "์ฒœ๋‘ฅ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + ) + + val DUMMY_ALOLA_RAICHU = + Evolution( + pokemonId = "raichu{Alola}", + pokemonName = "๋ผ์ด์ธ„{์•Œ๋กœ๋ผ}", + imageUrl = "https://data1.pokemonkorea.co.kr/newdata/pokedex/full/002602.png", + depth = 2, + item = "์ฒœ๋‘ฅ์˜ ๋Œ", + condition = "์„ฌ, ํ•ด๋ณ€์—์„œ ์•„์ดํ…œ ์‚ฌ์šฉ", + ) + + val DUMMY_GIGA_PIKACHU = + Evolution( + pokemonId = "pikachu{G-Max} ", + pokemonName = "ํ”ผ์นด์ธ„{G-Max}", + imageUrl = "https://data1.pokemonkorea.co.kr/newdata/pokedex/full/002502.png", + depth = 2, + item = "๋‹ค์ด ๋งฅ์Šค ๋ฒ„์„ฏ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + ) + + val DUMMY_PSYDUCK = + Evolution( + pokemonId = "psyduck", + pokemonName = "๊ณ ๋ผํŒŒ๋•", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/54.png", + depth = 0, + ) + + val DUMMY_GOLDUCK = + Evolution( + pokemonId = "golduck", + pokemonName = "๊ณจ๋•", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/55.png", + level = 33, + depth = 1, + ) + + val DUMMY_EEVEE = + Evolution( + pokemonId = "eevee{Normal}", + pokemonName = "์ด๋ธŒ์ด", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/133.png", + depth = 0, + ) + + val DUMMY_SYLYEON = + Evolution( + pokemonId = "sylveon", + pokemonName = "๋‹˜ํ”ผ์•„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/700.png", + condition = "์นœ๋ฐ€๋„ 70 \n+ ํŽ˜์–ด๋ฆฌ ํƒ€์ž… ๊ธฐ์ˆ  ์Šต๋“", + depth = 1, + ) + + val DUMMY_ESPEON = + Evolution( + pokemonId = "espeon", + pokemonName = "์—๋ธŒ์ด", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/196.png", + condition = "์นœ๋ฐ€๋„ 70 \n+ ๋‚ฎ์— ๋ ˆ๋ฒจ์—…", + depth = 1, + ) + + val DUMMY_UMBREON = + Evolution( + pokemonId = "umbreon", + pokemonName = "๋ธ”๋ž˜ํ‚ค", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/197.png", + condition = "์นœ๋ฐ€๋„ 70 \n+ ๋ฐค์— ๋ ˆ๋ฒจ์—…", + depth = 1, + ) + + val DUMMY_VAPOREON = + Evolution( + pokemonId = "vaporeon", + pokemonName = "์ƒค๋ฏธ๋“œ", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/134.png", + item = "๋ฌผ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_JOLTEON = + Evolution( + pokemonId = "jolteon", + pokemonName = "์ฅฌํ”ผ์ฌ๋”", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/135.png", + item = "์ฒœ๋‘ฅ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_FLAREON = + Evolution( + pokemonId = "flareon", + pokemonName = "๋ถ€์Šคํ„ฐ", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/136.png", + item = "ํ™”์—ผ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_LEAFEON = + Evolution( + pokemonId = "leafeon", + pokemonName = "๋ฆฌํ”ผ์•„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/470.png", + item = "๋ฆฌํ”„์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_GLACEON = + Evolution( + pokemonId = "glaceon", + pokemonName = "๊ธ€๋ ˆ์ด์‹œ์•„", + imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/471.png", + item = "๋ˆˆ์˜ ๋Œ", + condition = "์•„์ดํ…œ ์‚ฌ์šฉ", + depth = 1, + ) + + val DUMMY_PICAKCHU_EVOLUTION = + listOf( + DUMMY_PICHU, + DUMMY_PIKACHU, + DUMMY_RAICHU, + DUMMY_ALOLA_RAICHU, + DUMMY_GIGA_PIKACHU, + ) + + val DUMMY_PSYDUCK_EVOLUTION = + listOf( + DUMMY_PSYDUCK, + DUMMY_GOLDUCK, + ) + + val DUMMY_EVE_EVOLUTION = + listOf( + DUMMY_EEVEE, + DUMMY_SYLYEON, + DUMMY_ESPEON, + DUMMY_UMBREON, + DUMMY_VAPOREON, + DUMMY_JOLTEON, + DUMMY_FLAREON, + DUMMY_LEAFEON, + DUMMY_GLACEON, + ) + } +} + +fun EvolutionResponse.toData(): Evolution = + Evolution( + pokemonId = pokemonId, + pokemonName = pokemonName, + imageUrl = imageUrl, + depth = depth, + level = level, + item = item, + condition = condition, + ) + +fun List.toData(): List = map(EvolutionResponse::toData) + +fun EvolutionsResponse.toData(): List = evolutions.toData() diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/MatchedResult.kt b/android/data/src/main/java/poke/rogue/helper/data/model/MatchedResult.kt new file mode 100644 index 00000000..dc985405 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/MatchedResult.kt @@ -0,0 +1,8 @@ +package poke.rogue.helper.data.model + +enum class MatchedResult { + STRONG, + WEAK, + INEFFECTIVE, + NORMAL, +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/MatchedTypes.kt b/android/data/src/main/java/poke/rogue/helper/data/model/MatchedTypes.kt new file mode 100644 index 00000000..78486dd4 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/MatchedTypes.kt @@ -0,0 +1,3 @@ +package poke.rogue.helper.data.model + +data class MatchedTypes(val matchedResult: MatchedResult, val types: List) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/NextBiome.kt b/android/data/src/main/java/poke/rogue/helper/data/model/NextBiome.kt new file mode 100644 index 00000000..c9ed85d1 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/NextBiome.kt @@ -0,0 +1,24 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.biomes.NextBiomesResponse + +data class NextBiome( + val id: String, + val name: String, + val image: String, + val pokemonType: List, + val gymLeaderType: List, + val probability: Double, +) + +fun NextBiomesResponse.toData(): NextBiome = + NextBiome( + id = id, + name = name, + image = image, + pokemonType = pokemonTypes.toData(), + gymLeaderType = gymLeaderTypes.toData(), + probability = probability, + ) + +fun List.toData(): List = map(NextBiomesResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Pokemon.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Pokemon.kt new file mode 100644 index 00000000..867eba6e --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Pokemon.kt @@ -0,0 +1,141 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.local.entity.PokemonEntity +import poke.rogue.helper.remote.dto.response.ability.AbilityPokemonResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse2 +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +data class Pokemon( + val id: String, + val dexNumber: Long, + val name: String, + val formName: String = "", + val imageUrl: String, + val backImageUrl: String, + val types: List, + val generation: PokemonGeneration = PokemonGeneration.ONE, + val baseStat: Int = 0, + val hp: Int = 0, + val attack: Int = 0, + val defense: Int = 0, + val specialAttack: Int = 0, + val specialDefense: Int = 0, + val speed: Int = 0, +) { + companion object { + private const val DUMMY_POKEMON_NAME = "์ด์ƒํ•ด์”จ" + private const val DUMMY_IMAGE_URL = + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png" + private val DUMMY_TYPES = listOf(Type.GRASS, Type.POISON) + private const val DUMMY_BACK_IMAGE_URL = "" + + val DUMMY = + Pokemon( + id = "1", + dexNumber = 1, + name = DUMMY_POKEMON_NAME, + imageUrl = DUMMY_IMAGE_URL, + backImageUrl = DUMMY_BACK_IMAGE_URL, + types = DUMMY_TYPES, + ) + } +} + +fun PokemonResponse.toData(): Pokemon = + Pokemon( + id = id.toString(), + dexNumber = pokedexNumber, + name = name, + imageUrl = image, + backImageUrl = "", + types = types.map(PokemonTypeResponse::toData), + ) + +fun PokemonResponse2.toData(): Pokemon { + // TODO : ์ง€๊ธˆ ํฌ์ผ“๋ชฌ ์ด๋ฆ„์„ ๋ณด์—ฌ์ฃผ๋Š”๊ฑฐ ๋ชจ๋“  ๊ณณ์—์„œ ํ†ต์ผ์ด ์•ˆ๋จ.. + val pureName = name.substringBefore("_") + val pascalCaseFormName = + formName.split("_").joinToString("") { original -> + if (original.isBlank()) { + original + } else { + original.replaceFirstChar { it.uppercase() } + } + } + + val formattedName = + if (formName.isBlank() || formName.trim().lowercase() == "normal") { + pureName + } else { + "$pureName-$pascalCaseFormName" + } + + return Pokemon( + id = id, + dexNumber = pokedexNumber, + name = formattedName, + formName = formName, + imageUrl = image, + backImageUrl = backImage, + types = types.map(PokemonTypeResponse::toData), + generation = PokemonGeneration.of(generation), + baseStat = baseStats, + speed = speed, + hp = hp, + attack = attack, + defense = defense, + specialAttack = specialAttack, + specialDefense = specialDefense, + ) +} + +fun PokemonEntity.toData(): Pokemon = + Pokemon( + id = id, + dexNumber = dexNumber, + name = name, + formName = formName, + imageUrl = imageUrl, + backImageUrl = backImageUrl, + types = types.map(Type::valueOf), + generation = PokemonGeneration.of(generation), + baseStat = baseStat, + speed = speed, + hp = hp, + attack = attack, + defense = defense, + specialAttack = specialAttack, + specialDefense = specialDefense, + ) + +fun Pokemon.toEntity(): PokemonEntity = + PokemonEntity( + id = id, + dexNumber = dexNumber, + name = name, + formName = formName, + imageUrl = imageUrl, + backImageUrl = backImageUrl, + types = types.map(Type::name).toSet(), + generation = generation.number, + baseStat = baseStat, + speed = speed, + hp = hp, + attack = attack, + defense = defense, + specialAttack = specialAttack, + specialDefense = specialDefense, + ) + +fun List.toData(): List = map(PokemonResponse::toData) + +fun AbilityPokemonResponse.toData(): Pokemon = + Pokemon( + id = id, + dexNumber = pokedexNumber, + name = name, + imageUrl = image, + backImageUrl = image, + types = pokemonTypeResponses.toData(), + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonBiome.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonBiome.kt new file mode 100644 index 00000000..073918de --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonBiome.kt @@ -0,0 +1,43 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.biom.PokemonBiomeResponse + +data class PokemonBiome( + val id: String, + val name: String, + val imageUrl: String, + val pokemonType: List = emptyList(), +) { + companion object { + val DUMMYS: List = + listOf( + PokemonBiome( + id = "1", + name = "ํ‰์•ผ", + imageUrl = "https://pokeroguedex.com/biomes/plains.png", + pokemonType = listOf(Type.GRASS, Type.BUG), + ), + PokemonBiome( + id = "2", + name = "๋†’์€ ํ’€์ˆฒ", + imageUrl = "https://pokeroguedex.com/biomes/tall-grass.png", + pokemonType = listOf(Type.GRASS, Type.BUG, Type.FLYING), + ), + PokemonBiome( + id = "3", + name = "๋™๊ตด", + imageUrl = "https://pokeroguedex.com/biomes/cave.png", + pokemonType = listOf(Type.GROUND, Type.ROCK), + ), + ) + } +} + +fun PokemonBiomeResponse.toData(): PokemonBiome = + PokemonBiome( + id = id, + name = name, + imageUrl = image, + ) + +fun List.toData(): List = map(PokemonBiomeResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonCategory.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonCategory.kt new file mode 100644 index 00000000..c872d88f --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonCategory.kt @@ -0,0 +1,18 @@ +package poke.rogue.helper.data.model + +data class PokemonCategory( + val legendary: Boolean, + val subLegendary: Boolean, + val mythical: Boolean, + val canChangeForm: Boolean, +) { + companion object { + val EMPTY = + PokemonCategory( + legendary = false, + subLegendary = false, + mythical = false, + canChangeForm = false, + ) + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetail.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetail.kt new file mode 100644 index 00000000..c16d073b --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetail.kt @@ -0,0 +1,53 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.biom.PokemonBiomeResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonDetailResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonSkillResponse +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +data class PokemonDetail( + val pokemon: Pokemon, + val abilities: List, + val stats: List, + val pokemonCategory: PokemonCategory, + val evolutions: List, + val skills: PokemonDetailSkills, + val biomes: List, + val height: Double, + val weight: Double, +) + +fun PokemonDetailResponse.toData(id: String): PokemonDetail = + PokemonDetail( + pokemon = + Pokemon( + id = id, + dexNumber = dexNumber, + name = name, + imageUrl = imageUrl, + backImageUrl = "", + types = types.map(PokemonTypeResponse::toData), + ), + abilities = abilities.toData(), + stats = + listOf( + Stat("hp", hp), + Stat("attack", attack), + Stat("defense", defense), + Stat("specialAttack", specialAttack), + Stat("specialDefense", specialDefense), + Stat("speed", speed), + Stat("total", totalStats), + ), + pokemonCategory = PokemonCategory.EMPTY, + evolutions = evolutions.toData(), + skills = + PokemonDetailSkills( + selfLearn = selfLearnSkills.map(PokemonSkillResponse::toData), + tmLearn = selfLearnSkills.map(PokemonSkillResponse::toData), + eggLearn = eggSkills.map(PokemonSkillResponse::toData), + ), + biomes = biomes.map(PokemonBiomeResponse::toData), + height = height.toDouble(), + weight = weight.toDouble(), + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetailAbility.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetailAbility.kt new file mode 100644 index 00000000..bc043fa9 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetailAbility.kt @@ -0,0 +1,54 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.ability.PokemonAbilityResponse + +data class PokemonDetailAbility( + val id: String, + val name: String, + val description: String, + val passive: Boolean, + val hidden: Boolean, +) { + companion object { + val DUMMY_POKEMON_DETAIL_ABILTIES = + listOf( + PokemonDetailAbility( + id = "10", + name = "๊ทธ๋ž˜์Šค๋ฉ”์ด์ปค", + description = "๋“ฑ์žฅํ–ˆ์„ ๋•Œ ๊ทธ๋ž˜์Šคํ•„๋“œ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.", + passive = true, + hidden = false, + ), + PokemonDetailAbility( + id = "450", + name = "์‹ฌ๋ก", + description = "HP๊ฐ€ ์ค„์—ˆ์„ ๋•Œ ํ’€ํƒ€์ž… ๊ธฐ์ˆ ์˜ ์œ„๋ ฅ์ด ์˜ฌ๋ผ๊ฐ„๋‹ค.", + passive = false, + hidden = false, + ), + PokemonDetailAbility( + id = "419", + name = "์—ฝ๋ก์†Œ", + description = "๋‚ ์”จ๊ฐ€ ๋ง‘์„ ๋•Œ ์Šคํ”ผ๋“œ๊ฐ€ ์˜ฌ๋ผ๊ฐ„๋‹ค.", + passive = false, + hidden = true, + ), + ) + } +} + +fun PokemonAbilityResponse.toData(): PokemonDetailAbility = + PokemonDetailAbility( + id = id, + name = name, + description = description, + passive = passive, + hidden = hidden, + ) + +fun List.toData(): List = + groupBy { ability -> + ability.id + }.map { (_, value) -> + value.first().toData() + } diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetailSkills.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetailSkills.kt new file mode 100644 index 00000000..1af97c5b --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonDetailSkills.kt @@ -0,0 +1,7 @@ +package poke.rogue.helper.data.model + +data class PokemonDetailSkills( + val selfLearn: List, + val eggLearn: List, + val tmLearn: List, +) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonFilter.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonFilter.kt new file mode 100644 index 00000000..52685eec --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonFilter.kt @@ -0,0 +1,7 @@ +package poke.rogue.helper.data.model + +sealed class PokemonFilter { + data class ByType(val type: Type) : PokemonFilter() + + data class ByGeneration(val generation: PokemonGeneration) : PokemonFilter() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonGeneration.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonGeneration.kt new file mode 100644 index 00000000..2fbfbc75 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonGeneration.kt @@ -0,0 +1,21 @@ +package poke.rogue.helper.data.model + +enum class PokemonGeneration(val number: Int) { + ONE(1), + TWO(2), + THREE(3), + FOUR(4), + FIVE(5), + SIX(6), + SEVEN(7), + EIGHT(8), + NINE(9), + ; + + companion object { + fun of(number: Int): PokemonGeneration { + return entries.find { it.number == number } + ?: throw IllegalArgumentException("Unknown generation number: $number") + } + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonSkill.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonSkill.kt new file mode 100644 index 00000000..6390d2f2 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonSkill.kt @@ -0,0 +1,353 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.pokemon.PokemonSkillResponse + +data class PokemonSkill( + val id: String, + val name: String, + val level: Int, + val power: Int, + val type: Type, + val accuracy: Int, + val category: SkillCategory, +) { + companion object { + const val NO_POWER_VALUE = -1 + const val NO_ACCURACY_VALUE = -1 + + val FAKE_EGG_LEARN_SKILLS = + listOf( + PokemonSkill( + id = "101", + name = "๊ธฐ๊ฐ€๋“œ๋ ˆ์ธ", + level = 1, + power = 75, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.specialAttackSkill, + ), + PokemonSkill( + id = "102", + name = "์˜ค๋ฌผํญํƒ„", + level = 1, + power = 90, + type = Type.POISON, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "103", + name = "๋Œ€์ง€์˜ํž˜", + level = 1, + power = 90, + type = Type.GROUND, + accuracy = 100, + category = SkillCategory.specialAttackSkill, + ), + PokemonSkill( + id = "104", + name = "์‘ฅ์‘ฅ๋ด„๋ฒ„", + level = 1, + power = 100, + type = Type.GRASS, + accuracy = 90, + category = SkillCategory.physicalAttackSkill, + ), + ) + + val FAKE_SELF_LEARN_SKILLS = + listOf( + PokemonSkill( + id = "1", + name = "๋ชธํ†ต๋ฐ•์น˜๊ธฐ", + level = 1, + power = 40, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "2", + name = "์šธ์Œ์†Œ๋ฆฌ", + level = 1, + power = NO_POWER_VALUE, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "3", + name = "๋ฉ๊ตด์ฑ„์ฐ", + level = 1, + power = 45, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "4", + name = "์„ฑ์žฅ", + level = 6, + power = NO_POWER_VALUE, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "5", + name = "์”จ๋ฟŒ๋ฆฌ๊ธฐ", + level = 9, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 90, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "6", + name = "์•ž๋‚ ๊ฐ€๋ฅด๊ธฐ", + level = 12, + power = 55, + type = Type.GRASS, + accuracy = 95, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "7", + name = "๋…๊ฐ€๋ฃจ", + level = 15, + power = NO_POWER_VALUE, + type = Type.POISON, + accuracy = 75, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "8", + name = "์ˆ˜๋ฉด๊ฐ€๋ฃจ", + level = 15, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 75, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "9", + name = "์”จํญํƒ„", + level = 18, + power = 80, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "10", + name = "๋Œ์ง„", + level = 21, + power = 90, + type = Type.NORMAL, + accuracy = 85, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "11", + name = "๋‹ฌ์ฝคํ•œํ–ฅ๊ธฐ", + level = 24, + power = NO_POWER_VALUE, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "12", + name = "๊ด‘ํ•ฉ์„ฑ", + level = 27, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "13", + name = "๊ณ ๋ฏผ์”จ", + level = 30, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "14", + name = "ํŒŒ์›Œํœฉ", + level = 33, + power = 120, + type = Type.GRASS, + accuracy = 85, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "15", + name = "์†”๋ผ๋น”", + level = 36, + power = 120, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.specialAttackSkill, + ), + ) + + val FAKE_TM_LEARN_SKILLS = + listOf( + PokemonSkill( + id = "1", + name = "๋ชธํ†ต๋ฐ•์น˜๊ธฐ", + level = 1, + power = 40, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "2", + name = "์šธ์Œ์†Œ๋ฆฌ", + level = 1, + power = NO_POWER_VALUE, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "3", + name = "๋ฉ๊ตด์ฑ„์ฐ", + level = 1, + power = 45, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "4", + name = "์„ฑ์žฅ", + level = 6, + power = NO_POWER_VALUE, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "5", + name = "์”จ๋ฟŒ๋ฆฌ๊ธฐ", + level = 9, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 90, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "6", + name = "์•ž๋‚ ๊ฐ€๋ฅด๊ธฐ", + level = 12, + power = 55, + type = Type.GRASS, + accuracy = 95, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "7", + name = "๋…๊ฐ€๋ฃจ", + level = 15, + power = NO_POWER_VALUE, + type = Type.POISON, + accuracy = 75, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "8", + name = "์ˆ˜๋ฉด๊ฐ€๋ฃจ", + level = 15, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 75, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "9", + name = "์”จํญํƒ„", + level = 18, + power = 80, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "10", + name = "๋Œ์ง„", + level = 21, + power = 90, + type = Type.NORMAL, + accuracy = 85, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "11", + name = "๋‹ฌ์ฝคํ•œํ–ฅ๊ธฐ", + level = 24, + power = NO_POWER_VALUE, + type = Type.NORMAL, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "12", + name = "๊ด‘ํ•ฉ์„ฑ", + level = 27, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "13", + name = "๊ณ ๋ฏผ์”จ", + level = 30, + power = NO_POWER_VALUE, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.changeStatusSkill, + ), + PokemonSkill( + id = "14", + name = "ํŒŒ์›Œํœฉ", + level = 33, + power = 120, + type = Type.GRASS, + accuracy = 85, + category = SkillCategory.physicalAttackSkill, + ), + PokemonSkill( + id = "15", + name = "์†”๋ผ๋น”", + level = 36, + power = 120, + type = Type.GRASS, + accuracy = 100, + category = SkillCategory.specialAttackSkill, + ), + ) + } +} + +fun PokemonSkillResponse.toData(): PokemonSkill = + PokemonSkill( + id = id, + name = name, + level = level, + power = power, + type = Type.of(type), + accuracy = accuracy, + category = + SkillCategory( + category, + categoryLogo, + ), + ) + +fun List.toData(): List = map(PokemonSkillResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonSort.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonSort.kt new file mode 100644 index 00000000..d7adfae4 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonSort.kt @@ -0,0 +1,68 @@ +package poke.rogue.helper.data.model + +enum class PokemonSort : Comparator { + ByBaseStat { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.baseStat.compareTo(p2.baseStat)) + } + }, + ByDexNumber { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return p1.dexNumber.compareTo(p2.dexNumber) + } + }, + BySpeed { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.speed.compareTo(p2.speed)) + } + }, + ByAttack { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.attack.compareTo(p2.attack)) + } + }, + ByDefense { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.defense.compareTo(p2.defense)) + } + }, + BySpecialAttack { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.specialAttack.compareTo(p2.specialAttack)) + } + }, + BySpecialDefense { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.specialDefense.compareTo(p2.specialDefense)) + } + }, + ByHp { + override fun compare( + p1: Pokemon, + p2: Pokemon, + ): Int { + return -(p1.hp.compareTo(p2.hp)) + } + }, +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/PokemonWithSkillIds.kt b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonWithSkillIds.kt new file mode 100644 index 00000000..35b832dc --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/PokemonWithSkillIds.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.local.datastore.SavedPokemonWithSkill + +data class PokemonWithSkillIds(val pokemonId: String, val skillId: String) + +fun SavedPokemonWithSkill.toData() = PokemonWithSkillIds(pokemonId, skillId) + +data class PokemonWithSkill(val pokemon: Pokemon, val skill: BattleSkill) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/SkillCategory.kt b/android/data/src/main/java/poke/rogue/helper/data/model/SkillCategory.kt new file mode 100644 index 00000000..4d4c83a8 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/SkillCategory.kt @@ -0,0 +1,26 @@ +package poke.rogue.helper.data.model + +data class SkillCategory( + val name: String, + val logo: String, +) { + companion object { + val physicalAttackSkill = + SkillCategory( + name = "๋ฌผ๋ฆฌ", + logo = "https://img.pokemondb.net/images/icons/move-physical.png", + ) + + val specialAttackSkill = + SkillCategory( + name = "ํŠน์ˆ˜", + logo = "https://img.pokemondb.net/images/icons/move-special.png", + ) + + val changeStatusSkill = + SkillCategory( + name = "๋ณ€ํ™”", + logo = "https://img.pokemondb.net/images/icons/move-status.png", + ) + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Stat.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Stat.kt new file mode 100644 index 00000000..959a4df2 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Stat.kt @@ -0,0 +1,40 @@ +package poke.rogue.helper.data.model + +data class Stat( + val name: String, + val amount: Int, +) { + companion object { + val DUMMY_STATS = + listOf( + Stat( + name = "hp", + amount = 45, + ), + Stat( + name = "attack", + amount = 49, + ), + Stat( + name = "defense", + amount = 49, + ), + Stat( + name = "specialAttack", + amount = 65, + ), + Stat( + name = "specialDefense", + amount = 65, + ), + Stat( + name = "speed", + amount = 45, + ), + Stat( + name = "total", + amount = 318, + ), + ) + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Type.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Type.kt new file mode 100644 index 00000000..470d392e --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Type.kt @@ -0,0 +1,51 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse +import timber.log.Timber +import java.util.Locale + +enum class Type(val id: Int, val koName: String) { + NORMAL(0, "๋…ธ๋ง"), + FIRE(1, "๋ถˆ๊ฝƒ"), + WATER(2, "๋ฌผ"), + ELECTRIC(3, "์ „๊ธฐ"), + GRASS(4, "ํ’€"), + ICE(5, "์–ผ์Œ"), + FIGHTING(6, "๊ฒฉํˆฌ"), + POISON(7, "๋…"), + GROUND(8, "๋•…"), + FLYING(9, "๋น„ํ–‰"), + PSYCHIC(10, "์—์Šคํผ"), + BUG(11, "๋ฒŒ๋ ˆ"), + ROCK(12, "๋ฐ”์œ„"), + GHOST(13, "๊ณ ์ŠคํŠธ"), + DRAGON(14, "๋“œ๋ž˜๊ณค"), + DARK(15, "์•…"), + STEEL(16, "๊ฐ•์ฒ "), + FAIRY(17, "ํŽ˜์–ด๋ฆฌ"), + STELLAR(18, "์Šคํ…”๋ผ"), + ; + + companion object { + private val typeById = entries.associateBy { it.id } + + fun fromId(id: Int): Type { + return typeById[id] ?: throw IllegalArgumentException("Unknown type ID: $id") + } + + fun of(name: String): Type { + val type = + entries.firstOrNull { + it.name == name.uppercase(Locale.ROOT).trim() || it.koName == name + } ?: run { + Timber.e("Unknown type name: $name") + NORMAL + } + return type + } + } +} + +fun PokemonTypeResponse.toData(): Type = Type.of(typeName) + +fun List.toData(): List = map(PokemonTypeResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/TypeMatchedTable.kt b/android/data/src/main/java/poke/rogue/helper/data/model/TypeMatchedTable.kt new file mode 100644 index 00000000..17cdcd19 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/TypeMatchedTable.kt @@ -0,0 +1,425 @@ +package poke.rogue.helper.data.model + +object TypeMatchedTable { + val typeMatchedTable: Map> = + mapOf( + Type.NORMAL to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.WEAK, + Type.GHOST to MatchedResult.INEFFECTIVE, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.FIRE to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.WEAK, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.STRONG, + Type.ICE to MatchedResult.STRONG, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.STRONG, + Type.ROCK to MatchedResult.WEAK, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.WEAK, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.STRONG, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.WATER to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.STRONG, + Type.WATER to MatchedResult.WEAK, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.WEAK, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.STRONG, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.STRONG, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.WEAK, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.NORMAL, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.ELECTRIC to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.STRONG, + Type.ELECTRIC to MatchedResult.WEAK, + Type.GRASS to MatchedResult.WEAK, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.INEFFECTIVE, + Type.FLYING to MatchedResult.STRONG, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.WEAK, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.NORMAL, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.GRASS to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.STRONG, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.WEAK, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.WEAK, + Type.GROUND to MatchedResult.STRONG, + Type.FLYING to MatchedResult.WEAK, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.WEAK, + Type.ROCK to MatchedResult.STRONG, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.WEAK, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.ICE to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.WEAK, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.STRONG, + Type.ICE to MatchedResult.WEAK, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.STRONG, + Type.FLYING to MatchedResult.STRONG, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.STRONG, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.FIGHTING to + mapOf( + Type.NORMAL to MatchedResult.STRONG, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.STRONG, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.WEAK, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.WEAK, + Type.PSYCHIC to MatchedResult.WEAK, + Type.BUG to MatchedResult.WEAK, + Type.ROCK to MatchedResult.STRONG, + Type.GHOST to MatchedResult.INEFFECTIVE, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.STRONG, + Type.STEEL to MatchedResult.STRONG, + Type.FAIRY to MatchedResult.WEAK, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.POISON to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.STRONG, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.WEAK, + Type.GROUND to MatchedResult.WEAK, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.WEAK, + Type.GHOST to MatchedResult.WEAK, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.INEFFECTIVE, + Type.FAIRY to MatchedResult.STRONG, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.GROUND to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.STRONG, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.STRONG, + Type.GRASS to MatchedResult.WEAK, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.STRONG, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.INEFFECTIVE, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.WEAK, + Type.ROCK to MatchedResult.STRONG, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.STRONG, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.FLYING to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.WEAK, + Type.GRASS to MatchedResult.STRONG, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.STRONG, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.STRONG, + Type.ROCK to MatchedResult.WEAK, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.PSYCHIC to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.STRONG, + Type.POISON to MatchedResult.STRONG, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.WEAK, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.INEFFECTIVE, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.BUG to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.STRONG, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.WEAK, + Type.POISON to MatchedResult.WEAK, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.WEAK, + Type.PSYCHIC to MatchedResult.STRONG, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.WEAK, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.STRONG, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.WEAK, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.ROCK to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.STRONG, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.STRONG, + Type.FIGHTING to MatchedResult.WEAK, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.WEAK, + Type.FLYING to MatchedResult.STRONG, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.STRONG, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.GHOST to + mapOf( + Type.NORMAL to MatchedResult.INEFFECTIVE, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.STRONG, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.STRONG, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.WEAK, + Type.STEEL to MatchedResult.NORMAL, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.DRAGON to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.STRONG, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.INEFFECTIVE, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.DARK to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.WEAK, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.STRONG, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.STRONG, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.WEAK, + Type.STEEL to MatchedResult.NORMAL, + Type.FAIRY to MatchedResult.WEAK, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.STEEL to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.WEAK, + Type.ELECTRIC to MatchedResult.WEAK, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.STRONG, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.STRONG, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.FAIRY to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.STRONG, + Type.POISON to MatchedResult.WEAK, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.STRONG, + Type.DARK to MatchedResult.STRONG, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + Type.STELLAR to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.NORMAL, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.NORMAL, + Type.POISON to MatchedResult.NORMAL, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.NORMAL, + Type.DARK to MatchedResult.NORMAL, + Type.STEEL to MatchedResult.NORMAL, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + ) +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/Weather.kt b/android/data/src/main/java/poke/rogue/helper/data/model/Weather.kt new file mode 100644 index 00000000..f6cebab5 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/Weather.kt @@ -0,0 +1,18 @@ +package poke.rogue.helper.data.model + +import poke.rogue.helper.remote.dto.response.battle.WeatherResponse + +data class Weather( + val id: String, + val name: String, + val description: String, + val effects: List, +) + +fun WeatherResponse.toData(): Weather = + Weather( + id = id, + name = name, + description = description, + effects = effects, + ) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/biome/BiomePokemon.kt b/android/data/src/main/java/poke/rogue/helper/data/model/biome/BiomePokemon.kt new file mode 100644 index 00000000..be8eb9be --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/biome/BiomePokemon.kt @@ -0,0 +1,22 @@ +package poke.rogue.helper.data.model.biome + +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.remote.dto.response.biomes.BiomePokemonResponse + +data class BiomePokemon( + val id: String, + val name: String, + val imageUrl: String, + val types: List, +) + +fun BiomePokemonResponse.toData(): BiomePokemon = + BiomePokemon( + id = id, + name = name, + imageUrl = image, + types = types.toData(), + ) + +fun List.toData(): List = map(BiomePokemonResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/biome/BossPokemon.kt b/android/data/src/main/java/poke/rogue/helper/data/model/biome/BossPokemon.kt new file mode 100644 index 00000000..ea9c9e62 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/biome/BossPokemon.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.data.model.biome + +import poke.rogue.helper.remote.dto.response.biomes.BossPokemonResponse + +data class BossPokemon( + val tier: String, + val pokemons: List, +) + +fun BossPokemonResponse.toData(): BossPokemon = + BossPokemon( + tier = tier, + pokemons = pokemons.toData(), + ) + +fun List.toData(): List = + filter { + it.pokemons.isNotEmpty() + }.map(BossPokemonResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/biome/GymPokemon.kt b/android/data/src/main/java/poke/rogue/helper/data/model/biome/GymPokemon.kt new file mode 100644 index 00000000..23bb8ca6 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/biome/GymPokemon.kt @@ -0,0 +1,23 @@ +package poke.rogue.helper.data.model.biome + +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.model.toData +import poke.rogue.helper.remote.dto.response.biomes.GymPokemonResponse +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +data class GymPokemon( + val gymLeaderName: String, + val gymLeaderImage: String, + val gymLeaderTypeLogos: List, + val pokemons: List, +) + +fun GymPokemonResponse.toData(): GymPokemon = + GymPokemon( + gymLeaderName = gymLeaderName, + gymLeaderImage = gymLeaderImage, + gymLeaderTypeLogos = gymLeaderTypeLogos.map(PokemonTypeResponse::toData), + pokemons = pokemons.toData(), + ) + +fun List.toData(): List = map(GymPokemonResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/model/biome/WildPokemon.kt b/android/data/src/main/java/poke/rogue/helper/data/model/biome/WildPokemon.kt new file mode 100644 index 00000000..36e108f6 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/model/biome/WildPokemon.kt @@ -0,0 +1,16 @@ +package poke.rogue.helper.data.model.biome + +import poke.rogue.helper.remote.dto.response.biomes.WildPokemonResponse + +data class WildPokemon( + val tier: String, + val pokemons: List, +) + +fun WildPokemonResponse.toData(): WildPokemon = + WildPokemon( + tier = tier, + pokemons = pokemons.toData(), + ) + +fun List.toData(): List = filter { it.pokemons.isNotEmpty() }.map(WildPokemonResponse::toData) diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/AbilityRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/AbilityRepository.kt new file mode 100644 index 00000000..74d7070a --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/AbilityRepository.kt @@ -0,0 +1,12 @@ +package poke.rogue.helper.data.repository + +import poke.rogue.helper.data.model.Ability +import poke.rogue.helper.data.model.AbilityDetail + +interface AbilityRepository { + suspend fun abilities(): List + + suspend fun abilities(query: String): List + + suspend fun abilityDetail(id: String): AbilityDetail +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/BattleRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/BattleRepository.kt new file mode 100644 index 00000000..6c08cd9f --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/BattleRepository.kt @@ -0,0 +1,38 @@ +package poke.rogue.helper.data.repository + +import kotlinx.coroutines.flow.Flow +import poke.rogue.helper.data.model.BattlePrediction +import poke.rogue.helper.data.model.BattleSkill +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonWithSkill +import poke.rogue.helper.data.model.Weather + +interface BattleRepository { + suspend fun weathers(): List + + suspend fun availableSkills(dexNumber: Long): List + + suspend fun calculatedBattlePrediction( + weatherId: String, + myPokemonId: String, + mySkillId: String, + opponentPokemonId: String, + ): BattlePrediction + + suspend fun saveBattleSelection(pokemonId: String) + + suspend fun saveBattleSelection( + pokemonId: String, + skillId: String, + ) + + suspend fun saveWeather(weatherId: String) + + fun weatherStream(): Flow + + fun pokemonStream(): Flow + + fun pokemonWithSkillStream(): Flow + + suspend fun pokemonWithSkill(pokemonId: String): PokemonWithSkill +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/BiomeRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/BiomeRepository.kt new file mode 100644 index 00000000..ea64c01e --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/BiomeRepository.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.data.repository + +import kotlinx.coroutines.flow.Flow +import poke.rogue.helper.data.model.Biome +import poke.rogue.helper.data.model.BiomeDetail + +interface BiomeRepository { + suspend fun biomes(): List + + suspend fun biomes(query: String): List + + suspend fun biomeDetail(id: String): BiomeDetail + + suspend fun saveNavigationMode(isBattleNavigationMode: Boolean) + + fun isBattleNavigationModeStream(): Flow +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultAbilityRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultAbilityRepository.kt new file mode 100644 index 00000000..6b4ebaf0 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultAbilityRepository.kt @@ -0,0 +1,36 @@ +package poke.rogue.helper.data.repository + +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.analytics.analyticsLogger +import poke.rogue.helper.data.datasource.RemoteAbilityDataSource +import poke.rogue.helper.data.model.Ability +import poke.rogue.helper.data.model.AbilityDetail +import poke.rogue.helper.data.utils.logAbilityDetail +import poke.rogue.helper.stringmatcher.has + +class DefaultAbilityRepository( + private val remoteAbilityDataSource: RemoteAbilityDataSource, + private val analyticsLogger: AnalyticsLogger, +) : + AbilityRepository { + private var cachedAbilities: List = emptyList() + + override suspend fun abilities(): List { + if (cachedAbilities.isEmpty()) { + cachedAbilities = remoteAbilityDataSource.abilities() + } + return cachedAbilities + } + + override suspend fun abilities(query: String): List { + if (query.isBlank()) { + return abilities() + } + return abilities().filter { it.title.has(query) } + } + + override suspend fun abilityDetail(id: String): AbilityDetail = + remoteAbilityDataSource.abilityDetail(id).also { + analyticsLogger.logAbilityDetail(id, it.title) + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultBattleRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultBattleRepository.kt new file mode 100644 index 00000000..3327c3cd --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultBattleRepository.kt @@ -0,0 +1,87 @@ +package poke.rogue.helper.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import poke.rogue.helper.data.datasource.LocalBattleDataSource +import poke.rogue.helper.data.datasource.RemoteBattleDataSource +import poke.rogue.helper.data.model.BattlePrediction +import poke.rogue.helper.data.model.BattleSkill +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonWithSkill +import poke.rogue.helper.data.model.Weather + +class DefaultBattleRepository( + private val localBattleDataSource: LocalBattleDataSource, + private val remoteBattleDataSource: RemoteBattleDataSource, + private val pokemonRepository: DexRepository, +) : BattleRepository { + private val cachedSkills: MutableMap> = mutableMapOf() + + override suspend fun weathers(): List = remoteBattleDataSource.weathers() + + override suspend fun availableSkills(dexNumber: Long): List = + cachedSkills[dexNumber] ?: run { + val skills = remoteBattleDataSource.availableSkills(dexNumber).distinct() + cachedSkills[dexNumber] = skills + skills + } + + override suspend fun calculatedBattlePrediction( + weatherId: String, + myPokemonId: String, + mySkillId: String, + opponentPokemonId: String, + ): BattlePrediction = + remoteBattleDataSource.calculatedBattlePrediction( + weatherId = weatherId, + myPokemonId = myPokemonId, + mySkillId = mySkillId, + opponentPokemonId = opponentPokemonId, + ) + + override suspend fun saveBattleSelection(pokemonId: String) = localBattleDataSource.savePokemon(pokemonId) + + override suspend fun saveBattleSelection( + pokemonId: String, + skillId: String, + ) = localBattleDataSource.savePokemonWithSkill(pokemonId, skillId) + + override fun pokemonStream(): Flow = + localBattleDataSource.pokemonIdStream().map { + it?.let { pokemonRepository.pokemon(it) } + } + + override fun pokemonWithSkillStream(): Flow = + localBattleDataSource.pokemonWithSkillStream().map { + it?.let { pokemonWithSkill -> + val pokemon = pokemonRepository.pokemon(pokemonWithSkill.pokemonId) + val skill = skill(pokemon.dexNumber, pokemonWithSkill.skillId) + PokemonWithSkill(pokemon, skill) + } + } + + override suspend fun saveWeather(weatherId: String) = localBattleDataSource.saveWeather(weatherId) + + override fun weatherStream(): Flow = + localBattleDataSource.weatherIdStream().map { + if (it == null) { + return@map null + } + weathers().find { weather -> weather.id == it } + } + + private suspend fun skill( + dexNumber: Long, + skillId: String, + ): BattleSkill = + availableSkills(dexNumber).find { it.id == skillId } + ?: error("์•„์ด๋””์— ํ•ด๋‹นํ•˜๋Š” ์Šคํ‚ฌ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. id: $skillId") + + override suspend fun pokemonWithSkill(pokemonId: String): PokemonWithSkill { + val pokemon = pokemonRepository.pokemon(pokemonId) + val skill = + availableSkills(pokemon.dexNumber).firstOrNull() + ?: error("๋ฐฐ์ • ๊ฐ€๋Šฅํ•œ ์Šคํ‚ฌ์ด ์กด์žฌ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. - dexNumber : ${pokemon.dexNumber}") + return PokemonWithSkill(pokemon, skill) + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultBiomeRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultBiomeRepository.kt new file mode 100644 index 00000000..f1f8a0b5 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultBiomeRepository.kt @@ -0,0 +1,43 @@ +package poke.rogue.helper.data.repository + +import kotlinx.coroutines.flow.Flow +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.datasource.LocalNavigationDataSource +import poke.rogue.helper.data.datasource.RemoteBiomeDataSource +import poke.rogue.helper.data.model.Biome +import poke.rogue.helper.data.model.BiomeDetail +import poke.rogue.helper.data.utils.logBiomeDetail +import poke.rogue.helper.stringmatcher.has + +class DefaultBiomeRepository( + private val remoteBiomeDataSource: RemoteBiomeDataSource, + private val analyticsLogger: AnalyticsLogger, + private val localNavigationDataSource: LocalNavigationDataSource, +) : BiomeRepository { + private var cachedBiomes: List = emptyList() + + override suspend fun biomes(): List { + if (cachedBiomes.isEmpty()) { + cachedBiomes = remoteBiomeDataSource.biomes() + } + return cachedBiomes + } + + override suspend fun biomes(query: String): List { + if (query.isBlank()) { + return biomes() + } + return biomes().filter { it.name.has(query) } + } + + override suspend fun biomeDetail(id: String): BiomeDetail = + remoteBiomeDataSource.biomeDetail(id).also { + analyticsLogger.logBiomeDetail(id, it.name) + } + + override suspend fun saveNavigationMode(isBattleNavigationMode: Boolean) { + localNavigationDataSource.saveNavigationMode(isBattleNavigationMode) + } + + override fun isBattleNavigationModeStream(): Flow = localNavigationDataSource.isBattleNavigationModeStream() +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt new file mode 100644 index 00000000..ae845253 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultDexRepository.kt @@ -0,0 +1,152 @@ +package poke.rogue.helper.data.repository + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.cache.ImageCacher +import poke.rogue.helper.data.datasource.LocalDexDataSource +import poke.rogue.helper.data.datasource.LocalVersionDataSource +import poke.rogue.helper.data.datasource.RemoteDexDataSource +import poke.rogue.helper.data.datasource.RemoteVersionDataSource +import poke.rogue.helper.data.model.Biome +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonBiome +import poke.rogue.helper.data.model.PokemonDetail +import poke.rogue.helper.data.model.PokemonFilter +import poke.rogue.helper.data.model.PokemonSort +import poke.rogue.helper.data.utils.logPokemonDetail +import poke.rogue.helper.stringmatcher.has + +class DefaultDexRepository( + private val remotePokemonDataSource: RemoteDexDataSource, + private val localPokemonDataSource: LocalDexDataSource, + private val imageCacher: ImageCacher, + private val biomeRepository: BiomeRepository, + private val analyticsLogger: AnalyticsLogger, + private val localVersionDataSource: LocalVersionDataSource, + private val remoteVersionService: RemoteVersionDataSource, +) : DexRepository { + private var cachedPokemons: List = emptyList() + + override suspend fun warmUp() { + val localVersion = localVersionDataSource.databaseVersionStream().firstOrNull() + val remoteVersion = remoteVersionService.databaseVersion() + val shouldUpdateDatabase = shouldUpdateDatabaseVersion(localVersion, remoteVersion) + + if (shouldUpdateDatabase) { + localVersionDataSource.saveDatabaseVersion(remoteVersion) + val pokemons = remotePokemonDataSource.pokemons2() + cachePokemons(pokemons) + return + } + + val emptyDiskCache = localPokemonDataSource.pokemons().isEmpty() + if (emptyDiskCache) { + cachedPokemons = remotePokemonDataSource.pokemons2() + return + } + + cachedPokemons = localPokemonDataSource.pokemons() + } + + private fun shouldUpdateDatabaseVersion( + localVersion: Int?, + remoteVersion: Int, + ): Boolean { + return (localVersion ?: 0) < remoteVersion + } + + private suspend fun cachePokemons(pokemons: List) = + coroutineScope { + val urls = pokemons.take(PLELOAD_POKEMON_COUNT).map { it.imageUrl } + launch { + imageCacher.cacheImages(urls) + } + launch { + localPokemonDataSource.clear() + localPokemonDataSource.savePokemons(pokemons) + } + }.also { + cachedPokemons = pokemons + } + + override suspend fun pokemons(): List { + if (cachedPokemons.isEmpty()) { + warmUp() + } + return cachedPokemons + } + + override suspend fun filteredPokemons( + name: String, + sort: PokemonSort, + filters: List, + ): List = + if (name.isBlank()) { + pokemons() + } else { + pokemons().filter { it.name.has(name) } + }.toFilteredPokemons(sort, filters) + + override suspend fun pokemonDetail(id: String): PokemonDetail { + val allBiomes = biomeRepository.biomes() + return coroutineScope { + return@coroutineScope pokemonDetail(id, allBiomes).also { + val pokemonName = it.pokemon.name + " " + it.pokemon.formName + analyticsLogger.logPokemonDetail(id, pokemonName) + } + } + } + + override suspend fun pokemon(id: String): Pokemon { + cachedPokemons.find { it.id == id }?.let { + return it + } + return pokemons().find { it.id == id } ?: error("์•„์ด๋””์— ํ•ด๋‹นํ•˜๋Š” ํฌ์ผ“๋ชฌ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. id : $id") + } + + private suspend fun pokemonDetail( + id: String, + allBiomes: List, + ): PokemonDetail { + val pokemonDetail = remotePokemonDataSource.pokemon(id) + val pokemonDetailIds = pokemonDetail.biomes.map(PokemonBiome::id) + val pokemonBiomes = + allBiomes + .filter { biome -> biome.id in pokemonDetailIds } + .toPokemonBiome() + + return pokemonDetail.copy( + biomes = pokemonBiomes, + ) + } + + private fun List.toFilteredPokemons( + sort: PokemonSort, + pokemonFilters: List, + ): List = + this + .filter { pokemon -> + pokemonFilters.all { pokemonFilter -> + when (pokemonFilter) { + is PokemonFilter.ByType -> pokemon.types.contains(pokemonFilter.type) + is PokemonFilter.ByGeneration -> pokemon.generation == pokemonFilter.generation + } + } + }.sortedWith(sort) + + companion object { + const val PLELOAD_POKEMON_COUNT = 24 + } +} + +private fun Biome.toPokemonBiome(): PokemonBiome = + PokemonBiome( + id = id, + name = name, + imageUrl = image, + pokemonType = pokemonType, + ) + +private fun List.toPokemonBiome(): List = map(Biome::toPokemonBiome) diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultTypeRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultTypeRepository.kt new file mode 100644 index 00000000..0138c04f --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DefaultTypeRepository.kt @@ -0,0 +1,29 @@ +package poke.rogue.helper.data.repository + +import poke.rogue.helper.data.datasource.LocalTypeDataSource +import poke.rogue.helper.data.model.MatchedTypes +import poke.rogue.helper.data.model.Type + +class DefaultTypeRepository(private val localTypeDataSource: LocalTypeDataSource) : TypeRepository { + override fun matchedTypesAgainstMyType(myTypeId: Int): List { + return localTypeDataSource.matchedTypesAgainstAttackingType(myTypeId) + } + + override fun matchedTypesAgainstOpponent(opponentTypeId: Int): List { + return localTypeDataSource.matchedTypesAgainstDefendingType(opponentTypeId) + } + + override fun matchedTypes( + myTypeId: Int, + opponentTypeIds: List, + ): List { + return localTypeDataSource.matchedTypes( + myTypeId, + opponentTypeIds, + ) + } + + override fun allTypes(): List { + return localTypeDataSource.allTypes() + } +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/DexRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/DexRepository.kt new file mode 100644 index 00000000..d4010fa2 --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/DexRepository.kt @@ -0,0 +1,22 @@ +package poke.rogue.helper.data.repository + +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonDetail +import poke.rogue.helper.data.model.PokemonFilter +import poke.rogue.helper.data.model.PokemonSort + +interface DexRepository { + suspend fun warmUp() + + suspend fun pokemons(): List + + suspend fun filteredPokemons( + name: String = "", + sort: PokemonSort = PokemonSort.ByDexNumber, + filters: List = emptyList(), + ): List + + suspend fun pokemonDetail(id: String): PokemonDetail + + suspend fun pokemon(id: String): Pokemon +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/repository/TypeRepository.kt b/android/data/src/main/java/poke/rogue/helper/data/repository/TypeRepository.kt new file mode 100644 index 00000000..3d5a0aeb --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/repository/TypeRepository.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.data.repository + +import poke.rogue.helper.data.model.MatchedTypes +import poke.rogue.helper.data.model.Type + +interface TypeRepository { + fun matchedTypesAgainstMyType(myTypeId: Int): List + + fun matchedTypesAgainstOpponent(opponentTypeId: Int): List + + fun matchedTypes( + myTypeId: Int, + opponentTypeIds: List, + ): List + + fun allTypes(): List +} diff --git a/android/data/src/main/java/poke/rogue/helper/data/utils/AnalyticsExtensions.kt b/android/data/src/main/java/poke/rogue/helper/data/utils/AnalyticsExtensions.kt new file mode 100644 index 00000000..10076ccf --- /dev/null +++ b/android/data/src/main/java/poke/rogue/helper/data/utils/AnalyticsExtensions.kt @@ -0,0 +1,80 @@ +package poke.rogue.helper.data.utils + +import poke.rogue.helper.analytics.AnalyticsEvent +import poke.rogue.helper.analytics.AnalyticsLogger + +internal fun AnalyticsLogger.logPokemonDetail( + pokemonId: String, + name: String, +) { + val eventType = "pokemon_dex_detail" + logEvent( + AnalyticsEvent( + type = eventType, + extras = + listOf( + AnalyticsEvent.Param(key = eventType.toParamKey(), value = pokemonId), + AnalyticsEvent.Param(key = eventType.toNameParamKey(), value = name), + ), + ), + ) +} + +internal fun AnalyticsLogger.logBiomeDetail( + biomeId: String, + name: String, +) { + val eventType = "biome_detail" + logEvent( + AnalyticsEvent( + type = eventType, + extras = + listOf( + AnalyticsEvent.Param(key = eventType.toParamKey(), value = biomeId), + AnalyticsEvent.Param(key = eventType.toNameParamKey(), value = name), + ), + ), + ) +} + +internal fun AnalyticsLogger.logBattlePokemon( + pokemonId: String, + name: String, +) { + val eventType = "battle_pokemon" + logEvent( + AnalyticsEvent( + type = eventType, + extras = + listOf( + AnalyticsEvent.Param(key = eventType.toParamKey(), value = pokemonId), + AnalyticsEvent.Param(key = eventType.toNameParamKey(), value = name), + ), + ), + ) +} + +internal fun AnalyticsLogger.logAbilityDetail( + abilityId: String, + name: String, +) { + val eventType = "ability_detail" + logEvent( + AnalyticsEvent( + type = eventType, + extras = + listOf( + AnalyticsEvent.Param(key = eventType.toParamKey(), value = abilityId), + AnalyticsEvent.Param(key = eventType.toNameParamKey(), value = name), + ), + ), + ) +} + +private fun String.toParamKey(): String { + return "${this}_key" +} + +private fun String.toNameParamKey(): String { + return "${this}_name_key" +} diff --git a/android/data/src/test/java/poke/rogue/helper/data/datasource/RemoteDexDataSourceTest.kt b/android/data/src/test/java/poke/rogue/helper/data/datasource/RemoteDexDataSourceTest.kt new file mode 100644 index 00000000..4aa08ddc --- /dev/null +++ b/android/data/src/test/java/poke/rogue/helper/data/datasource/RemoteDexDataSourceTest.kt @@ -0,0 +1,33 @@ +package poke.rogue.helper.data.datasource + +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.model.fixture.pokemonResponses +import poke.rogue.helper.data.model.fixture.pokemons +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.service.PokeDexService + +class RemoteDexDataSourceTest { + private val mockService = mockk() + private val dataSource = RemoteDexDataSource(mockService, AnalyticsLogger.Stub) + + @Test + fun `๋ชจ๋“  ํฌ์ผ“๋ชฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // given + val mockPokemonResponses = pokemonResponses(1, 2, 3, 4, 5) + val mockResponse = mockPokemonResponses + coEvery { mockService.pokemons() } returns ApiResponse.Success(mockResponse) + + // when + val actualPokemonDatas = dataSource.pokemons() + + // then + val expectedPokemonDatas = pokemons("1", "2", "3", "4", "5") + actualPokemonDatas shouldBe expectedPokemonDatas + } +} diff --git a/android/data/src/test/java/poke/rogue/helper/data/model/PokemonMapperTest.kt b/android/data/src/test/java/poke/rogue/helper/data/model/PokemonMapperTest.kt new file mode 100644 index 00000000..29939581 --- /dev/null +++ b/android/data/src/test/java/poke/rogue/helper/data/model/PokemonMapperTest.kt @@ -0,0 +1,78 @@ +package poke.rogue.helper.data.model + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import poke.rogue.helper.data.model.fixture.pokemonResponses +import poke.rogue.helper.data.model.fixture.pokemonTypeResponses +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse + +class PokemonMapperTest { + @Test + fun `ํฌ์ผ“๋ชฌ ์‘๋‹ต์„ ๋ฐ์ดํ„ฐ๋กœ ๋งคํ•‘ํ•œ๋‹ค`() { + // given + val pokemonResponse = + PokemonResponse( + id = 1, + image = "image", + name = "name", + pokedexNumber = 1, + types = + pokemonTypeResponses( + "fire", + "water", + ), + ) + + // when + val expectedPokemonData = + Pokemon( + id = "1", + dexNumber = 1, + name = "name", + imageUrl = "image", + backImageUrl = "", + types = listOf(Type.FIRE, Type.WATER), + ) + + // then + pokemonResponse.toData() shouldBe expectedPokemonData + } + + @Test + fun `ํฌ์ผ“๋ชฌ ์‘๋‹ต ๋ชฉ๋ก์„ ๋ฐ์ดํ„ฐ๋กœ ๋งคํ•‘ํ•œ๋‹ค`() { + // given + val pokemonResponses = pokemonResponses(1, 2, 3) + + // when + val expectedPokemonDatas = + listOf( + Pokemon( + id = "1", + dexNumber = 10, + name = "pokemon1", + imageUrl = "logo1", + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + Pokemon( + id = "2", + dexNumber = 20, + name = "pokemon2", + imageUrl = "logo2", + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + Pokemon( + id = "3", + dexNumber = 30, + name = "pokemon3", + imageUrl = "logo3", + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + ) + + // then + pokemonResponses.toData() shouldBe expectedPokemonDatas + } +} diff --git a/android/data/src/test/java/poke/rogue/helper/data/model/TypeMapperTest.kt b/android/data/src/test/java/poke/rogue/helper/data/model/TypeMapperTest.kt new file mode 100644 index 00000000..a3165377 --- /dev/null +++ b/android/data/src/test/java/poke/rogue/helper/data/model/TypeMapperTest.kt @@ -0,0 +1,37 @@ +package poke.rogue.helper.data.model + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import poke.rogue.helper.data.model.fixture.pokemonTypeResponse +import poke.rogue.helper.data.model.fixture.pokemonTypeResponses + +class TypeMapperTest { + @ParameterizedTest + @CsvSource( + "fire, FIRE", + "water, WATER", + "grass, GRASS", + "electric, ELECTRIC", + ) + fun `ํฌ์ผ“๋ชฌ ํƒ€์ž… ์‘๋‹ต์„ ๋ฐ์ดํ„ฐ๋กœ ๋งคํ•‘ํ•œ๋‹ค`( + responseTypeName: String, + dataTypeName: String, + ) { + // given + val typeResponse = pokemonTypeResponse(responseTypeName) + + // then + typeResponse.toData() shouldBe Type.valueOf(dataTypeName) + } + + @Test + fun `ํฌ์ผ“๋ชฌ ํƒ€์ž… ์‘๋‹ต ๋ชฉ๋ก์„ ๋ฐ์ดํ„ฐ๋กœ ๋งคํ•‘ํ•œ๋‹ค`() { + // given + val typeResponses = pokemonTypeResponses("fire", "water") + + // then + typeResponses.toData() shouldBe listOf(Type.FIRE, Type.WATER) + } +} diff --git a/android/data/src/test/java/poke/rogue/helper/data/model/fixture/PokemonResponseFixture.kt b/android/data/src/test/java/poke/rogue/helper/data/model/fixture/PokemonResponseFixture.kt new file mode 100644 index 00000000..55e8f702 --- /dev/null +++ b/android/data/src/test/java/poke/rogue/helper/data/model/fixture/PokemonResponseFixture.kt @@ -0,0 +1,64 @@ +package poke.rogue.helper.data.model.fixture + +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +/** + * Test fixture for [PokemonResponse]. + * @param id ํฌ์ผ“๋ชฌ์˜ id. default: `1` + * @param pokedexNumber ํฌ์ผ“๋ชฌ์˜ ๋„๊ฐ ๋ฒˆํ˜ธ. default: `id * 10` + * @param name ํฌ์ผ“๋ชฌ์˜ ์ด๋ฆ„. default: `pokemon$id` + * @param image ํฌ์ผ“๋ชฌ์˜ ์ด๋ฏธ์ง€. default: `logo$id` + * @param types ํฌ์ผ“๋ชฌ์˜ ํƒ€์ž… ๋ชฉ๋ก. default: ํ’€, ๋… + * @return [PokemonResponse] + */ +fun pokemonResponse( + id: Long = 1, + pokedexNumber: Long = id * 10, + name: String = "pokemon$id", + image: String = "logo$id", + types: List = pokemonTypeResponses("grass", "poison"), +) = PokemonResponse( + id = id, + pokedexNumber = pokedexNumber, + name = name, + image = image, + types = types, +) + +/** + * Test fixture for [List]<[PokemonResponse]>. + * @param ids ํฌ์ผ“๋ชฌ์˜ id ๋ชฉ๋ก (vararg) + * @return [List]<[PokemonResponse]> + * + * also see [pokemonResponse] + */ +fun pokemonResponses(vararg ids: Long): List = ids.map(::pokemonResponse) + +/** + * Test fixture for [Pokemon]. + * @param id ํฌ์ผ“๋ชฌ์˜ id. default: `1` + * @param dexNumber ํฌ์ผ“๋ชฌ์˜ ๋„๊ฐ ๋ฒˆํ˜ธ. default: `id * 10` + * @param name ํฌ์ผ“๋ชฌ์˜ ์ด๋ฆ„. default: `pokemon$id` + * @param imageUrl ํฌ์ผ“๋ชฌ์˜ ์ด๋ฏธ์ง€. default: `logo$id` + * @param types ํฌ์ผ“๋ชฌ์˜ ํƒ€์ž… ๋ชฉ๋ก. default: ํ’€, ๋… + * @return [Pokemon] + */ +fun pokemon(id: String) = + Pokemon( + id = id, + dexNumber = id.toLong() * 10, + name = "pokemon$id", + imageUrl = "logo$id", + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ) + +/** + * Test fixture for [List]<[Pokemon]>. + * @param ids ํฌ์ผ“๋ชฌ์˜ id ๋ชฉ๋ก (vararg) + * @return [List]<[Pokemon]> + */ +fun pokemons(vararg ids: String) = ids.map(::pokemon) diff --git a/android/data/src/test/java/poke/rogue/helper/data/model/fixture/TypeResponseFixture.kt b/android/data/src/test/java/poke/rogue/helper/data/model/fixture/TypeResponseFixture.kt new file mode 100644 index 00000000..4f79f168 --- /dev/null +++ b/android/data/src/test/java/poke/rogue/helper/data/model/fixture/TypeResponseFixture.kt @@ -0,0 +1,28 @@ +package poke.rogue.helper.data.model.fixture + +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +/** + * Test fixture for [PokemonTypeResponse]. + * @param typeName ํฌ์ผ“๋ชฌ ํƒ€์ž…์˜ ์ด๋ฆ„(์˜์–ด ์†Œ๋ฌธ์ž). ์˜ˆ) "fire", "water" + * @return [PokemonTypeResponse] + * + * ํƒ€์ž…์€ typeName, ๋กœ๊ณ ๋Š” "logo " + typeName ์ด ๋œ๋‹ค + */ +fun pokemonTypeResponse(typeName: String) = + PokemonTypeResponse( + typeName = typeName, + typeLogo = "logo $typeName", + ) + +/** + * Test fixture for [List]<[PokemonTypeResponse]>. + * @param typeNames ํฌ์ผ“๋ชฌ ํƒ€์ž…์˜ ์ด๋ฆ„ ๋ชฉ๋ก(์˜์–ด ์†Œ๋ฌธ์ž). ์˜ˆ) "fire", "water" + * @return [List]<[PokemonTypeResponse]> + * + * sample + * ```kotlin + * val typeResponses = pokemonTypeResponses("fire", "water") + *``` + */ +fun pokemonTypeResponses(vararg typeNames: String) = typeNames.map(::pokemonTypeResponse) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a785bbd8..b7ab44af 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,26 +1,168 @@ [versions] +applicationId = "poke.rogue.helper" +balloon = "1.6.8" +compileSdk = "34" +koin = "4.0.0" +minSdk = "26" +robolectric = "4.13" +targetSdk = "34" +appVersion = "1.1.1" +versionCode = "101001" agp = "8.3.2" + +# kotlin kotlin = "1.9.24" -coreKtx = "1.13.1" -junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" +kotlinx-serialization-json = "1.6.2" +kotlinx-coroutines = "1.7.3" + +# Android +core-ktx = "1.13.1" +fragment = "1.8.4" appcompat = "1.7.0" -material = "1.12.0" activity = "1.9.0" +lifecycle = "2.8.3" +material = "1.12.0" +startup = "1.1.1" +room = "2.6.1" constraintlayout = "2.1.4" +flexbox = "3.0.0" +# Third-Party +okhttp = "4.12.0" +retrofit = "2.11.0" +timber = "5.0.1" +coil = "2.6.0" +glide = "4.14.2" +ktlint = "12.1.0" +splash-screen = "1.0.1" +datastore = "1.0.0" + +# Google & Firebase +google-services = "4.4.2" +firebase = "33.1.2" +firebase-crashlytics = "3.0.2" +app-update = "2.1.0" + +# Android-Test +android-junit5-plugin = "1.10.0.0" +android-junit5-core = "1.4.0" +androidx-test = "1.6.1" +androidx-test-junit = "1.2.1" +androidx-test-fragment = "1.8.1" +android-test-esspreso-contrib = "3.6.1" +androidx-test-espresso-core = "3.6.1" +androidx-test-core = "1.6.1" +androidx-test-runner = "1.5.0" + +# Unit-Test +junit = "4.13.2" +junit5-jupiter = "5.10.2" +kotest-runner-junit5 = "5.8.0" +mockk = "1.13.9" +firebaseCrashlyticsBuildtools = "3.0.2" [libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } +agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } + +# kotlin +balloon = { module = "com.github.skydoves:balloon", version.ref = "balloon" } +kotlin = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } +kotlin-gradleplugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } + +# koin +koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } +koin-core = { module = "io.insert-koin:koin-core" } +koin-test-junit5 = { module = "io.insert-koin:koin-test-junit5" } +koin-android = { module = "io.insert-koin:koin-android" } +koin-android-test = { module = "io.insert-koin:koin-android-test" } +# coroutines +kotlin-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlin-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } + +# android +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +room = { module = "androidx.room:room-runtime", version.ref = "room" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" } +datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } + +# Google & Firebase +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" } +firebase-crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "firebase-crashlytics" } +app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "app-update" } +# android test +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +android-test-fragment = { module = "androidx.fragment:fragment-testing", version.ref = "androidx-test-fragment" } +android-test-fragment-manifest = { module = "androidx.fragment:fragment-testing-manifest", version.ref = "androidx-test-fragment" } +androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "android-test-esspreso-contrib" } +androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso-core" } +androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-core" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test" } +androidx-test-extention-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidx-test-junit" } +junit5-android-test-core = { module = "de.mannodermaus.junit5:android-test-core", version.ref = "android-junit5-core" } +junit5-android-test-runner = { module = "de.mannodermaus.junit5:android-test-runner", version.ref = "android-junit5-core" } +android-mockk = { module = "io.mockk:mockk-android", version.ref = "mockk" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } + +# unit test +kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5-jupiter" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5-jupiter" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest-runner-junit5" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-webserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } + +# third party +ktlint = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } +okhttp = { module = "com.squareup.okhttp3:okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-kotlin-serialization-converter = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +coil-core = { module = "io.coil-kt:coil", version.ref = "coil" } +coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } +splash-screen = { module = "androidx.core:core-splashscreen", version.ref = "splash-screen" } +firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } [plugins] -androidApplication = { id = "com.android.application", version.ref = "agp" } -jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "android-junit5-plugin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +room = { id = "androidx.room", version.ref = "room" } + +# google & firebase +google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } +firebase-crashlytics-plugin = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" } +[bundles] +firebase = ["firebase-analytics", "firebase-crashlytics"] +retrofit = ["retrofit", "retrofit-kotlin-serialization-converter"] +unit-test = ["kotlin-coroutines-test", "junit5", "kotest-runner-junit5", "mockk"] +robolectric-test = ["robolectric", "androidx-test-core", "androidx-test-extention-ktx"] +android-test = [ + "androidx-test-core", "androidx-test-espresso-contrib", "androidx-test-espresso", "androidx-test-junit", "androidx-test-extention-ktx", "androidx-test-rules", "androidx-test-runner", "android-test-fragment", + "junit5", "kotest-runner-junit5", "android-mockk", "junit5-android-test-core", "android-test-fragment-manifest" +] diff --git a/android/keystore/debug.keystore b/android/keystore/debug.keystore new file mode 100644 index 00000000..99c3313d Binary files /dev/null and b/android/keystore/debug.keystore differ diff --git a/android/local/.gitignore b/android/local/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/local/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/local/build.gradle.kts b/android/local/build.gradle.kts new file mode 100644 index 00000000..fb31ed79 --- /dev/null +++ b/android/local/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.room) +} + +android { + namespace = "poke.rogue.helper.local" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["runnerBuilder"] = + "de.mannodermaus.junit5.AndroidJUnit5Builder" + } + + 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*/**" + } + } + sourceSets { + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } +} + +room { + schemaDirectory("$projectDir/schemas") +} + +dependencies { + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlin.serialization.json) + // third-party + implementation(libs.timber) + // room + implementation(libs.room) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + implementation(libs.datastore.preferences) + // koin + implementation(platform(libs.koin.bom)) + implementation(libs.koin.core) + testImplementation(libs.koin.test.junit5) + androidTestImplementation(libs.koin.android.test) + // unit test + testImplementation(libs.bundles.unit.test) + testImplementation(libs.kotlin.test) + // android test + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlin.coroutines.test) + androidTestImplementation(libs.junit5) + androidTestImplementation(libs.kotest.runner.junit5) + androidTestImplementation(libs.junit5.android.test.core) + androidTestRuntimeOnly(libs.junit5.android.test.runner) + androidTestImplementation(libs.room.testing) +} diff --git a/android/local/consumer-rules.pro b/android/local/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/local/proguard-rules.pro b/android/local/proguard-rules.pro new file mode 100644 index 00000000..ff59496d --- /dev/null +++ b/android/local/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/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/1.json b/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/1.json new file mode 100644 index 00000000..85f5adc2 --- /dev/null +++ b/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/1.json @@ -0,0 +1,112 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "734577a7ee599cdbfadbf26892c3d677", + "entities": [ + { + "tableName": "Pokemon", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dexNumber` INTEGER NOT NULL, `formName` TEXT NOT NULL, `name` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `types` TEXT NOT NULL, `generation` INTEGER NOT NULL, `baseStat` INTEGER NOT NULL, `hp` INTEGER NOT NULL, `attack` INTEGER NOT NULL, `defense` INTEGER NOT NULL, `specialAttack` INTEGER NOT NULL, `specialDefense` INTEGER NOT NULL, `speed` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dexNumber", + "columnName": "dexNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formName", + "columnName": "formName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "types", + "columnName": "types", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseStat", + "columnName": "baseStat", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hp", + "columnName": "hp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attack", + "columnName": "attack", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defense", + "columnName": "defense", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialAttack", + "columnName": "specialAttack", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialDefense", + "columnName": "specialDefense", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '734577a7ee599cdbfadbf26892c3d677')" + ] + } +} \ No newline at end of file diff --git a/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/2.json b/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/2.json new file mode 100644 index 00000000..fe9998db --- /dev/null +++ b/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/2.json @@ -0,0 +1,118 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "15571e87f13db1101fb5cecf0fc2527f", + "entities": [ + { + "tableName": "Pokemon", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dexNumber` INTEGER NOT NULL, `formName` TEXT NOT NULL, `name` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `backImageUrl` TEXT NOT NULL, `types` TEXT NOT NULL, `generation` INTEGER NOT NULL, `baseStat` INTEGER NOT NULL, `hp` INTEGER NOT NULL, `attack` INTEGER NOT NULL, `defense` INTEGER NOT NULL, `specialAttack` INTEGER NOT NULL, `specialDefense` INTEGER NOT NULL, `speed` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dexNumber", + "columnName": "dexNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formName", + "columnName": "formName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backImageUrl", + "columnName": "backImageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "types", + "columnName": "types", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseStat", + "columnName": "baseStat", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hp", + "columnName": "hp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attack", + "columnName": "attack", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defense", + "columnName": "defense", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialAttack", + "columnName": "specialAttack", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialDefense", + "columnName": "specialDefense", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '15571e87f13db1101fb5cecf0fc2527f')" + ] + } +} \ No newline at end of file diff --git a/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/3.json b/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/3.json new file mode 100644 index 00000000..173dc387 --- /dev/null +++ b/android/local/schemas/poke.rogue.helper.local.db.PokeRogueDatabase/3.json @@ -0,0 +1,119 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "15571e87f13db1101fb5cecf0fc2527f", + "entities": [ + { + "tableName": "Pokemon", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dexNumber` INTEGER NOT NULL, `formName` TEXT NOT NULL, `name` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `backImageUrl` TEXT NOT NULL DEFAULT '', `types` TEXT NOT NULL, `generation` INTEGER NOT NULL, `baseStat` INTEGER NOT NULL, `hp` INTEGER NOT NULL, `attack` INTEGER NOT NULL, `defense` INTEGER NOT NULL, `specialAttack` INTEGER NOT NULL, `specialDefense` INTEGER NOT NULL, `speed` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dexNumber", + "columnName": "dexNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formName", + "columnName": "formName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backImageUrl", + "columnName": "backImageUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "types", + "columnName": "types", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseStat", + "columnName": "baseStat", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hp", + "columnName": "hp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attack", + "columnName": "attack", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defense", + "columnName": "defense", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialAttack", + "columnName": "specialAttack", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialDefense", + "columnName": "specialDefense", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '15571e87f13db1101fb5cecf0fc2527f')" + ] + } +} \ No newline at end of file diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/dao/PokemonDaoTest.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/dao/PokemonDaoTest.kt new file mode 100644 index 00000000..1fa95ec5 --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/dao/PokemonDaoTest.kt @@ -0,0 +1,59 @@ +package poke.rogue.helper.local.dao + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import org.koin.test.KoinTest +import org.koin.test.get +import poke.rogue.helper.local.di.testLocalModule +import poke.rogue.helper.local.entity.PokemonEntity +import poke.rogue.helper.local.entity.pokemonEntity +import poke.rogue.helper.local.utils.KoinAndroidUnitTestRule + +class PokemonDaoTest : KoinTest { + private val dao get() = get() + + @get:Rule + val koinTestRule = + KoinAndroidUnitTestRule( + testLocalModule, + ) + + @Test + @DisplayName("ํฌ์ผ“๋ชฌ ์ €์žฅ ํ›„ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ") + fun `test`() = + runTest { + // given + val pokemons = + listOf(pokemonEntity(id = "1", name = "ํ”ผ์นด์ธ„"), pokemonEntity(id = "2", name = "๋ผ์ด์ธ„")) + // when + dao.savePokemons(pokemons) + val actual = dao.pokemons() + // then + val expect = pokemons + actual shouldBe expect + } + + @Test + @DisplayName("ํฌ์ผ“๋ชฌ ์ €์žฅ ํ›„ ์ €์žฅ ํ™•์ธ, ์‚ญ์ œ ํ›„ ์‚ญ์ œ ํ™•์ธ") + fun `test2`() = + runTest { + // given + val pokemons = + listOf(pokemonEntity(id = "1", name = "ํ”ผ์นด์ธ„"), pokemonEntity(id = "2", name = "๋ผ์ด์ธ„")) + // when + dao.savePokemons(pokemons) + val actual = dao.pokemons() + // then + val expect = pokemons + actual shouldBe expect + // when + dao.clear() + val actual2 = dao.pokemons() + // then + val expect2 = emptyList() + actual2 shouldBe expect2 + } +} diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/database/PokeRogueDatabaseMigrationTest.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/database/PokeRogueDatabaseMigrationTest.kt new file mode 100644 index 00000000..7419ebbc --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/database/PokeRogueDatabaseMigrationTest.kt @@ -0,0 +1,135 @@ +package poke.rogue.helper.local.database + +import android.database.sqlite.SQLiteDatabase +import androidx.core.content.contentValuesOf +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldBeEmpty +import io.kotest.matchers.string.shouldNotBeEmpty +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import poke.rogue.helper.local.db.PokeRogueDatabase +import poke.rogue.helper.local.entity.PokemonEntity +import poke.rogue.helper.local.utils.migrationTestCase +import poke.rogue.helper.local.utils.testContext +import java.io.IOException + +/** + * [PokeRogueDatabase]์˜ AutoMigration ํ…Œ์ŠคํŠธ + * + * ref: https://cs.android.com/androidx/architecture-components-samples/+/main:PersistenceMigrationsSample/app/src/androidTestRoom3/java/com/example/android/persistence/migrations/MigrationTest.java;l=58?q=MigrationTestHelper + * ref2: https://github.com/Ivy-Apps/ivy-wallet/blob/main/shared/data/core/src/androidTest/java/com/ivy/data/db/Migration128to129Test.kt#L29 + * docs: https://developer.android.com/training/data-storage/room/migrating-db-versions#all-migrations-test + */ +class PokeRogueDatabaseMigrationTest { + @get:Rule + val helper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + PokeRogueDatabase::class.java, + ) + + @Test + @Throws(IOException::class) + @DisplayName("๋ฒ„์ „ 1์—์„œ ๋ฒ„์ „ 2๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ํ…Œ์ŠคํŠธ - backImageUrl ์ถ”๊ฐ€") + fun test_migration1To2() = + helper.migrationTestCase( + tableName = PokemonEntity.TABLE_NAME, + from = 1, + to = 2, + onBeforeMigration = { + val contentValue = + contentValuesOf( + "id" to 1, + "dexNumber" to 25, + "formName" to "Normal", + "name" to "Pikachu", + "imageUrl" to "url_to_image", + "types" to "Electric", + "generation" to 1, + "baseStat" to 320, + "hp" to 35, + "attack" to 55, + "defense" to 40, + "specialAttack" to 50, + "specialDefense" to 50, + "speed" to 90, + ) + insert(PokemonEntity.TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, contentValue) + }, + onAfterMigration = { + moveToFirst().shouldBeTrue() + val backImageUrlIndex = getColumnIndex("backImageUrl") + val backImageUrl = getString(backImageUrlIndex) + backImageUrl.shouldBeEmpty() + moveToNext().shouldBeFalse() + }, + migrations = PokeRogueDatabase.MIGRATIONS, + ) + + @Test + @Throws(IOException::class) + @DisplayName("๋ฒ„์ „ 2์—์„œ ๋ฒ„์ „ 3๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ํ…Œ์ŠคํŠธ - backImageUrl ์— defaultValue ์ถ”๊ฐ€") + fun test_migration2To3() = + helper.migrationTestCase( + tableName = PokemonEntity.TABLE_NAME, + from = 2, + to = 3, + onBeforeMigration = { + val contentValue = + contentValuesOf( + "id" to 1, + "dexNumber" to 25, + "formName" to "Normal", + "name" to "Pikachu", + "imageUrl" to "url_to_image", + "types" to "Electric", + "generation" to 1, + "baseStat" to 320, + "hp" to 35, + "attack" to 55, + "backImageUrl" to "testUrl", + "defense" to 40, + "specialAttack" to 50, + "specialDefense" to 50, + "speed" to 90, + ) + insert(PokemonEntity.TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, contentValue) + }, + onAfterMigration = { + moveToFirst().shouldBeTrue() + val pokemonName = getString(getColumnIndex("name")) + pokemonName.shouldNotBeEmpty() + pokemonName shouldBe "Pikachu" + moveToNext().shouldBeFalse() + }, + migrations = PokeRogueDatabase.MIGRATIONS, + ) + + @Test + @DisplayName("๋ชจ๋“  database ๋ฒ„์ „ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ…Œ์ŠคํŠธ") + @Throws(IOException::class) + fun test_migrateAll() { + // given + val dbName = "TEST_DB" + val oldestDbVersion = 1 + // when : ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ๋ฒ„์ „์˜ DB๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๋‹ซ์Œ + helper.createDatabase(dbName, oldestDbVersion) + .close() + + // then : ์ตœ์‹  ๋ฒ„์ „ DB๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ๊ฒ€์ฆ + Room.databaseBuilder( + testContext, + PokeRogueDatabase::class.java, + dbName, + ).addMigrations(*PokeRogueDatabase.MIGRATIONS) + .build().apply { + openHelper.writableDatabase.close() + } + } +} diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/di/TestLocalModule.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/di/TestLocalModule.kt new file mode 100644 index 00000000..bf2078ef --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/di/TestLocalModule.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.local.di + +import androidx.room.Room +import org.koin.dsl.module +import poke.rogue.helper.local.db.PokeRogueDatabase +import poke.rogue.helper.local.utils.testContext + +val testLocalModule + get() = + module { + includes(daoModule) + + single { + Room.inMemoryDatabaseBuilder( + testContext, + PokeRogueDatabase::class.java, + ).build() + } + } diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/entity/PokemonFixtures.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/entity/PokemonFixtures.kt new file mode 100644 index 00000000..59a6a060 --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/entity/PokemonFixtures.kt @@ -0,0 +1,35 @@ +package poke.rogue.helper.local.entity + +fun pokemonEntity( + id: String = "1", + dexNumber: Long = 1, + name: String = "์ด์ƒํ•ด์”จ", + formName: String = "์ผ๋ฐ˜", + imageUrl: String = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", + types: Set = setOf("ํ’€", "๋…"), + generation: Int = 1, + baseStat: Int = 318, + hp: Int = 45, + attack: Int = 49, + defense: Int = 49, + specialAttack: Int = 65, + specialDefense: Int = 65, + speed: Int = 45, +): PokemonEntity = + PokemonEntity( + id = id, + dexNumber = dexNumber, + name = name, + imageUrl = imageUrl, + types = types, + generation = generation, + baseStat = baseStat, + hp = hp, + attack = attack, + defense = defense, + specialAttack = specialAttack, + specialDefense = specialDefense, + speed = speed, + backImageUrl = imageUrl, + formName = formName, + ) diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/utils/ContextUtils.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/utils/ContextUtils.kt new file mode 100644 index 00000000..b0066594 --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/utils/ContextUtils.kt @@ -0,0 +1,6 @@ +package poke.rogue.helper.local.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider + +val testContext: Context get() = ApplicationProvider.getApplicationContext() diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/utils/KoinAndroidUnitTestRule.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/utils/KoinAndroidUnitTestRule.kt new file mode 100644 index 00000000..aa64734c --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/utils/KoinAndroidUnitTestRule.kt @@ -0,0 +1,32 @@ +package poke.rogue.helper.local.utils + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.GlobalContext.getKoinApplicationOrNull +import org.koin.core.context.loadKoinModules +import org.koin.core.context.startKoin +import org.koin.core.context.unloadKoinModules +import org.koin.core.module.Module + +class KoinAndroidUnitTestRule( + private val modules: List, +) : TestWatcher() { + constructor(vararg modules: Module) : this(modules.toList()) + + override fun starting(description: Description) { + if (getKoinApplicationOrNull() == null) { + startKoin { + androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext) + modules(modules) + } + } else { + loadKoinModules(modules) + } + } + + override fun finished(description: Description) { + unloadKoinModules(modules) + } +} diff --git a/android/local/src/androidTest/java/poke/rogue/helper/local/utils/MigrationTestUtils.kt b/android/local/src/androidTest/java/poke/rogue/helper/local/utils/MigrationTestUtils.kt new file mode 100644 index 00000000..c43c1262 --- /dev/null +++ b/android/local/src/androidTest/java/poke/rogue/helper/local/utils/MigrationTestUtils.kt @@ -0,0 +1,42 @@ +package poke.rogue.helper.local.utils + +import android.database.Cursor +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * [MigrationTestHelper]๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ…Œ์ŠคํŠธ๋ฅผ BDD ์Šคํƒ€์ผ๋กœ ์ž‘์„ฑํ•˜๊ธฐ ์œ„ํ•œ ํ™•์žฅ ํ•จ์ˆ˜ + * + * ref: https://github.com/Ivy-Apps/ivy-wallet/blob/main/shared/data/core/src/androidTest/java/com/ivy/data/db/Migration128to129Test.kt#L29 + * */ +fun MigrationTestHelper.migrationTestCase( + tableName: String, + from: Int, + to: Int, + onBeforeMigration: SupportSQLiteDatabase.() -> Unit, + onAfterMigration: Cursor.() -> Unit, + testDbName: String = "test-db", + vararg migrations: Migration, +) { + // Given + createDatabase(testDbName, from).apply { + onBeforeMigration() + close() + } + + // When + val newDb = + runMigrationsAndValidate( + testDbName, + to, + true, + *migrations, + ) + + // Then + newDb.query("SELECT * FROM $tableName").apply { + onAfterMigration() + } + newDb.close() +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/converter/PokemonTypeConverters.kt b/android/local/src/main/java/poke/rogue/helper/local/converter/PokemonTypeConverters.kt new file mode 100644 index 00000000..4505efa9 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/converter/PokemonTypeConverters.kt @@ -0,0 +1,15 @@ +package poke.rogue.helper.local.converter + +import androidx.room.TypeConverter + +class PokemonTypeConverters { + @TypeConverter + fun fromTypeList(value: Set): String { + return value.joinToString(",") + } + + @TypeConverter + fun toTypeList(json: String): Set { + return json.split(",").toSet() + } +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/dao/PokemonDao.kt b/android/local/src/main/java/poke/rogue/helper/local/dao/PokemonDao.kt new file mode 100644 index 00000000..921dbc82 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/dao/PokemonDao.kt @@ -0,0 +1,19 @@ +package poke.rogue.helper.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import poke.rogue.helper.local.entity.PokemonEntity + +@Dao +interface PokemonDao { + @Query("SELECT * FROM Pokemon") + suspend fun pokemons(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun savePokemons(pokemons: List) + + @Query("DELETE FROM Pokemon") + suspend fun clear() +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/datastore/BattleDataStore.kt b/android/local/src/main/java/poke/rogue/helper/local/datastore/BattleDataStore.kt new file mode 100644 index 00000000..fc3f9a70 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/datastore/BattleDataStore.kt @@ -0,0 +1,67 @@ +package poke.rogue.helper.local.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class SavedPokemonWithSkill(val pokemonId: String, val skillId: String) + +class BattleDataStore(private val context: Context) { + private val Context.dataStore: DataStore by preferencesDataStore(name = BATTLE_PREFERENCE_NAME) + + suspend fun savePokemonWithSkill( + pokemonId: String, + skillId: String, + ) { + context.dataStore.edit { + it[PAIR_POKEMON_SELECTION_KEY] = pokemonId + it[PAIR_SKILL_SELECTION_KEY] = skillId + } + } + + suspend fun savePokemon(pokemonId: String) { + context.dataStore.edit { + it[SINGLE_POKEMON_SELECTION_KEY] = pokemonId + } + } + + suspend fun saveWeather(weatherId: String) { + context.dataStore.edit { + it[WEATHER_SELECTION_KEY] = weatherId + } + } + + fun weatherId(): Flow = + context.dataStore.data.map { preferences -> + preferences[WEATHER_SELECTION_KEY] + } + + fun pokemonWithSkillId(): Flow = + context.dataStore.data.map { preference -> + val pokemonId = preference[PAIR_POKEMON_SELECTION_KEY] + val skillId = preference[PAIR_SKILL_SELECTION_KEY] + if (pokemonId == null || skillId == null) { + null + } else { + SavedPokemonWithSkill(pokemonId, skillId) + } + } + + fun pokemonId(): Flow = + context.dataStore.data.map { preferences -> + preferences[SINGLE_POKEMON_SELECTION_KEY] + } + + private companion object { + const val BATTLE_PREFERENCE_NAME = "battle" + val WEATHER_SELECTION_KEY = stringPreferencesKey("weather_selection") + val PAIR_POKEMON_SELECTION_KEY = stringPreferencesKey("pair_pokemon_selection") + val PAIR_SKILL_SELECTION_KEY = stringPreferencesKey("pair_skill_selection") + val SINGLE_POKEMON_SELECTION_KEY = stringPreferencesKey("single_pokemon_selection") + } +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/datastore/NavigationModeDataStore.kt b/android/local/src/main/java/poke/rogue/helper/local/datastore/NavigationModeDataStore.kt new file mode 100644 index 00000000..68aee844 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/datastore/NavigationModeDataStore.kt @@ -0,0 +1,27 @@ +package poke.rogue.helper.local.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull + +class NavigationModeDataStore(private val context: Context) { + private val Context.datastore: DataStore by preferencesDataStore(name = NAVIGATION_MODE_PREFERENCE_NAME) + + suspend fun saveNavigationMode(isBattleNavigationMode: Boolean) { + context.datastore.edit { + it[IS_BATTLE_NAVIGATION_MODE_KEY] = isBattleNavigationMode + } + } + + fun isBattleNavigationMode(): Flow = context.datastore.data.mapNotNull { it[IS_BATTLE_NAVIGATION_MODE_KEY] } + + private companion object { + const val NAVIGATION_MODE_PREFERENCE_NAME = "navigationMode" + val IS_BATTLE_NAVIGATION_MODE_KEY = booleanPreferencesKey("is_battle_navigation_mode") + } +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/datastore/VersionDataStore.kt b/android/local/src/main/java/poke/rogue/helper/local/datastore/VersionDataStore.kt new file mode 100644 index 00000000..f13ad4e6 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/datastore/VersionDataStore.kt @@ -0,0 +1,30 @@ +package poke.rogue.helper.local.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class VersionDataStore(private val context: Context) { + private val Context.dataStore: DataStore by preferencesDataStore(name = DATA_BASE_NAME) + + fun databaseVersionStream(): Flow = + context.dataStore.data.map { preferences -> + preferences[DATABASE_VERSION_KEY] + } + + suspend fun saveDatabaseVersion(version: Int) { + context.dataStore.edit { preferences -> + preferences[DATABASE_VERSION_KEY] = version + } + } + + companion object { + private const val DATA_BASE_NAME = "datastore_version" + private val DATABASE_VERSION_KEY = intPreferencesKey("database_version") + } +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/db/PokeRogueDatabase.kt b/android/local/src/main/java/poke/rogue/helper/local/db/PokeRogueDatabase.kt new file mode 100644 index 00000000..5e2b5a0a --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/db/PokeRogueDatabase.kt @@ -0,0 +1,26 @@ +package poke.rogue.helper.local.db + +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.RoomDatabase +import poke.rogue.helper.local.converter.PokemonTypeConverters +import poke.rogue.helper.local.dao.PokemonDao +import poke.rogue.helper.local.db.migrations.Migration1To2 +import poke.rogue.helper.local.entity.PokemonEntity + +@Database( + entities = [PokemonEntity::class], + version = 3, + autoMigrations = [ + AutoMigration(from = 2, to = 3), + ], +) +@androidx.room.TypeConverters(PokemonTypeConverters::class) +abstract class PokeRogueDatabase : RoomDatabase() { + abstract fun pokemonDao(): PokemonDao + + companion object { + const val DATABASE_NAME = "pokemon_helper.db" + val MIGRATIONS = arrayOf(Migration1To2) + } +} diff --git a/android/local/src/main/java/poke/rogue/helper/local/db/migrations/Migration1To2.kt b/android/local/src/main/java/poke/rogue/helper/local/db/migrations/Migration1To2.kt new file mode 100644 index 00000000..76d229f2 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/db/migrations/Migration1To2.kt @@ -0,0 +1,11 @@ +package poke.rogue.helper.local.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val Migration1To2 = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE Pokemon ADD COLUMN backImageUrl TEXT NOT NULL DEFAULT ''") + } + } diff --git a/android/local/src/main/java/poke/rogue/helper/local/di/DaoModule.kt b/android/local/src/main/java/poke/rogue/helper/local/di/DaoModule.kt new file mode 100644 index 00000000..8bc68bf6 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/di/DaoModule.kt @@ -0,0 +1,11 @@ +package poke.rogue.helper.local.di + +import org.koin.dsl.module +import poke.rogue.helper.local.dao.PokemonDao +import poke.rogue.helper.local.db.PokeRogueDatabase + +internal val daoModule + get() = + module { + single { get().pokemonDao() } + } diff --git a/android/local/src/main/java/poke/rogue/helper/local/di/DataBaseModule.kt b/android/local/src/main/java/poke/rogue/helper/local/di/DataBaseModule.kt new file mode 100644 index 00000000..3ef0f415 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/di/DataBaseModule.kt @@ -0,0 +1,18 @@ +package poke.rogue.helper.local.di + +import androidx.room.Room +import org.koin.dsl.module +import poke.rogue.helper.local.db.PokeRogueDatabase + +internal val dataBaseModule + get() = + module { + single { + Room.databaseBuilder( + get(), + PokeRogueDatabase::class.java, + PokeRogueDatabase.DATABASE_NAME, + ).addMigrations(*PokeRogueDatabase.MIGRATIONS) + .build() + } + } diff --git a/android/local/src/main/java/poke/rogue/helper/local/di/DataStoreModule.kt b/android/local/src/main/java/poke/rogue/helper/local/di/DataStoreModule.kt new file mode 100644 index 00000000..7d56c705 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/di/DataStoreModule.kt @@ -0,0 +1,15 @@ +package poke.rogue.helper.local.di + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import poke.rogue.helper.local.datastore.BattleDataStore +import poke.rogue.helper.local.datastore.NavigationModeDataStore +import poke.rogue.helper.local.datastore.VersionDataStore + +internal val dataStoreModule + get() = + module { + singleOf(::BattleDataStore) + singleOf(::NavigationModeDataStore) + singleOf(::VersionDataStore) + } diff --git a/android/local/src/main/java/poke/rogue/helper/local/di/LocalModule.kt b/android/local/src/main/java/poke/rogue/helper/local/di/LocalModule.kt new file mode 100644 index 00000000..80fd30d4 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/di/LocalModule.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.local.di + +import org.koin.dsl.module + +val localModule + get() = + module { + includes(dataBaseModule, daoModule, dataStoreModule) + } diff --git a/android/local/src/main/java/poke/rogue/helper/local/entity/PokemonEntity.kt b/android/local/src/main/java/poke/rogue/helper/local/entity/PokemonEntity.kt new file mode 100644 index 00000000..92bbbe17 --- /dev/null +++ b/android/local/src/main/java/poke/rogue/helper/local/entity/PokemonEntity.kt @@ -0,0 +1,30 @@ +package poke.rogue.helper.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import poke.rogue.helper.local.entity.PokemonEntity.Companion.TABLE_NAME + +@Entity(tableName = TABLE_NAME) +data class PokemonEntity( + @PrimaryKey val id: String, + val dexNumber: Long, + val formName: String, + val name: String, + val imageUrl: String, + @ColumnInfo(defaultValue = "") + val backImageUrl: String, + val types: Set, + val generation: Int, + val baseStat: Int, + val hp: Int, + val attack: Int, + val defense: Int, + val specialAttack: Int, + val specialDefense: Int, + val speed: Int, +) { + companion object { + const val TABLE_NAME = "Pokemon" + } +} diff --git a/android/app/src/test/java/poke/rogue/helper/ExampleUnitTest.kt b/android/local/src/test/java/poke/rogue/helper/local/ExampleUnitTest.kt similarity index 68% rename from android/app/src/test/java/poke/rogue/helper/ExampleUnitTest.kt rename to android/local/src/test/java/poke/rogue/helper/local/ExampleUnitTest.kt index 87ff6064..6c7080d1 100644 --- a/android/app/src/test/java/poke/rogue/helper/ExampleUnitTest.kt +++ b/android/local/src/test/java/poke/rogue/helper/local/ExampleUnitTest.kt @@ -1,8 +1,7 @@ -package poke.rogue.helper +package poke.rogue.helper.local -import org.junit.Test - -import org.junit.Assert.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test /** * Example local unit test, which will execute on the development machine (host). @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/android/remote/.gitignore b/android/remote/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/remote/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/remote/build.gradle.kts b/android/remote/build.gradle.kts new file mode 100644 index 00000000..7f4a9ac9 --- /dev/null +++ b/android/remote/build.gradle.kts @@ -0,0 +1,73 @@ +import org.jetbrains.kotlin.konan.properties.Properties + +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlinx.serialization) +} + +val properties = + Properties().apply { + load(rootProject.file("local.properties").inputStream()) + } + +android { + namespace = "poke.rogue.helper.remote" + + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + buildConfigField( + "String", + "POKE_BASE_URL", + properties.getProperty("POKE_BASE_URL"), + ) + } + + 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() + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlin.serialization.json) + // third-party + implementation(libs.timber) + // koin + implementation(platform(libs.koin.bom)) + implementation(libs.koin.core) + testImplementation(libs.koin.test.junit5) + // retrofit & okhttp + implementation(libs.bundles.retrofit) + implementation(platform(libs.okhttp.bom)) + implementation(libs.okhttp.logging.interceptor) + // unit test + testImplementation(libs.bundles.unit.test) + testImplementation(libs.kotlin.test) + testImplementation(libs.mockk.webserver) +} diff --git a/android/remote/consumer-rules.pro b/android/remote/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/remote/proguard-rules.pro b/android/remote/proguard-rules.pro new file mode 100644 index 00000000..ff59496d --- /dev/null +++ b/android/remote/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/remote/src/main/java/poke/rogue/helper/remote/di/RemoteModule.kt b/android/remote/src/main/java/poke/rogue/helper/remote/di/RemoteModule.kt new file mode 100644 index 00000000..5294d4e4 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/di/RemoteModule.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.remote.di + +import org.koin.dsl.module + +val remoteModule + get() = + module { + includes(retrofitModule, serviceModule) + } diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/di/RetrofitModule.kt b/android/remote/src/main/java/poke/rogue/helper/remote/di/RetrofitModule.kt new file mode 100644 index 00000000..c7de2d2d --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/di/RetrofitModule.kt @@ -0,0 +1,77 @@ +package poke.rogue.helper.remote.di + +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.module.dsl.singleOf +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module +import poke.rogue.helper.remote.BuildConfig +import poke.rogue.helper.remote.injector.PokeCallAdapterFactory +import poke.rogue.helper.remote.injector.PokeConverterFactory +import poke.rogue.helper.remote.interceptor.RedirectInterceptor +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit + +private const val LOCAL_HOST_BASE_URL = "http://10.0.2.2:8080" + +internal val retrofitModule + get() = + module { + + single { + if (BuildConfig.DEBUG) { + Json { + coerceInputValues = true + } + } else { + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + } + } + single { get().asConverterFactory("application/json".toMediaType()) } + single { + HttpLoggingInterceptor().setLevel( + if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + }, + ) + } + single { (redirectUrl: String) -> + RedirectInterceptor(redirectUrl) + } + singleOf(::PokeCallAdapterFactory) + singleOf(::PokeConverterFactory) + + single { + OkHttpClient + .Builder() + .addInterceptor(get()) + .let { client -> + if (BuildConfig.DEBUG) { + client.addInterceptor(get { parametersOf(LOCAL_HOST_BASE_URL) }) + } else { + client + } + }.connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + } + + single { + Retrofit.Builder() + .baseUrl(BuildConfig.POKE_BASE_URL) + .client(get()) + .addCallAdapterFactory(get()) + .addConverterFactory(get()) + .build() + } + } diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/di/ServiceModule.kt b/android/remote/src/main/java/poke/rogue/helper/remote/di/ServiceModule.kt new file mode 100644 index 00000000..9545d69f --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/di/ServiceModule.kt @@ -0,0 +1,20 @@ +package poke.rogue.helper.remote.di + +import org.koin.dsl.module +import poke.rogue.helper.remote.service.AbilityService +import poke.rogue.helper.remote.service.BattleService +import poke.rogue.helper.remote.service.BiomeService +import poke.rogue.helper.remote.service.PokeDexService +import poke.rogue.helper.remote.service.VersionService +import retrofit2.Retrofit +import retrofit2.create + +internal val serviceModule + get() = + module { + single { get().create() } + single { get().create() } + single { get().create() } + single { get().create() } + single { get().create() } + } diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/base/ApiResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/base/ApiResponse.kt new file mode 100644 index 00000000..fa26032e --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/base/ApiResponse.kt @@ -0,0 +1,24 @@ +package poke.rogue.helper.remote.dto.base + +import poke.rogue.helper.remote.dto.base.ApiResponse.Failure +import poke.rogue.helper.remote.dto.base.ApiResponse.Success + +/** + * NetworkState is a sealed class that represents the state of a network request. + * It can be one of the following: + * - [Success] : 200..300 ์ฝ”๋“œ ์‚ฌ์ด์˜ ์‘๋‹ต + * - [Failure] : 200 ๋ฒˆ๋Œ€ ์™ธ์˜ ์‘๋‹ต + * - [HttpException] : ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ (์„œ๋ฒ„์™€ ๊ด€๋ จ๋œ ์—๋Ÿฌ, 404, 401...) + * - [NetworkException] : ์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ (IOException, ConnectException, SocketTimeoutException ์ด์™ธ์˜ ์—๋Ÿฌ) + */ +sealed interface ApiResponse { + data class Success(val data: T) : ApiResponse + + sealed class Failure(val throwable: Throwable) : ApiResponse { + data class HttpException(val code: Int, private val error: Throwable) : Failure(error) + + data class NetworkException(private val error: Throwable) : Failure(error) + + data class UnknownError(private val error: Throwable) : Failure(error) + } +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/base/ApiResponseExtensions.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/base/ApiResponseExtensions.kt new file mode 100644 index 00000000..7b8705e9 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/base/ApiResponseExtensions.kt @@ -0,0 +1,23 @@ +package poke.rogue.helper.remote.dto.base + +val ApiResponse.isSuccess: Boolean + get() = this is ApiResponse.Success + +val ApiResponse.isFailure: Boolean + get() = this is ApiResponse.Failure + +val ApiResponse.isHttpException: Boolean + get() = this is ApiResponse.Failure.HttpException + +val ApiResponse.isNetworkException: Boolean + get() = this is ApiResponse.Failure.NetworkException + +val ApiResponse.isUnKnownError: Boolean + get() = this is ApiResponse.Failure.UnknownError + +val ApiResponse.messageOrNull: String? + get() = + when (this) { + is ApiResponse.Failure -> throwable.message + is ApiResponse.Success -> null + } diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/request/.gitkeep b/android/remote/src/main/java/poke/rogue/helper/remote/dto/request/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/BaseResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/BaseResponse.kt new file mode 100644 index 00000000..56528839 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/BaseResponse.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.remote.dto.response + +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + val message: String, + val data: T, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityDetailResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityDetailResponse.kt new file mode 100644 index 00000000..cd2a4b44 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityDetailResponse.kt @@ -0,0 +1,13 @@ +package poke.rogue.helper.remote.dto.response.ability + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse + +@Serializable +data class AbilityDetailResponse( + @SerialName("koName") + val title: String, + val description: String, + val pokemons: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityDetailResponse2.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityDetailResponse2.kt new file mode 100644 index 00000000..dbc900af --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityDetailResponse2.kt @@ -0,0 +1,12 @@ +package poke.rogue.helper.remote.dto.response.ability + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AbilityDetailResponse2( + @SerialName("koName") + val name: String, + val description: String, + val pokemons: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityPokemonResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityPokemonResponse.kt new file mode 100644 index 00000000..58e9e107 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityPokemonResponse.kt @@ -0,0 +1,15 @@ +package poke.rogue.helper.remote.dto.response.ability + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +@Serializable +data class AbilityPokemonResponse( + val id: String, + val pokedexNumber: Long, + @SerialName("koName") + val name: String, + val image: String, + val pokemonTypeResponses: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityResponse.kt new file mode 100644 index 00000000..de1de395 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/AbilityResponse.kt @@ -0,0 +1,12 @@ +package poke.rogue.helper.remote.dto.response.ability + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AbilityResponse( + val id: String, + @SerialName("koName") + val name: String, + val description: String, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/PokemonAbilityResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/PokemonAbilityResponse.kt new file mode 100644 index 00000000..d514f1d8 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/ability/PokemonAbilityResponse.kt @@ -0,0 +1,12 @@ +package poke.rogue.helper.remote.dto.response.ability + +import kotlinx.serialization.Serializable + +@Serializable +data class PokemonAbilityResponse( + val id: String, + val name: String, + val description: String, + val passive: Boolean, + val hidden: Boolean, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/BattlePredictionResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/BattlePredictionResponse.kt new file mode 100644 index 00000000..793fcab6 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/BattlePredictionResponse.kt @@ -0,0 +1,14 @@ +package poke.rogue.helper.remote.dto.response.battle + +import kotlinx.serialization.Serializable + +@Serializable +data class BattlePredictionResponse( + val multiplier: Double, + val power: Int, + val accuracy: Double, + val moveName: String, + val moveDescription: String, + val moveType: String, + val moveCategory: String, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/PokemonSkillResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/PokemonSkillResponse.kt new file mode 100644 index 00000000..5f3de505 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/PokemonSkillResponse.kt @@ -0,0 +1,16 @@ +package poke.rogue.helper.remote.dto.response.battle + +import kotlinx.serialization.Serializable + +@Serializable +data class PokemonSkillResponse( + val id: String, + val name: String, + val typeEngName: String, + val typeLogo: String, + val categoryEngName: String, + val categoryLogo: String, + val power: Int, + val accuracy: Int, + val effect: String, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/WeatherResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/WeatherResponse.kt new file mode 100644 index 00000000..d751ca6d --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/battle/WeatherResponse.kt @@ -0,0 +1,11 @@ +package poke.rogue.helper.remote.dto.response.battle + +import kotlinx.serialization.Serializable + +@Serializable +data class WeatherResponse( + val id: String, + val name: String, + val description: String, + val effects: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biom/PokemonBiomeResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biom/PokemonBiomeResponse.kt new file mode 100644 index 00000000..cd2de732 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biom/PokemonBiomeResponse.kt @@ -0,0 +1,10 @@ +package poke.rogue.helper.remote.dto.response.biom + +import kotlinx.serialization.Serializable + +@Serializable +data class PokemonBiomeResponse( + val id: String, + val name: String, + val image: String, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biomes/BiomeDetailResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biomes/BiomeDetailResponse.kt new file mode 100644 index 00000000..ee7b07c1 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biomes/BiomeDetailResponse.kt @@ -0,0 +1,63 @@ +package poke.rogue.helper.remote.dto.response.biomes + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +@Serializable +class BiomeDetailResponse( + val id: String, + val name: String, + val image: String, + val wildPokemons: List, + val bossPokemons: List, + @SerialName("trainerPokemons") + val gymPokemons: List, + @SerialName("nextBiomes") + val nextBiomes: List, +) + +@Serializable +data class BiomePokemonResponse( + val id: String, + val name: String, + val image: String, + @SerialName("pokemonTypeResponses") + val types: List, +) + +@Serializable +data class WildPokemonResponse( + val tier: String, + val pokemons: List, +) + +@Serializable +data class BossPokemonResponse( + val tier: String, + val pokemons: List, +) + +@Serializable +data class GymPokemonResponse( + @SerialName("trainerName") + val gymLeaderName: String, + @SerialName("trainerImage") + val gymLeaderImage: String, + @SerialName("trainerTypeResponses") + val gymLeaderTypeLogos: List, + val pokemons: List, +) + +@Serializable +data class NextBiomesResponse( + val id: String, + val name: String, + val image: String, + @SerialName("pokemonTypeResponses") + val pokemonTypes: List, + @SerialName("trainerTypeResponses") + val gymLeaderTypes: List, + @SerialName("percent") + val probability: Double, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biomes/BiomesResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biomes/BiomesResponse.kt new file mode 100644 index 00000000..ea4c534b --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/biomes/BiomesResponse.kt @@ -0,0 +1,16 @@ +package poke.rogue.helper.remote.dto.response.biomes + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +@Serializable +data class BiomesResponse( + val id: String, + val name: String, + val image: String, + @SerialName("pokemonTypeResponses") + val pokemonTypes: List, + @SerialName("trainerTypeResponses") + val gymLeaderTypes: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/EvolutionResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/EvolutionResponse.kt new file mode 100644 index 00000000..e0e3f56b --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/EvolutionResponse.kt @@ -0,0 +1,25 @@ +package poke.rogue.helper.remote.dto.response.pokemon + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EvolutionResponse( + @SerialName("id") + val pokemonId: String = "", + @SerialName("name") + val pokemonName: String, + @SerialName("image") + val imageUrl: String, + val depth: Int, + val level: Int, + val item: String? = null, + val condition: String? = null, +) + +@Serializable +data class EvolutionsResponse( + val currentDepth: Int, + @SerialName("stages") + val evolutions: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonDetailResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonDetailResponse.kt new file mode 100644 index 00000000..97c83dfd --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonDetailResponse.kt @@ -0,0 +1,40 @@ +package poke.rogue.helper.remote.dto.response.pokemon + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.ability.PokemonAbilityResponse +import poke.rogue.helper.remote.dto.response.biom.PokemonBiomeResponse +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +@Serializable +data class PokemonDetailResponse( + val id: String, + @SerialName("pokedexNumber") + val dexNumber: Long, + val name: String, + @SerialName("pokemonImage") + val imageUrl: String, + @SerialName("pokemonTypeResponses") + val types: List, + @SerialName("pokemonAbilityResponses") + val abilities: List, + val totalStats: Int, + val hp: Int, + val attack: Int, + val defense: Int, + val specialAttack: Int, + val specialDefense: Int, + val speed: Int, + val evolutions: EvolutionsResponse, + @SerialName("eggMoveResponses") + val eggSkills: List, + @SerialName("moves") + val selfLearnSkills: List, + val weight: Float, + val height: Float, + val biomes: List, + val mythical: Boolean, + val subLegendary: Boolean, + val legendary: Boolean, + val canChangeForm: Boolean, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonResponse.kt new file mode 100644 index 00000000..752bd7eb --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonResponse.kt @@ -0,0 +1,18 @@ +package poke.rogue.helper.remote.dto.response.pokemon + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +@Serializable +data class PokemonResponse( + val id: Long, + val pokedexNumber: Long, + @SerialName("koName") + val name: String, + val image: String, + @SerialName("formName") + val formName: String = "", + @SerialName("pokemonTypeResponses") + val types: List, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonResponse2.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonResponse2.kt new file mode 100644 index 00000000..abe2bcfc --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonResponse2.kt @@ -0,0 +1,26 @@ +package poke.rogue.helper.remote.dto.response.pokemon + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import poke.rogue.helper.remote.dto.response.type.PokemonTypeResponse + +@Serializable +data class PokemonResponse2( + val id: String, + val pokedexNumber: Long, + val formName: String, + val name: String, + val image: String, + val backImage: String, + @SerialName("pokemonTypeResponse") + val types: List, + val generation: Int, + @SerialName("totalStats") + val baseStats: Int, + val speed: Int, + val hp: Int, + val attack: Int, + val defense: Int, + val specialAttack: Int, + val specialDefense: Int, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonSkillResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonSkillResponse.kt new file mode 100644 index 00000000..f37f5646 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/pokemon/PokemonSkillResponse.kt @@ -0,0 +1,16 @@ +package poke.rogue.helper.remote.dto.response.pokemon + +import kotlinx.serialization.Serializable + +@Serializable +data class PokemonSkillResponse( + val id: String, + val name: String, + val level: Int, + val power: Int, + val accuracy: Int, + val type: String, + val typeLogo: String, + val category: String, + val categoryLogo: String, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/type/PokemonTypeResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/type/PokemonTypeResponse.kt new file mode 100644 index 00000000..1e2d17a8 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/type/PokemonTypeResponse.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.remote.dto.response.type + +import kotlinx.serialization.Serializable + +@Serializable +data class PokemonTypeResponse( + val typeName: String, + val typeLogo: String, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/version/VersionResponse.kt b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/version/VersionResponse.kt new file mode 100644 index 00000000..b8b3a6be --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/dto/response/version/VersionResponse.kt @@ -0,0 +1,10 @@ +package poke.rogue.helper.remote.dto.response.version + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VersionResponse( + @SerialName("currentVersion") + val version: Int, +) diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeCall.kt b/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeCall.kt new file mode 100644 index 00000000..9ec30c31 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeCall.kt @@ -0,0 +1,78 @@ +package poke.rogue.helper.remote.injector + +import okhttp3.Request +import okio.Timeout +import poke.rogue.helper.remote.dto.base.ApiResponse +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException + +class PokeCall(private val call: Call) : Call> { + override fun enqueue(callback: Callback>) { + call.enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + val apiResponse = response.toApiResponse() + return callback.onResponse( + this@PokeCall, + Response.success(apiResponse), + ) + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + val errorResponse = t.toErrorResponse() + return callback.onResponse( + this@PokeCall, + Response.success(errorResponse), + ) + } + }, + ) + } + + private fun Response.toApiResponse(): ApiResponse { + val code: Int = code() + val error: String? = errorBody()?.string() + return if (isSuccessful) { // 200..300 + val body = body() + if (body != null) { + ApiResponse.Success(body) + } else { + ApiResponse.Failure.UnknownError(IllegalStateException("body == null")) + } + } else { // 300..400 + ApiResponse.Failure.HttpException(code, IOException(error)) + } + } + + private fun Throwable.toErrorResponse(): ApiResponse = + when (this) { + // Network Error - ex) UnknownHostException, SocketTimeoutException, ConnectException + is IOException -> ApiResponse.Failure.NetworkException(this) + // ํ†ต์‹  ์ด์Šˆ ์™ธ - ex) JsonSyntaxException, IllegalStateException + else -> ApiResponse.Failure.UnknownError(this) + } + + override fun clone(): Call> = PokeCall(call.clone()) + + override fun execute(): Response> { + throw UnsupportedOperationException("PokeCall ์€ execute() ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. enqueue() ๋ฅผ ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”.") + } + + override fun isExecuted(): Boolean = call.isExecuted + + override fun cancel() = call.cancel() + + override fun isCanceled(): Boolean = call.isCanceled + + override fun request(): Request = call.request() + + override fun timeout(): Timeout = call.timeout() +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeCallAdapterFactory.kt b/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeCallAdapterFactory.kt new file mode 100644 index 00000000..8205c320 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeCallAdapterFactory.kt @@ -0,0 +1,47 @@ +package poke.rogue.helper.remote.injector + +import poke.rogue.helper.remote.dto.base.ApiResponse +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class PokeCallAdapterFactory : CallAdapter.Factory() { + // getRawType - ๊ธฐ์ € ํƒ€์ž… + // List๊ณผ ๊ฐ™์€ ParameterizedType์ด ์žˆ๋Š” ๊ฒฝ์šฐ getRawType์€ List.class๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + // getParameterUpperBound - ์ œ๋„ค๋ฆญ ํƒ€์ž…์˜ ์ธ์ž๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + // List -> getParameterUpperBound(0, returnType) -> String.class + // Map -> getParameterUpperBound(1, returnType) -> Int.class + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + // 1) getRawType - Call> + val rawType: Type = getRawType(returnType) + if (rawType != Call::class.java) return null + require(returnType is ParameterizedType) { // 2 + "returnType(=$returnType) ์€ ๋ฐ˜๋“œ์‹œ Call> ํ˜•ํƒœ์—ฌ์•ผ ํ•จ" + } + + // 2) responseType - ApiResponse + val responseType: Type = getParameterUpperBound(0, returnType) + if (getRawType(responseType) != ApiResponse::class.java) return null + require(responseType is ParameterizedType) { + "responseType(=$responseType) ์€ ๋ฐ˜๋“œ์‹œ ApiResponse or ApiResponse ํ˜•ํƒœ์—ฌ์•ผ ํ•จ" + } + + // 3) bodyType - Foo + val bodyType = getParameterUpperBound(0, responseType) + return PokeCallAdapter(bodyType) + } + + private class PokeCallAdapter(private val responseType: Type) : + CallAdapter>> { + override fun responseType(): Type = responseType + + override fun adapt(call: Call): Call> = PokeCall(call) + } +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeConverterFactory.kt b/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeConverterFactory.kt new file mode 100644 index 00000000..2b164a74 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/injector/PokeConverterFactory.kt @@ -0,0 +1,52 @@ +package poke.rogue.helper.remote.injector + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import poke.rogue.helper.remote.dto.response.BaseResponse +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class PokeConverterFactory( + private val json: Json, + private val delegate: Converter.Factory, +) : Converter.Factory() { + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit, + ): Converter { + return Converter { body: ResponseBody -> + // type ์ด BaseResponse ์ด๋ผ๋ฉด BaseResponse ๋กœ ๋ณ€ํ™˜ + if (getRawType(type) == BaseResponse::class.java) { + return@Converter delegate.responseBodyConverter(type, annotations, retrofit) + ?.convert(body) + } + // unwrap BaseResponse + val baseResponseJsonObject = json.parseToJsonElement(body.string()).jsonObject + val dataPropertyName = + baseResponseJsonObject.keys.lastOrNull() ?: error("No data property") + val jsonString = baseResponseJsonObject[dataPropertyName].toString() + // unwrap ํ•œ data property + val newBody = jsonString.toResponseBody(body.contentType()) + delegate.responseBodyConverter(type, annotations, retrofit)?.convert(newBody) + } + } + + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + retrofit: Retrofit, + ): Converter<*, RequestBody>? { + return delegate.requestBodyConverter( + type, + parameterAnnotations, + methodAnnotations, + retrofit, + ) + } +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/interceptor/RedirectInterceptor.kt b/android/remote/src/main/java/poke/rogue/helper/remote/interceptor/RedirectInterceptor.kt new file mode 100644 index 00000000..ae3cedc4 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/interceptor/RedirectInterceptor.kt @@ -0,0 +1,27 @@ +package poke.rogue.helper.remote.interceptor + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import timber.log.Timber +import java.io.IOException + +class RedirectInterceptor(private val url: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + val response: Response = + try { + chain.proceed(request) + } catch (e: IOException) { + Timber.e("๊ฐœ๋ฐœ ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์‹คํŒจ: ${request.url}") + Timber.e("๋กœ์ปฌ ์„œ๋ฒ„๋กœ ์žฌ์‹œ๋„: $url") + val path: String = request.url.encodedPath + val localhostRequest: Request = + request.newBuilder() + .url(url + path) + .build() + chain.proceed(localhostRequest) + } + return response + } +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/service/AbilityService.kt b/android/remote/src/main/java/poke/rogue/helper/remote/service/AbilityService.kt new file mode 100644 index 00000000..e6d417e1 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/service/AbilityService.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.remote.service + +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.response.ability.AbilityDetailResponse2 +import poke.rogue.helper.remote.dto.response.ability.AbilityResponse +import retrofit2.http.GET +import retrofit2.http.Path + +interface AbilityService { + @GET("api/v1/abilities2") + suspend fun abilities(): ApiResponse> + + @GET("api/v1/ability2/{id}") + suspend fun ability( + @Path("id") id: String, + ): ApiResponse +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/service/BattleService.kt b/android/remote/src/main/java/poke/rogue/helper/remote/service/BattleService.kt new file mode 100644 index 00000000..2bfe24cc --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/service/BattleService.kt @@ -0,0 +1,26 @@ +package poke.rogue.helper.remote.service + +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.response.battle.BattlePredictionResponse +import poke.rogue.helper.remote.dto.response.battle.PokemonSkillResponse +import poke.rogue.helper.remote.dto.response.battle.WeatherResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface BattleService { + @GET("api/v1/weathers") + suspend fun weathers(): ApiResponse> + + @GET("api/v1/moves") + suspend fun availableSkills( + @Query("pokedex-number") dexNumber: Long, + ): ApiResponse> + + @GET("api/v1/battle") + suspend fun calculatedBattlePrediction( + @Query("weather-id") weatherId: String, + @Query("my-pokemon-id") myPokemonId: String, + @Query("my-move-id") mySkillId: String, + @Query("rival-pokemon-id") opponentPokemonId: String, + ): ApiResponse +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/service/BiomeService.kt b/android/remote/src/main/java/poke/rogue/helper/remote/service/BiomeService.kt new file mode 100644 index 00000000..98189fc8 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/service/BiomeService.kt @@ -0,0 +1,17 @@ +package poke.rogue.helper.remote.service + +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.response.biomes.BiomeDetailResponse +import poke.rogue.helper.remote.dto.response.biomes.BiomesResponse +import retrofit2.http.GET +import retrofit2.http.Path + +interface BiomeService { + @GET("api/v1/biomes") + suspend fun biomes(): ApiResponse> + + @GET("api/v1/biome/{id}") + suspend fun biome( + @Path("id") id: String, + ): ApiResponse +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/service/PokeDexService.kt b/android/remote/src/main/java/poke/rogue/helper/remote/service/PokeDexService.kt new file mode 100644 index 00000000..570c34d7 --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/service/PokeDexService.kt @@ -0,0 +1,21 @@ +package poke.rogue.helper.remote.service + +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonDetailResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse +import poke.rogue.helper.remote.dto.response.pokemon.PokemonResponse2 +import retrofit2.http.GET +import retrofit2.http.Path + +interface PokeDexService { + @GET("api/v1/pokemons2") + suspend fun pokemons2(): ApiResponse> + + @GET("api/v1/pokemons") + suspend fun pokemons(): ApiResponse> + + @GET("api/v1/pokemon2/{id}") + suspend fun pokemon( + @Path("id") id: String, + ): ApiResponse +} diff --git a/android/remote/src/main/java/poke/rogue/helper/remote/service/VersionService.kt b/android/remote/src/main/java/poke/rogue/helper/remote/service/VersionService.kt new file mode 100644 index 00000000..9f525d9a --- /dev/null +++ b/android/remote/src/main/java/poke/rogue/helper/remote/service/VersionService.kt @@ -0,0 +1,10 @@ +package poke.rogue.helper.remote.service + +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.response.version.VersionResponse +import retrofit2.http.GET + +interface VersionService { + @GET("api/v1/database/version") + suspend fun databaseVersion(): ApiResponse +} diff --git a/android/remote/src/test/java/poke/rogue/helper/remote/service/AbilityServiceTest.kt b/android/remote/src/test/java/poke/rogue/helper/remote/service/AbilityServiceTest.kt new file mode 100644 index 00000000..1df195a9 --- /dev/null +++ b/android/remote/src/test/java/poke/rogue/helper/remote/service/AbilityServiceTest.kt @@ -0,0 +1,120 @@ +package poke.rogue.helper.remote.service + +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.parameter.parametersOf +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.response.ability.AbilityDetailResponse2 +import poke.rogue.helper.remote.dto.response.ability.AbilityResponse +import poke.rogue.helper.remote.service.di.testRemoteModule +import poke.rogue.helper.remote.service.utils.getOrThrow +import poke.rogue.helper.remote.service.utils.httpErrorResponse +import poke.rogue.helper.remote.service.utils.shouldBeHttpException +import poke.rogue.helper.remote.service.utils.shouldBeNetworkException +import poke.rogue.helper.remote.service.utils.shouldBeSuccess +import poke.rogue.helper.remote.service.utils.shouldBeUnknownError +import poke.rogue.helper.remote.service.utils.successResponse +import java.io.IOException +import java.net.ConnectException + +class AbilityServiceTest : KoinTest { + private lateinit var mockWebServer: MockWebServer + private val service: AbilityService + get() = get { parametersOf(mockWebServer.url("")) } + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + } + + @JvmField + @RegisterExtension + val koinTestExtension = + KoinTestExtension.create { + mockWebServer = MockWebServer() + modules(testRemoteModule) + } + + @Test + fun `ํฌ์ผ“๋ชฌ์˜ ๋ชจ๋“  ํŠน์„ฑ๋“ค์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val fakeResponse = successResponse("abilities2") + mockWebServer.enqueue(fakeResponse) + + // when + val actual: ApiResponse> = service.abilities() + + // then + actual.shouldBeSuccess() + } + + @Test + fun `id ์— ํ•ด๋‹นํ•˜๋Š” ํŠน์„ฑ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val fakeResponse = successResponse("ability2") + mockWebServer.enqueue(fakeResponse) + // when + val actual: ApiResponse = service.ability("water_absorb") + + // then + actual.shouldBeSuccess() + } + + @Test + fun `HttpException ๋ฐœ์ƒ`() = + runTest { + // given + val fakeResponse = httpErrorResponse(404) + mockWebServer.enqueue(fakeResponse) + // when + val actual: ApiResponse> = service.abilities() + // then + assertSoftly { + actual.shouldBeHttpException() + shouldThrow { actual.getOrThrow() } + } + } + + @Test + fun `NetworkException - ConnectException ๋ฐœ์ƒ`() = + runTest { + // given + val fakeResponse = MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START) + mockWebServer.enqueue(fakeResponse) + // when + val actual: ApiResponse> = service.abilities() + // then + assertSoftly { + actual.shouldBeNetworkException() + shouldThrow { actual.getOrThrow() } + } + } + + @Test + fun `UnKnownException - ์ง๋ ฌํ™” ์˜ˆ์™ธ ๋ฐœ์ƒ`() = + runTest { + // given + val fakeResponse = MockResponse() + mockWebServer.enqueue(fakeResponse) + // when + val actual: ApiResponse> = service.abilities() + // then + assertSoftly { + actual.shouldBeUnknownError() + shouldThrow { actual.getOrThrow() } + } + } +} diff --git a/android/remote/src/test/java/poke/rogue/helper/remote/service/di/TestRemoteModule.kt b/android/remote/src/test/java/poke/rogue/helper/remote/service/di/TestRemoteModule.kt new file mode 100644 index 00000000..c9bee99a --- /dev/null +++ b/android/remote/src/test/java/poke/rogue/helper/remote/service/di/TestRemoteModule.kt @@ -0,0 +1,40 @@ +package poke.rogue.helper.remote.service.di + +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import org.koin.dsl.module +import poke.rogue.helper.remote.di.retrofitModule +import poke.rogue.helper.remote.injector.PokeCallAdapterFactory +import poke.rogue.helper.remote.injector.PokeConverterFactory +import poke.rogue.helper.remote.service.AbilityService +import retrofit2.Retrofit +import retrofit2.create + +val testRemoteModule + get() = + module { + includes(retrofitModule) + + single(named("test")) { + OkHttpClient + .Builder() + .addInterceptor(get()) + .build() + } + + single(named("test")) { (path: HttpUrl) -> + Retrofit.Builder() + .client(get(named("test"))) + .baseUrl(path) + .addCallAdapterFactory(get()) + .addConverterFactory(get()) + .build() + } + + single { (path: HttpUrl) -> + get(named("test")) { parametersOf(path) }.create() + } + } diff --git a/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/ApiResponseAssertions.kt b/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/ApiResponseAssertions.kt new file mode 100644 index 00000000..f9ebd06a --- /dev/null +++ b/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/ApiResponseAssertions.kt @@ -0,0 +1,29 @@ +package poke.rogue.helper.remote.service.utils + +import io.kotest.matchers.booleans.shouldBeTrue +import poke.rogue.helper.remote.dto.base.ApiResponse +import poke.rogue.helper.remote.dto.base.isFailure +import poke.rogue.helper.remote.dto.base.isHttpException +import poke.rogue.helper.remote.dto.base.isNetworkException +import poke.rogue.helper.remote.dto.base.isSuccess +import poke.rogue.helper.remote.dto.base.isUnKnownError + +fun ApiResponse.shouldBeSuccess() { + this.isSuccess.shouldBeTrue() +} + +fun ApiResponse.shouldBeFailure() { + this.isFailure.shouldBeTrue() +} + +fun ApiResponse.shouldBeHttpException() { + this.isHttpException.shouldBeTrue() +} + +fun ApiResponse.shouldBeNetworkException() { + this.isNetworkException.shouldBeTrue() +} + +fun ApiResponse.shouldBeUnknownError() { + this.isUnKnownError.shouldBeTrue() +} diff --git a/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/ApiResponseExtensions.kt b/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/ApiResponseExtensions.kt new file mode 100644 index 00000000..085ac404 --- /dev/null +++ b/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/ApiResponseExtensions.kt @@ -0,0 +1,10 @@ +package poke.rogue.helper.remote.service.utils + +import poke.rogue.helper.remote.dto.base.ApiResponse + +fun ApiResponse.getOrThrow(): Any { + return when (this) { + is ApiResponse.Success -> data + is ApiResponse.Failure -> throw throwable + } +} diff --git a/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/FakeResponseUtils.kt b/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/FakeResponseUtils.kt new file mode 100644 index 00000000..d085b190 --- /dev/null +++ b/android/remote/src/test/java/poke/rogue/helper/remote/service/utils/FakeResponseUtils.kt @@ -0,0 +1,13 @@ +package poke.rogue.helper.remote.service.utils + +import okhttp3.mockwebserver.MockResponse +import java.io.File + +fun successResponse(fileName: String): MockResponse { + val matchingJson = File("src/test/res/$fileName.json").readText() + return MockResponse().setBody(matchingJson).setResponseCode(200) +} + +fun httpErrorResponse(code: Int): MockResponse { + return MockResponse().setResponseCode(code) +} diff --git a/android/remote/src/test/res/abilities.json b/android/remote/src/test/res/abilities.json new file mode 100644 index 00000000..8d60d05f --- /dev/null +++ b/android/remote/src/test/res/abilities.json @@ -0,0 +1,20 @@ +{ + "message": "ํŠน์„ฑ ๋ฆฌ์ŠคํŠธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.", + "data": [ + { + "id": 1102, + "koName": "์•…์ทจ", + "description": "์•…์ทจ๋ฅผ ํ’๊ฒจ์„œ ๊ณต๊ฒฉํ–ˆ์„ ๋•Œ ์ƒ๋Œ€๊ฐ€ ํ’€์ฃฝ์„ ๋•Œ๊ฐ€ ์žˆ๋‹ค." + }, + { + "id": 1103, + "koName": "์ž”๋น„", + "description": "๋“ฑ์žฅํ–ˆ์„ ๋•Œ ๋‚ ์”จ๋ฅผ ๋น„๋กœ ๋งŒ๋“ ๋‹ค." + }, + { + "id": 1104, + "koName": "๊ฐ€์†", + "description": "๋งค ํ„ด ์Šคํ”ผ๋“œ๊ฐ€ ์˜ฌ๋ผ๊ฐ„๋‹ค." + } + ] +} diff --git a/android/remote/src/test/res/abilities2.json b/android/remote/src/test/res/abilities2.json new file mode 100644 index 00000000..45267bf8 --- /dev/null +++ b/android/remote/src/test/res/abilities2.json @@ -0,0 +1,35 @@ +{ + "message": "ํŠน์„ฑ ๋ฆฌ์ŠคํŠธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.", + "data": [ + { + "id": "pure_power", + "koName": "์ˆœ์ˆ˜ํ•œํž˜", + "description": "์š”๊ฐ€์˜ ํž˜์œผ๋กœ ๋ฌผ๋ฆฌ๊ณต๊ฒฉ์˜ ์œ„๋ ฅ์ด 2๋ฐฐ๊ฐ€ ๋œ๋‹ค." + }, + { + "id": "mummy", + "koName": "๋ฏธ๋ผ", + "description": "์ƒ๋Œ€๊ฐ€ ์ ‘์ด‰ํ•˜๋ฉด ์ƒ๋Œ€๋ฅผ ๋ฏธ๋ผ๋กœ ๋งŒ๋“ค์–ด๋ฒ„๋ฆฐ๋‹ค." + }, + { + "id": "toxic_chain", + "koName": "๋…์‚ฌ์Šฌ", + "description": "๋…์†Œ๋ฅผ ๋จธ๊ธˆ์€ ์‚ฌ์Šฌ์˜ ํž˜์œผ๋กœ ๊ธฐ์ˆ ์— ๋งž์€ ์ƒ๋Œ€๋ฅผ ๋งน๋… ์ƒํƒœ๋กœ ๋งŒ๋“ค ๋•Œ๊ฐ€ ์žˆ๋‹ค." + }, + { + "id": "shed_skin", + "koName": "ํƒˆํ”ผ", + "description": "๋ชธ์˜ ๊ป์งˆ์„ ๋ฒ—์–ด ๋˜์ ธ ์ƒํƒœ ์ด์ƒ์„ ํšŒ๋ณตํ•  ๋•Œ๊ฐ€ ์žˆ๋‹ค." + }, + { + "id": "water_absorb", + "koName": "์ €์ˆ˜", + "description": "๋ฌผํƒ€์ž…์˜ ๊ธฐ์ˆ ์„ ๋ฐ›์œผ๋ฉด ๋ฐ๋ฏธ์ง€๋ฅผ ๋ฐ›์ง€ ์•Š๊ณ  ํšŒ๋ณตํ•œ๋‹ค." + }, + { + "id": "suction_cups", + "koName": "ํก๋ฐ˜", + "description": "ํก๋ฐ˜์œผ๋กœ ์ง€๋ฉด์— ๋‹ฌ๋ผ๋ถ™์–ด ํฌ์ผ“๋ชฌ์„ ๊ต์ฒด์‹œํ‚ค๋Š” ๊ธฐ์ˆ ์ด๋‚˜ ๋„๊ตฌ์˜ ํšจ๊ณผ๋ฅผ ๋ฐœํœ˜ํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ํ•œ๋‹ค." + } + ] +} diff --git a/android/remote/src/test/res/ability.json b/android/remote/src/test/res/ability.json new file mode 100644 index 00000000..0dbb68ed --- /dev/null +++ b/android/remote/src/test/res/ability.json @@ -0,0 +1,26 @@ +{ + "message": "ํŠน์„ฑ ์ •๋ณด ๋ถˆ๋Ÿฌ์˜ค๊ธฐ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.", + "data": { + "koName": "์•…์ทจ", + "description": "์•…์ทจ๋ฅผ ํ’๊ฒจ์„œ ๊ณต๊ฒฉํ–ˆ์„ ๋•Œ ์ƒ๋Œ€๊ฐ€ ํ’€์ฃฝ์„ ๋•Œ๊ฐ€ ์žˆ๋‹ค.", + "pokemons": [ + { + "id": 2648, + "pokedexNumber": 44, + "koName": "๋ƒ„์ƒˆ๊ผฌ", + "backImage": "https://dl70s9ccojnge.cloudfront.net/pokerogue-helper/image/be4b7db8-0cae-4479-85cb-286b71192756", + "image": "https://dl70s9ccojnge.cloudfront.net/pokerogue-helper/image/be4b7db8-0cae-4479-85cb-286b71192756", + "pokemonTypeResponses": [ + { + "typeName": "poison", + "typeLogo": "https://dl70s9ccojnge.cloudfront.net/pokerogue-helper/type/poison.svg" + }, + { + "typeName": "grass", + "typeLogo": "https://dl70s9ccojnge.cloudfront.net/pokerogue-helper/type/grass.svg" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/android/remote/src/test/res/ability2.json b/android/remote/src/test/res/ability2.json new file mode 100644 index 00000000..31d99d18 --- /dev/null +++ b/android/remote/src/test/res/ability2.json @@ -0,0 +1,21 @@ +{ + "message": "ํŠน์„ฑ ์ •๋ณด ๋ถˆ๋Ÿฌ์˜ค๊ธฐ์— ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค.", + "data": { + "koName": "์ €์ˆ˜", + "description": "๋ฌผํƒ€์ž…์˜ ๊ธฐ์ˆ ์„ ๋ฐ›์œผ๋ฉด ๋ฐ๋ฏธ์ง€๋ฅผ ๋ฐ›์ง€ ์•Š๊ณ  ํšŒ๋ณตํ•œ๋‹ค.", + "pokemons": [ + { + "id": "poliwag", + "pokedexNumber": 60, + "koName": "๋ฐœ์ฑ™์ด", + "image": "https://dl70s9ccojnge.cloudfront.net/pokerogue-helper/pokerogue/pokemon/front/poliwag.png", + "pokemonTypeResponses": [ + { + "typeLogo": "https://dl70s9ccojnge.cloudfront.net/pokerogue-helper/pokerogue/type/water", + "typeName": "๋ฌผ" + } + ] + } + ] + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index e551b2f2..6c8380fe 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,16 +1,11 @@ pluginManagement { repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -21,4 +16,11 @@ dependencyResolutionManagement { rootProject.name = "PokeRogueHelper" include(":app") +include(":data") +include(":remote") +include(":local") +include(":testing") +include(":analytics") +// libraries +include(":stringmatcher") \ No newline at end of file diff --git a/android/stringmatcher/.gitignore b/android/stringmatcher/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/stringmatcher/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/stringmatcher/build.gradle.kts b/android/stringmatcher/build.gradle.kts new file mode 100644 index 00000000..10738b19 --- /dev/null +++ b/android/stringmatcher/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +dependencies { + implementation(libs.kotlin) + testImplementation(libs.bundles.unit.test) + testImplementation(libs.kotlin.test) +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} diff --git a/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/KrStringMatcher.kt b/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/KrStringMatcher.kt new file mode 100644 index 00000000..dccf324a --- /dev/null +++ b/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/KrStringMatcher.kt @@ -0,0 +1,72 @@ +package poke.rogue.helper.stringmatcher + +import poke.rogue.helper.stringmatcher.util.clean + +internal object KrStringMatcher : StringMatcher { + override fun isMatched( + search: String, + target: String, + ): Boolean { + val cleanedSearch = search.clean() + val cleanedTarget = target.clean() + val diff = cleanedTarget.length - cleanedSearch.length + if (diff < 0) return false // ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋” ๊ธธ๋ฉด false๋ฅผ ๋ฆฌํ„ดํ•œ๋‹ค. + + for (i in 0..diff) { + if (isSubstring(cleanedSearch, cleanedTarget, i)) return true + } + + return false + } + + /** + * 1) searchChr == targetChr + * 2) searchChr != targetChr && isKrConsonantMatch(searchChr, targetChr) + * - ํ•œ๊ธ€ ์ž์Œ์ด ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ + * - ex) search: "ใ„ฑ", target: "๊ฐ€" + * ์œ„์˜ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋ฉด loop๋ฅผ ๋ˆ๋‹ค + * */ + private fun isSubstring( + search: String, + target: String, + startIndex: Int, + ): Boolean { + var subStringLen = 0 + val searchLen = search.length + + while (subStringLen < searchLen) { + val searchChr = search[subStringLen] + val targetChr = target[startIndex + subStringLen] + if (searchChr != targetChr) { + if (!isKrConsonantMatch(searchChr, targetChr)) return false + } + subStringLen++ + } + return true + } + + private fun isKrConsonantMatch( + searchChr: Char, + targetChr: Char, + ): Boolean = searchChr.isKrConsonant() && targetChr.isKorean() && (searchChr == targetChr.mapToKrConsonant()) + + private fun Char.isKrConsonant(): Boolean = this in KR_CONSONANT_LIST + + private fun Char.mapToKrConsonant(): Char { + val consonantIndex = (this.code - KR_START_UNICODE) / KR_CONSONANT_UNIT + return KR_CONSONANT_LIST[consonantIndex] + } + + private fun Char.isKorean(): Boolean = this.code in KR_START_UNICODE..KR_END_UNICODE + + private const val KR_START_UNICODE = 0xAC00 // ๊ฐ€ code + private const val KR_END_UNICODE = 0xD7A3 // ํžฃ code + private const val KR_CONSONANT_UNIT = 588 // ๊ฐ์ž์Œ ๋งˆ๋‹ค ๊ฐ€์ง€๋Š” ๊ธ€์ž์ˆ˜ + private val KR_CONSONANT_LIST: List = + listOf( + 'ใ„ฑ', 'ใ„ฒ', 'ใ„ด', 'ใ„ท', 'ใ„ธ', + 'ใ„น', 'ใ…', 'ใ…‚', 'ใ…ƒ', 'ใ……', + 'ใ…†', 'ใ…‡', 'ใ…ˆ', 'ใ…‰', 'ใ…Š', + 'ใ…‹', 'ใ…Œ', 'ใ…', 'ใ…Ž', + ) +} diff --git a/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/StringMatcher.kt b/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/StringMatcher.kt new file mode 100644 index 00000000..ff293f4c --- /dev/null +++ b/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/StringMatcher.kt @@ -0,0 +1,15 @@ +package poke.rogue.helper.stringmatcher + +fun interface StringMatcher { + fun isMatched( + search: String, + target: String, + ): Boolean +} + +fun String.has( + search: String, + matcher: StringMatcher = KrStringMatcher, +): Boolean { + return matcher.isMatched(search, this) +} diff --git a/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/util/StringExtentions.kt b/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/util/StringExtentions.kt new file mode 100644 index 00000000..f2671e75 --- /dev/null +++ b/android/stringmatcher/src/main/java/poke/rogue/helper/stringmatcher/util/StringExtentions.kt @@ -0,0 +1,9 @@ +package poke.rogue.helper.stringmatcher.util + +/** + * ๊ณต๋ฐฑ ์ œ๊ฑฐ, ํŠน์ˆ˜๋ฌธ์ž ์ œ๊ฑฐ, lowerCase + * */ +internal fun String.clean(): String = + this.replace("\\s".toRegex(), "") + .replace("[^a-zA-Z0-9ใ„ฑ-ใ…Ž๊ฐ€-ํžฃ]".toRegex(), "") + .lowercase() diff --git a/android/stringmatcher/src/test/java/poke/rogue/helper/stringmatcher/KrStringMatcherTest.kt b/android/stringmatcher/src/test/java/poke/rogue/helper/stringmatcher/KrStringMatcherTest.kt new file mode 100644 index 00000000..8bcedc05 --- /dev/null +++ b/android/stringmatcher/src/test/java/poke/rogue/helper/stringmatcher/KrStringMatcherTest.kt @@ -0,0 +1,60 @@ +package poke.rogue.helper.stringmatcher + +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +internal class KrStringMatcherTest { + @Test + fun `๊ฒ€์ƒ‰๊ฐ’์ด target์˜ ๋ถ€๋ถ„ ๋ฌธ์ž์—ด์ธ์ง€ ํ™•์ธํ•œ๋‹ค`() { + // given + val mixedEnKrMatchStrategy = KrStringMatcher + val searchValue = "aa" + val target = "caaabc" + val searchValue2 = "๊ฐ€๋‚˜๋‹ค" + val target2 = "๊ฐ€๋‚˜๋‹ค๋ผ๋งˆ๋ฐ”์‚ฌ" + val searchValue3 = "์ด์ค€์›murj" + val target3 = "์ด์ค€์›murjune" + // when + val res = mixedEnKrMatchStrategy.isMatched(searchValue, target) + val res2 = mixedEnKrMatchStrategy.isMatched(searchValue2, target2) + val res3 = mixedEnKrMatchStrategy.isMatched(searchValue3, target3) + // then + assertSoftly { + res.shouldBeTrue() + res2.shouldBeTrue() + res3.shouldBeTrue() + } + } + + @Test + fun `๊ฒ€์ƒ‰๊ฐ’์— ํ•œ๊ธ€์ดˆ์„ฑ์ด ์กด์žฌํ•˜๊ณ  ํ•ด๋‹น ์œ„์น˜์˜ target์˜ ๋ฌธ์ž๊ฐ€ ํ•œ๊ธ€์ผ ๋•Œ, target๋ฌธ์ž์˜ ์ดˆ์„ฑ๊ณผ ์ผ์น˜ ์—ฌ๋ถ€๋ฅผ ๋น„๊ตํ•œ๋‹ค`() { + // given + val mixedEnKrMatchStrategy = KrStringMatcher + val searchValue = "ใ…ˆใ…ƒใ…‡ใ„ฑใ…Œใ„ฑ" + val target = "์ œ๋นต์™•๊น€ํƒ๊ตฌ" + val searchValue2 = "koํ‹€ใ„น" + val target2 = "koํ‹€๋ฆฐ" + // when + val res = mixedEnKrMatchStrategy.isMatched(searchValue, target) + val res2 = mixedEnKrMatchStrategy.isMatched(searchValue2, target2) + // then + assertSoftly { + res.shouldBeTrue() + res2.shouldBeTrue() + } + } + + @Test + fun `search_value์˜_๊ธธ์ด๊ฐ€_input์˜_๊ธธ์ด๋ณด๋‹ค_๊ธธ๋ฉด_false๋ฅผ_๋ฐ˜ํ™˜ํ•œ๋‹ค`() { + // given + val mixedEnKrMatchStrategy = KrStringMatcher + val searchValue = "์ด์ค€์›mur" + val target = "์ด์ค€์›" + // when + val result = mixedEnKrMatchStrategy.isMatched(searchValue, target) + // then + result.shouldBe(false) + } +} diff --git a/android/testing/.gitignore b/android/testing/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/testing/build.gradle.kts b/android/testing/build.gradle.kts new file mode 100644 index 00000000..5b49d7a8 --- /dev/null +++ b/android/testing/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) +} + +android { + namespace = "poke.rogue.helper.testing" + 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*/**" + } + } +} + +dependencies { + implementation(project(":analytics")) + implementation(project(":data")) + implementation(project(":stringmatcher")) + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin) + implementation(libs.timber) + implementation(libs.mockk) + // koin + implementation(platform(libs.koin.bom)) + implementation(libs.koin.core) + testImplementation(libs.koin.test.junit5) + // JUnit test api + api(libs.bundles.unit.test) + api(libs.kotlin.test) + // robolectric api + api(libs.bundles.robolectric.test) + // koin api + api(libs.koin.test.junit5) + api(libs.koin.android.test) +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/CoroutinesTestExtension.kt b/android/testing/src/main/java/poke/rogue/helper/testing/CoroutinesTestExtension.kt new file mode 100644 index 00000000..f1cfffbf --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/CoroutinesTestExtension.kt @@ -0,0 +1,24 @@ +package poke.rogue.helper.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class CoroutinesTestExtension( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, AfterEachCallback { + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(dispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/TestAnalyticsLogger.kt b/android/testing/src/main/java/poke/rogue/helper/testing/TestAnalyticsLogger.kt new file mode 100644 index 00000000..e5d0550e --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/TestAnalyticsLogger.kt @@ -0,0 +1,24 @@ +package poke.rogue.helper.testing + +import poke.rogue.helper.analytics.AnalyticsEvent +import poke.rogue.helper.analytics.AnalyticsLogger + +class TestAnalyticsLogger : AnalyticsLogger { + private val events = mutableListOf() + private val errors = mutableListOf() + + override fun logEvent(event: AnalyticsEvent) { + events.add(event) + } + + override fun logError( + throwable: Throwable, + message: String?, + ) { + errors.add(throwable) + } + + fun hasLogged(event: AnalyticsEvent) = event in events + + fun hasLoggedError(throwable: Throwable) = throwable in errors +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/TestApplication.kt b/android/testing/src/main/java/poke/rogue/helper/testing/TestApplication.kt new file mode 100644 index 00000000..d7a25bb6 --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/TestApplication.kt @@ -0,0 +1,5 @@ +package poke.rogue.helper.testing + +import android.app.Application + +class TestApplication : Application() diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepository.kt b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepository.kt new file mode 100644 index 00000000..d388504d --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepository.kt @@ -0,0 +1,94 @@ +package poke.rogue.helper.testing.data.repository + +import poke.rogue.helper.data.model.Ability +import poke.rogue.helper.data.model.AbilityDetail +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.repository.AbilityRepository +import poke.rogue.helper.stringmatcher.has + +class FakeAbilityRepository : AbilityRepository { + override suspend fun abilities(): List = ABILITES + + override suspend fun abilities(query: String): List = + ABILITES.filter { ability -> + ability.title.has(query) + } + + override suspend fun abilityDetail(id: String): AbilityDetail = + ABILITY_DETAILS[id] ?: throw IllegalArgumentException("Invalid Ability Id") + + companion object { + private const val FORMAT_POKEMON_IMAGE_URL = + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other" + + "/official-artwork/" + + private const val POSTFIX_PNG = ".png" + + private fun pokemonImageUrl(pokemonId: Long) = FORMAT_POKEMON_IMAGE_URL + pokemonId + POSTFIX_PNG + + val ABILITES: List = + listOf( + Ability("1", "์•…์ทจ", "์•…์ทจ๋ฅผ ํ’๊ฒจ์„œ ๊ณต๊ฒฉํ–ˆ์„ ๋•Œ ์ƒ๋Œ€๊ฐ€ ํ’€์ฃฝ์„ ๋•Œ๊ฐ€ ์žˆ๋‹ค."), + Ability("2", "์ž”๋น„", "๋“ฑ์žฅํ–ˆ์„ ๋•Œ ๋‚ ์”จ๋ฅผ ๋น„๋กœ ๋งŒ๋“ ๋‹ค."), + Ability("3", "๊ฐ€์†", "๋งค ํ„ด ์Šคํ”ผ๋“œ๊ฐ€ ์˜ฌ๋ผ๊ฐ„๋‹ค."), + Ability("4", "์ „ํˆฌ๋ฌด์žฅ", "๋‹จ๋‹จํ•œ ๊ป์งˆ์— ๋ณดํ˜ธ๋ฐ›์•„ ์ƒ๋Œ€์˜ ๊ณต๊ฒฉ์ด ๊ธ‰์†Œ์— ๋งž์ง€ ์•Š๋Š”๋‹ค."), + Ability("5", "์˜น๊ณจ์ฐธ", "์ƒ๋Œ€ ๊ธฐ์ˆ ์„ ๋ฐ›์•„๋„ ์ผ๊ฒฉ์œผ๋กœ ์“ฐ๋Ÿฌ์ง€์ง€ ์•Š๋Š”๋‹ค. ์ผ๊ฒฉํ•„์‚ด ๊ธฐ์ˆ ๋„ ํšจ๊ณผ ์—†๋‹ค."), + Ability("6", "์œ ์—ฐ", "์ฃผ๋ณ€์„ ์Šตํ•˜๊ฒŒ ํ•จ์œผ๋กœ์จ ์žํญ ๋“ฑ ํญ๋ฐœํ•˜๋Š” ๊ธฐ์ˆ ์„ ์•„๋ฌด๋„ ๋ชป ์“ฐ๊ฒŒ ํ•œ๋‹ค."), + Ability("7", "๋ชจ๋ž˜์ˆจ๊ธฐ", "๋ชจ๋ž˜๋ฐ”๋žŒ์ผ ๋•Œ ํšŒํ”ผ์œจ์ด ์˜ฌ๋ผ๊ฐ„๋‹ค."), + Ability("8", "์ •์ „๊ธฐ", "์ •์ „๊ธฐ๋ฅผ ๋ชธ์— ๋‘˜๋Ÿฌ ์ ‘์ด‰ํ•œ ์ƒ๋Œ€๋ฅผ ๋งˆ๋น„์‹œํ‚ฌ ๋•Œ๊ฐ€ ์žˆ๋‹ค."), + Ability("9", "์ถ•์ „ (P)", "์ „๊ธฐํƒ€์ž…์˜ ๊ธฐ์ˆ ์„ ๋ฐ›์œผ๋ฉด ๋ฐ๋ฏธ์ง€๋ฅผ ๋ฐ›์ง€ ์•Š๊ณ  ํšŒ๋ณตํ•œ๋‹ค."), + Ability("10", "์ €์ˆ˜ (P)", "๋ฌผํƒ€์ž…์˜ ๊ธฐ์ˆ ์„ ๋ฐ›์œผ๋ฉด ๋ฐ๋ฏธ์ง€๋ฅผ ๋ฐ›์ง€ ์•Š๊ณ  ํšŒ๋ณตํ•œ๋‹ค."), + Ability("11", "๋‘”๊ฐ", "๋‘”๊ฐํ•ด์„œ ํ—ค๋กฑํ—ค๋กฑ์ด๋‚˜ ๋„๋ฐœ ์ƒํƒœ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค."), + Ability("12", "๋‚ ์”จ๋ถ€์ •", "๋ชจ๋“  ๋‚ ์”จ์˜ ์˜ํ–ฅ์ด ์—†์–ด์ง„๋‹ค."), + Ability("13", "๋ณต์•ˆ", "๋ณต์•ˆ์„ ๊ฐ€์ง€๊ณ  ์žˆ์–ด ๊ธฐ์ˆ ์˜ ๋ช…์ค‘๋ฅ ์ด ์˜ฌ๋ผ๊ฐ„๋‹ค."), + Ability("14", "๋ถˆ๋ฉด", "์ž ๋“ค์ง€ ๋ชปํ•˜๋Š” ์ฒด์งˆ์ด๋ผ ์ž ๋“ฆ ์ƒํƒœ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค."), + Ability("15", "๋ณ€์ƒ‰", "์ƒ๋Œ€์—๊ฒŒ ๋ฐ›์€ ๊ธฐ์ˆ ์˜ ํƒ€์ž…์œผ๋กœ ์ž์‹ ์˜ ํƒ€์ž…์ด ๋ณ€ํ™”ํ•œ๋‹ค."), + Ability("16", "๋ฉด์—ญ", "์ฒด๋‚ด์— ๋ฉด์—ญ์„ ๊ฐ€์ง€๊ณ  ์žˆ์–ด ๋… ์ƒํƒœ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค."), + Ability("17", "ํƒ€์˜ค๋ฅด๋Š”๋ถˆ๊ฝƒ", "๋ถˆ๊ฝƒํƒ€์ž…์˜ ๊ธฐ์ˆ ์„ ๋ฐ›์œผ๋ฉด ๋ถˆ๊ฝƒ์„ ๋ฐ›์•„์„œ ์ž์‹ ์ด ์‚ฌ์šฉํ•˜๋Š” ๋ถˆ๊ฝƒํƒ€์ž…์˜ ๊ธฐ์ˆ ์ด ๊ฐ•ํ•ด์ง„๋‹ค."), + Ability("18", "์ธ๋ถ„ (P)", "์ธ๋ถ„์— ๋ณดํ˜ธ๋ฐ›์•„ ๊ธฐ์ˆ ์˜ ์ถ”๊ฐ€ ํšจ๊ณผ๋ฅผ ๋ฐ›์ง€ ์•Š๊ฒŒ ๋œ๋‹ค."), + Ability("19", "๋งˆ์ดํŽ˜์ด์Šค", "๋งˆ์ดํŽ˜์ด์Šค๋ผ์„œ ํ˜ผ๋ž€ ์ƒํƒœ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค."), + Ability("20", "ํ™‰๋ฐ˜", "ํก๋ฐ˜์œผ๋กœ ์ง€๋ฉด์— ๋‹ฌ๋ผ๋ถ™์–ด ํฌ์ผ“๋ชฌ์„ ๊ต์ฒด์‹œํ‚ค๋Š” ๊ธฐ์ˆ ์ด๋‚˜ ๋„๊ตฌ์˜ ํšจ๊ณผ๋ฅผ ๋ฐœํœ˜ํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ํ•œ๋‹ค."), + Ability("21", "์œ„ํ˜‘", "๋“ฑ์žฅํ–ˆ์„ ๋•Œ ์œ„ํ˜‘ํ•ด์„œ ์ƒ๋Œ€๋ฅผ ์œ„์ถ•์‹œ์ผœ ์ƒ๋Œ€์˜ ๊ณต๊ฒฉ์„ ๋–จ์–ด๋œจ๋ฆฐ๋‹ค."), + Ability("22", "๊ทธ๋ฆผ์ž๋ฐ๊ธฐ", "์ƒ๋Œ€์˜ ๊ทธ๋ฆผ์ž๋ฅผ ๋ฐŸ์•„ ๋„๋ง์น˜๊ฑฐ๋‚˜ ๊ต์ฒดํ•  ์ˆ˜ ์—†๊ฒŒ ํ•œ๋‹ค."), + Ability("23", "๊นŒ์น ํ•œํ”ผ๋ถ€", "๊ณต๊ฒฉ์„ ๋ฐ›์•˜์„ ๋•Œ ์ž์‹ ์—๊ฒŒ ์ ‘์ด‰ํ•œ ์ƒ๋Œ€๋ฅผ ๊นŒ์น ๊นŒ์น ํ•œ ํ”ผ๋ถ€๋กœ ์ƒ์ฒ˜๋ฅผ ์ž…ํžŒ๋‹ค."), + Ability("24", "๋ถˆ๊ฐ€์‚ฌ์˜๋ถ€์ ", "ํšจ๊ณผ๊ฐ€ ๊ต‰์žฅํ•œ ๊ธฐ์ˆ ๋งŒ ๋งž๋Š” ๋ถˆ๊ฐ€์‚ฌ์˜ํ•œ ํž˜."), + ) + + private val ABILITY_DETAILS: Map = + mapOf( + "1L" to + AbilityDetail( + title = "์•…์ทจ", + description = "์•…์ทจ๋ฅผ ํ’๊ฒจ์„œ ๊ณต๊ฒฉํ–ˆ์„ ๋•Œ ์ƒ๋Œ€๊ฐ€ ํ’€์ฃฝ์„ ๋•Œ๊ฐ€ ์žˆ๋‹ค.", + pokemons = + listOf( + Pokemon( + id = "1", + dexNumber = 1, + name = "์ด์ƒํ•ด์”จ", + imageUrl = pokemonImageUrl(pokemonId = 1), + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + ), + ), + "2L" to + AbilityDetail( + title = "์ž”๋น„", + description = "๋“ฑ์žฅํ–ˆ์„ ๋•Œ ๋‚ ์”จ๋ฅผ ๋น„๋กœ ๋งŒ๋“ ๋‹ค.", + pokemons = + listOf( + Pokemon( + id = "2", + dexNumber = 2, + name = "์ด์ƒํ•ดํ’€", + imageUrl = pokemonImageUrl(pokemonId = 2), + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + ), + ), + ) + } +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeBattleRepository.kt b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeBattleRepository.kt new file mode 100644 index 00000000..5eb0c106 --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeBattleRepository.kt @@ -0,0 +1,140 @@ +package poke.rogue.helper.testing.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import poke.rogue.helper.data.model.BattlePrediction +import poke.rogue.helper.data.model.BattleSkill +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonGeneration +import poke.rogue.helper.data.model.PokemonWithSkill +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.model.Weather +import poke.rogue.helper.data.repository.BattleRepository + +class FakeBattleRepository : BattleRepository { + override suspend fun weathers(): List = DUMMY_WEATHERS + + override suspend fun availableSkills(dexNumber: Long): List = DUMMY_SKILLS + + override suspend fun calculatedBattlePrediction( + weatherId: String, + myPokemonId: String, + mySkillId: String, + opponentPokemonId: String, + ): BattlePrediction = DUMMY_BATTLE_RESULT + + override suspend fun saveBattleSelection(pokemonId: String) {} + + override suspend fun saveBattleSelection( + pokemonId: String, + skillId: String, + ) { + } + + override suspend fun saveWeather(weatherId: String) {} + + override fun weatherStream(): Flow = flow { emit(DUMMY_WEATHERS[0]) } + + override fun pokemonStream(): Flow = flow { emit(DUMMY_POKEMON) } + + override fun pokemonWithSkillStream(): Flow = + flow { + emit( + PokemonWithSkill( + DUMMY_POKEMON, + DUMMY_SKILL, + ), + ) + } + + override suspend fun pokemonWithSkill(pokemonId: String): PokemonWithSkill = + PokemonWithSkill( + DUMMY_POKEMON, + DUMMY_SKILL, + ) + + companion object { + val DUMMY_POKEMON = + Pokemon( + id = "1", + dexNumber = 1, + name = "์ด์ƒํ•ด์”จ", + imageUrl = "", + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + generation = PokemonGeneration.ONE, + baseStat = 318, + hp = 45, + attack = 49, + defense = 49, + specialAttack = 65, + specialDefense = 65, + speed = 45, + ) + + val DUMMY_SKILL = + BattleSkill( + id = "4", + name = "Solar Beam", + type = Type.GRASS, + categoryLogo = "", + power = 120, + accuracy = 100, + effect = "", + ) + + val DUMMY_BATTLE_RESULT = BattlePrediction(0, 0.0, 0.0, 0.0) + + val DUMMY_WEATHERS = + listOf( + Weather( + id = "none", + name = "์—†์Œ", + description = "์—†์Œ", + effects = listOf("์—†์Œ"), + ), + Weather( + id = "sunny", + name = "์พŒ์ฒญ", + description = "ํ–‡์‚ด์ด ๊ฐ•ํ•˜๋‹ค", + effects = + listOf( + "๋ถˆ๊ฝƒ ํƒ€์ž… ๊ธฐ์ˆ ์˜ ์œ„๋ ฅ์ด 1.5๋ฐฐ๊ฐ€ ๋œ๋‹ค", + "๋ฌผ ํƒ€์ž… ๊ธฐ์ˆ ์˜ ์œ„๋ ฅ์ด 0.5๋ฐฐ๊ฐ€ ๋œ๋‹ค", + ), + ), + Weather( + id = "rain", + name = "๋น„", + description = "๋น„๊ฐ€ ๊ณ„์† ๋‚ด๋ฆฌ๊ณ  ์žˆ๋‹ค", + effects = + listOf( + "๋ฌผ ํƒ€์ž… ๊ธฐ์ˆ ์˜ ์œ„๋ ฅ์ด 1.5๋ฐฐ๊ฐ€ ๋œ๋‹ค", + "๋ถˆ๊ฝƒ ํƒ€์ž… ๊ธฐ์ˆ ์˜ ์œ„๋ ฅ์ด 0.5๋ฐฐ๊ฐ€ ๋œ๋‹ค", + ), + ), + ) + + val DUMMY_SKILLS = + listOf( + BattleSkill( + id = "tackle", + name = "๋ชธํ†ต๋ฐ•์น˜๊ธฐ", + type = Type.GRASS, + categoryLogo = "", + power = 40, + accuracy = 100, + effect = "์ƒ๋Œ€๋ฅผ ํ–ฅํ•ด์„œ ๋ชธ ์ „์ฒด๋ฅผ ๋ถ€๋”ช์ณ๊ฐ€๋ฉฐ ๊ณต๊ฒฉํ•œ๋‹ค.", + ), + BattleSkill( + id = "growl", + name = "์šธ์Œ์†Œ๋ฆฌ", + type = Type.NORMAL, + categoryLogo = "", + power = -1, + accuracy = 100, + effect = "๊ท€์—ฌ์šด ์šธ์Œ์†Œ๋ฆฌ๋ฅผ ๋“ค๋ ค์ฃผ๊ณ  ๊ด€์‹ฌ์„ ๋Œ์–ด ๋ฐฉ์‹ฌํ•œ ์‚ฌ์ด์— ์ƒ๋Œ€์˜ ๊ณต๊ฒฉ์„ ๋–จ์–ด๋œจ๋ฆฐ๋‹ค.", + ), + ) + } +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeBiomeRepository.kt b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeBiomeRepository.kt new file mode 100644 index 00000000..8c09a0ca --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeBiomeRepository.kt @@ -0,0 +1,130 @@ +package poke.rogue.helper.testing.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import poke.rogue.helper.data.model.Biome +import poke.rogue.helper.data.model.BiomeDetail +import poke.rogue.helper.data.model.NextBiome +import poke.rogue.helper.data.model.Type +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.data.repository.BiomeRepository +import poke.rogue.helper.stringmatcher.has + +class FakeBiomeRepository : BiomeRepository { + override suspend fun biomes(): List = BIOMES + + override suspend fun biomes(query: String): List = BIOMES.filter { biome -> biome.name.has(query) } + + override suspend fun biomeDetail(id: String): BiomeDetail = BIOME_DETAIL[id] ?: throw IllegalArgumentException("Invalid biome ID") + + override suspend fun saveNavigationMode(isBattleNavigationMode: Boolean) { + isBattleNavigationModeFlow.value = isBattleNavigationMode + } + + override fun isBattleNavigationModeStream(): Flow = isBattleNavigationModeFlow + + companion object { + val BIOMES: List = + listOf( + Biome( + id = "grass", + name = "ํ’€์ˆฒ", + image = "https://wiki.pokerogue.net/_media/ko:biomes:ko_grassy_fields_bg.png?w=200&tok=745c5b", + pokemonType = listOf(Type.GRASS, Type.BUG), + gymLeaderType = listOf(Type.GRASS), + ), + Biome( + id = "tall_grass", + name = "๋†’์€ ํ’€์ˆฒ", + image = "https://wiki.pokerogue.net/_media/ko:biomes:ko_tall_grass_bg.png?w=200&tok=b3497c", + pokemonType = listOf(Type.BUG), + gymLeaderType = listOf(Type.GRASS), + ), + Biome( + id = "cave", + name = "๋™๊ตด", + image = "https://wiki.pokerogue.net/_media/ko:biomes:ko_cave_bg.png?w=200&tok=905d8b", + pokemonType = listOf(Type.GRASS), + gymLeaderType = listOf(Type.GRASS), + ), + Biome( + id = "badlands", + name = "์•…์ง€", + image = "https://wiki.pokerogue.net/_media/ko:biomes:ko_badlands_bg.png?w=200&tok=37d070", + pokemonType = listOf(Type.DARK, Type.FIGHTING), + gymLeaderType = listOf(Type.DARK, Type.FIGHTING), + ), + ) + + val BIOME_DETAIL: Map = + mapOf( + "grass" to + BiomeDetail( + id = "grass", + name = "ํ’€์ˆฒ", + image = "https://wiki.pokerogue.net/_media/ko:biomes:ko_grassy_fields_bg.png?w=200&tok=745c5b", + wildPokemons = + listOf( + WildPokemon( + "๋ ˆ์–ด", + listOf( + BiomePokemon( + id = "์ด์ƒํ•ด์”จ", + name = "์ด์ƒํ•ด์”จ", + imageUrl = "", + types = listOf(Type.BUG, Type.GRASS), + ), + ), + ), + ), + bossPokemons = + listOf( + BossPokemon( + "๋ ˆ์–ด", + listOf( + BiomePokemon( + id = "์ด์ƒํ•ดํ’€", + name = "์ด์ƒํ•ดํ’€", + imageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + ), + ), + ), + gymPokemons = + listOf( + GymPokemon( + gymLeaderName = "์˜ค๋ฐ•์‚ฌ", + gymLeaderImage = "", + gymLeaderTypeLogos = emptyList(), + pokemons = + listOf( + BiomePokemon( + id = "์ด์ƒํ•ด๊ฝƒ", + name = "์ด์ƒํ•ด๊ฝƒ", + imageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + ), + ), + ), + ), + nextBiomes = + listOf( + NextBiome( + id = "tall_grass", + name = "๋†’์€ ํ’€์ˆฒ", + image = "https://wiki.pokerogue.net/_media/ko:biomes:ko_tall_grass_bg.png?w=200&tok=b3497c", + pokemonType = emptyList(), + gymLeaderType = emptyList(), + probability = 0.5, + ), + ), + ), + ) + + private val isBattleNavigationModeFlow = MutableStateFlow(false) + } +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeDexRepository.kt b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeDexRepository.kt new file mode 100644 index 00000000..a43e1352 --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeDexRepository.kt @@ -0,0 +1,245 @@ +package poke.rogue.helper.testing.data.repository + +import poke.rogue.helper.data.model.Evolution +import poke.rogue.helper.data.model.Pokemon +import poke.rogue.helper.data.model.PokemonBiome +import poke.rogue.helper.data.model.PokemonCategory +import poke.rogue.helper.data.model.PokemonDetail +import poke.rogue.helper.data.model.PokemonDetailAbility.Companion.DUMMY_POKEMON_DETAIL_ABILTIES +import poke.rogue.helper.data.model.PokemonDetailSkills +import poke.rogue.helper.data.model.PokemonFilter +import poke.rogue.helper.data.model.PokemonGeneration +import poke.rogue.helper.data.model.PokemonSkill +import poke.rogue.helper.data.model.PokemonSort +import poke.rogue.helper.data.model.Stat +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.repository.DexRepository +import poke.rogue.helper.stringmatcher.has + +class FakeDexRepository : DexRepository { + override suspend fun warmUp() = Unit + + override suspend fun pokemons(): List = POKEMONS + + override suspend fun filteredPokemons( + name: String, + sort: PokemonSort, + filters: List, + ): List { + return if (name.isEmpty()) { + pokemons() + } else { + pokemons().filter { it.name.has(name) } + }.toFilteredPokemons(sort, filters) + } + + override suspend fun pokemonDetail(id: String): PokemonDetail = + PokemonDetail( + pokemon = Pokemon.DUMMY, + abilities = DUMMY_POKEMON_DETAIL_ABILTIES, + stats = Stat.DUMMY_STATS, + pokemonCategory = PokemonCategory.EMPTY, + evolutions = Evolution.DUMMY_PICAKCHU_EVOLUTION, + skills = + PokemonDetailSkills( + selfLearn = PokemonSkill.FAKE_SELF_LEARN_SKILLS, + eggLearn = PokemonSkill.FAKE_EGG_LEARN_SKILLS, + tmLearn = PokemonSkill.FAKE_TM_LEARN_SKILLS, + ), + biomes = PokemonBiome.DUMMYS, + height = 0.7, + weight = 6.9, + ) + + override suspend fun pokemon(id: String): Pokemon = pokemons().find { it.id == id } ?: error("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํฌ์ผ“๋ชฌ ID : $id") + + private fun List.toFilteredPokemons( + sort: PokemonSort, + filters: List, + ): List { + return this + .filter { pokemon -> + filters.all { filter -> + when (filter) { + is PokemonFilter.ByType -> pokemon.types.contains(filter.type) + is PokemonFilter.ByGeneration -> pokemon.generation == filter.generation + } + } + } + .sortedWith(sort) + } + + companion object { + private const val FORMAT_POKEMON_IMAGE_URL = + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other" + + "/official-artwork/" + + private const val POSTFIX_PNG = ".png" + + private fun pokemonImageUrl(pokemonId: Long) = FORMAT_POKEMON_IMAGE_URL + pokemonId + POSTFIX_PNG + + val POKEMONS: List = + listOf( + Pokemon( + id = "1", + dexNumber = 1, + name = "์ด์ƒํ•ด์”จ", + imageUrl = pokemonImageUrl(pokemonId = 1), + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + generation = PokemonGeneration.ONE, + baseStat = 318, + hp = 45, + attack = 49, + defense = 49, + specialAttack = 65, + specialDefense = 65, + speed = 45, + ), + Pokemon( + id = "2", + dexNumber = 2, + name = "์ด์ƒํ•ดํ’€", + imageUrl = pokemonImageUrl(pokemonId = 2), + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + generation = PokemonGeneration.ONE, + baseStat = 405, + hp = 60, + attack = 62, + defense = 63, + specialAttack = 80, + specialDefense = 80, + speed = 60, + ), + Pokemon( + id = "3", + dexNumber = 3, + name = "์ด์ƒํ•ด๊ฝƒ", + imageUrl = pokemonImageUrl(pokemonId = 3), + backImageUrl = "", + types = listOf(Type.GRASS, Type.POISON), + generation = PokemonGeneration.ONE, + baseStat = 525, + hp = 80, + attack = 82, + defense = 83, + specialAttack = 100, + specialDefense = 100, + speed = 80, + ), + Pokemon( + id = "4", + dexNumber = 4, + name = "ํŒŒ์ด๋ฆฌ", + imageUrl = pokemonImageUrl(pokemonId = 4), + backImageUrl = "", + types = listOf(Type.FIRE), + generation = PokemonGeneration.ONE, + baseStat = 309, + hp = 39, + attack = 52, + defense = 43, + specialAttack = 60, + specialDefense = 50, + speed = 65, + ), + Pokemon( + id = "5", + dexNumber = 5, + name = "๋ฆฌ์ž๋“œ", + imageUrl = pokemonImageUrl(pokemonId = 5), + backImageUrl = "", + types = listOf(Type.FIRE), + generation = PokemonGeneration.ONE, + baseStat = 405, + hp = 58, + attack = 64, + defense = 58, + specialAttack = 80, + specialDefense = 65, + speed = 80, + ), + Pokemon( + id = "6", + dexNumber = 6, + name = "๋ฆฌ์ž๋ชฝ", + imageUrl = pokemonImageUrl(pokemonId = 6), + backImageUrl = "", + types = listOf(Type.FIRE, Type.FLYING), + generation = PokemonGeneration.ONE, + baseStat = 534, + hp = 78, + attack = 84, + defense = 78, + specialAttack = 109, + specialDefense = 85, + speed = 100, + ), + Pokemon( + id = "7", + dexNumber = 7, + name = "๊ผฌ๋ถ€๊ธฐ", + imageUrl = pokemonImageUrl(pokemonId = 7), + backImageUrl = "", + types = listOf(Type.WATER), + generation = PokemonGeneration.ONE, + baseStat = 314, + hp = 44, + attack = 48, + defense = 65, + specialAttack = 50, + specialDefense = 64, + speed = 43, + ), + Pokemon( + id = "8", + dexNumber = 8, + name = "์–ด๋‹ˆ๋ถ€๊ธฐ", + imageUrl = pokemonImageUrl(pokemonId = 8), + backImageUrl = "", + types = listOf(Type.WATER), + generation = PokemonGeneration.ONE, + baseStat = 405, + hp = 59, + attack = 63, + defense = 80, + specialAttack = 65, + specialDefense = 80, + speed = 58, + ), + Pokemon( + id = "9", + dexNumber = 9, + name = "๊ฑฐ๋ถ์™•", + imageUrl = pokemonImageUrl(pokemonId = 9), + backImageUrl = "", + types = listOf(Type.WATER), + generation = PokemonGeneration.ONE, + baseStat = 530, + hp = 79, + attack = 83, + defense = 100, + specialAttack = 85, + specialDefense = 105, + speed = 78, + ), + Pokemon( + id = "373", + dexNumber = 373, + name = "๋ณด๋งŒ๋‹ค", + imageUrl = pokemonImageUrl(pokemonId = 373), + backImageUrl = "", + types = listOf(Type.FLYING, Type.DRAGON), + generation = PokemonGeneration.THREE, + baseStat = 600, + hp = 95, + attack = 135, + defense = 80, + specialAttack = 110, + specialDefense = 80, + speed = 100, + ), + ) + } +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeTypeRepository.kt b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeTypeRepository.kt new file mode 100644 index 00000000..158c1719 --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/data/repository/FakeTypeRepository.kt @@ -0,0 +1,81 @@ +package poke.rogue.helper.testing.data.repository + +import poke.rogue.helper.data.model.MatchedResult +import poke.rogue.helper.data.model.MatchedTypes +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.repository.TypeRepository + +class FakeTypeRepository : TypeRepository { + override fun matchedTypesAgainstMyType(myTypeId: Int): List = + DUMMY_RESULTS_AGAINST_MINE[Type.fromId(myTypeId)] + ?: throw IllegalArgumentException("Invalid ID : $myTypeId") + + override fun matchedTypesAgainstOpponent(opponentTypeId: Int): List = + DUMMY_RESULTS_AGAINST_OPPONENT[Type.fromId(opponentTypeId)] + ?: throw IllegalArgumentException("Invalid ID : $opponentTypeId") + + override fun matchedTypes( + myTypeId: Int, + opponentTypeIds: List, + ): List { + val myType = Type.fromId(myTypeId) + val result = + opponentTypeIds.groupBy { opponentTypeId -> + val opponentType = Type.fromId(opponentTypeId) + DUMMY_RESULTS[myType]?.get( + opponentType, + ) ?: throw IllegalArgumentException("No result for myType: $myType and opponentType: $opponentType") + } + return result.entries.map { (matchedResult, types) -> + MatchedTypes(matchedResult, types.map { Type.fromId(it) }) + } + } + + override fun allTypes(): List = listOf() + + companion object { + val DUMMY_RESULTS_AGAINST_MINE = + mapOf( + Type.FAIRY to + listOf( + MatchedTypes(MatchedResult.STRONG, listOf(Type.ICE, Type.DRAGON)), + MatchedTypes(MatchedResult.WEAK, listOf(Type.FIRE, Type.POISON)), + ), + ) + + val DUMMY_RESULTS_AGAINST_OPPONENT = + mapOf( + Type.FAIRY to + listOf( + MatchedTypes(MatchedResult.NORMAL, listOf(Type.WATER, Type.GRASS)), + MatchedTypes(MatchedResult.STRONG, listOf(Type.POISON, Type.STEEL)), + ), + ) + + val DUMMY_RESULTS = + mapOf( + Type.FAIRY to + mapOf( + Type.NORMAL to MatchedResult.NORMAL, + Type.FIRE to MatchedResult.WEAK, + Type.WATER to MatchedResult.NORMAL, + Type.ELECTRIC to MatchedResult.NORMAL, + Type.GRASS to MatchedResult.NORMAL, + Type.ICE to MatchedResult.NORMAL, + Type.FIGHTING to MatchedResult.STRONG, + Type.POISON to MatchedResult.WEAK, + Type.GROUND to MatchedResult.NORMAL, + Type.FLYING to MatchedResult.NORMAL, + Type.PSYCHIC to MatchedResult.NORMAL, + Type.BUG to MatchedResult.NORMAL, + Type.ROCK to MatchedResult.NORMAL, + Type.GHOST to MatchedResult.NORMAL, + Type.DRAGON to MatchedResult.STRONG, + Type.DARK to MatchedResult.STRONG, + Type.STEEL to MatchedResult.WEAK, + Type.FAIRY to MatchedResult.NORMAL, + Type.STELLAR to MatchedResult.NORMAL, + ), + ) + } +} diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/di/TestingModule.kt b/android/testing/src/main/java/poke/rogue/helper/testing/di/TestingModule.kt new file mode 100644 index 00000000..54827abc --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/di/TestingModule.kt @@ -0,0 +1,39 @@ +package poke.rogue.helper.testing.di + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import poke.rogue.helper.analytics.AnalyticsLogger +import poke.rogue.helper.data.repository.AbilityRepository +import poke.rogue.helper.data.repository.BattleRepository +import poke.rogue.helper.data.repository.BiomeRepository +import poke.rogue.helper.data.repository.DexRepository +import poke.rogue.helper.data.repository.TypeRepository +import poke.rogue.helper.testing.TestAnalyticsLogger +import poke.rogue.helper.testing.data.repository.FakeAbilityRepository +import poke.rogue.helper.testing.data.repository.FakeBattleRepository +import poke.rogue.helper.testing.data.repository.FakeBiomeRepository +import poke.rogue.helper.testing.data.repository.FakeDexRepository +import poke.rogue.helper.testing.data.repository.FakeTypeRepository + +val testingModule + get() = + module { + includes(fakeRepositoryModule, fakeAnalyticsModule) + } + +private val fakeRepositoryModule + get() = + module { + singleOf(::FakeAbilityRepository).bind() + singleOf(::FakeDexRepository).bind() + singleOf(::FakeBiomeRepository).bind() + singleOf(::FakeTypeRepository).bind() + singleOf(::FakeBattleRepository).bind() + } + +private val fakeAnalyticsModule + get() = + module { + singleOf(::TestAnalyticsLogger).bind() + } diff --git a/android/testing/src/main/java/poke/rogue/helper/testing/rule/KoinAndroidUnitTestRule.kt b/android/testing/src/main/java/poke/rogue/helper/testing/rule/KoinAndroidUnitTestRule.kt new file mode 100644 index 00000000..637c33a0 --- /dev/null +++ b/android/testing/src/main/java/poke/rogue/helper/testing/rule/KoinAndroidUnitTestRule.kt @@ -0,0 +1,32 @@ +package poke.rogue.helper.testing.rule + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.GlobalContext.getKoinApplicationOrNull +import org.koin.core.context.loadKoinModules +import org.koin.core.context.startKoin +import org.koin.core.context.unloadKoinModules +import org.koin.core.module.Module + +class KoinAndroidUnitTestRule( + private val modules: List, +) : TestWatcher() { + constructor(vararg modules: Module) : this(modules.toList()) + + override fun starting(description: Description) { + if (getKoinApplicationOrNull() == null) { + startKoin { + androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext) + modules(modules) + } + } else { + loadKoinModules(modules) + } + } + + override fun finished(description: Description) { + unloadKoinModules(modules) + } +} diff --git a/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt new file mode 100644 index 00000000..7ba163ad --- /dev/null +++ b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeAbilityRepositoryTest.kt @@ -0,0 +1,84 @@ +package poke.rogue.helper.testing.data.repository + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.logger.Level +import org.koin.core.logger.PrintLogger +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.data.repository.AbilityRepository +import poke.rogue.helper.testing.di.testingModule + +class FakeAbilityRepositoryTest : KoinTest { + private val repository: AbilityRepository + get() = get() + + @JvmField + @RegisterExtension + val koinExtension = + KoinTestExtension.create { + modules(testingModule) + logger(PrintLogger(Level.DEBUG)) + } + + @Test + fun `๋ชจ๋“  ํŠน์„ฑ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค`() = + runTest { + // when + val actualAbilities = repository.abilities() + + // then + actualAbilities.size shouldBe 24 + } + + @Test + fun `์ž˜๋ชป๋œ ํŠน์„ฑ ์ด๋ฆ„์„ ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ, ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = + runTest { + // given + val query = "์ž˜๋ชป๋œ ํŠน์„ฑ ์ด๋ฆ„" + + // when + val ability = repository.abilities(query) + + // then + ability.isEmpty() shouldBe true + } + + @Test + fun `์˜ฌ๋ฐ”๋ฅธ ํŠน์„ฑ์˜ ์ด๋ฆ„์„ ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ, ํ•ด๋‹นํ•˜๋Š” ํŠน์„ฑ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = + runTest { + // when + val ability = repository.abilities("ํƒ€์˜ค๋ฅด๋Š”๋ถˆ๊ฝƒ") + + // then + val actual = ability.find { it.title == "ํƒ€์˜ค๋ฅด๋Š”๋ถˆ๊ฝƒ" }?.title + val expect = "ํƒ€์˜ค๋ฅด๋Š”๋ถˆ๊ฝƒ" + actual shouldBe expect + } + + @Test + fun `ํŠน์„ฑ์˜ ์ด๋ฆ„์„ ์ดˆ์„ฑ์œผ๋กœ ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ, ํ•ด๋‹นํ•˜๋Š” ํŠน์„ฑ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = + runTest { + // given + val query = "ใ…Œใ…‡ใ„นใ„ดใ…‚ใ„ฒ" + + // when + val ability = repository.abilities(query) + + // then + ability.find { it.title == "ํƒ€์˜ค๋ฅด๋Š”๋ถˆ๊ฝƒ" }?.title shouldBe "ํƒ€์˜ค๋ฅด๋Š”๋ถˆ๊ฝƒ" + } + + @Test + fun `์ž˜๋ชป๋œ ID๊ฐ’์œผ๋กœ ์กฐํšŒํ•˜๋ฉด, ํŠน์„ฑ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค`() = + runTest { + // when, then + shouldThrow { + repository.abilityDetail("-1") + } + } +} diff --git a/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeBiomeRepositoryTest.kt b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeBiomeRepositoryTest.kt new file mode 100644 index 00000000..78bbe2f2 --- /dev/null +++ b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeBiomeRepositoryTest.kt @@ -0,0 +1,53 @@ +package poke.rogue.helper.testing.data.repository + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.data.repository.BiomeRepository +import poke.rogue.helper.testing.di.testingModule + +class FakeBiomeRepositoryTest : KoinTest { + private val repository: BiomeRepository + get() = get() + + @JvmField + @RegisterExtension + val koinExtension = + KoinTestExtension.create { + modules(testingModule) + } + + @Test + fun `๋ชจ๋“  ๋ฐ”์ด์˜ด ๋ฆฌ์ŠคํŠธ์˜ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋ฉด, ์‚ฌ์ด์ฆˆ๊ฐ€ 4์ด๋‹ค`() = + runTest { + // when + val actualBiomes = repository.biomes() + + // then + actualBiomes.size shouldBe 4 + } + + @Test + fun `์ž˜๋ชป๋œ ๋ฐ”์ด์˜ด ID๊ฐ’์œผ๋กœ ์กฐํšŒํ•˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค`() = + runTest { + // when,then + assertThrows { + repository.biomeDetail("-1") + } + } + + @Test + fun `์˜ฌ๋ฐ”๋ฅธ ๋ฐ”์ด์˜ด ID๊ฐ’์œผ๋กœ ์กฐํšŒํ•˜๋ฉด, ๋ฐ”์ด์˜ด ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() = + runTest { + // when + val biomeDetail = repository.biomeDetail("grass") + + // then + biomeDetail.id shouldBe "grass" + } +} diff --git a/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeDexRepositoryTest.kt b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeDexRepositoryTest.kt new file mode 100644 index 00000000..dfc3e8fc --- /dev/null +++ b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeDexRepositoryTest.kt @@ -0,0 +1,133 @@ +package poke.rogue.helper.testing.data.repository + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.data.model.PokemonFilter +import poke.rogue.helper.data.model.PokemonGeneration +import poke.rogue.helper.data.model.PokemonSort +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.repository.DexRepository +import poke.rogue.helper.stringmatcher.has +import poke.rogue.helper.testing.di.testingModule + +class FakeDexRepositoryTest : KoinTest { + private val repository: DexRepository + get() = get() + + @JvmField + @RegisterExtension + val koinExtension = + KoinTestExtension.create { + modules(testingModule) + } + + @Test + fun `๊ธฐ๋ณธ์ ์œผ๋กœ ๋„๊ฐ ๋ฒˆํ˜ธ์ˆœ, ๋ชจ๋“  ์„ธ๋Œ€์˜ ํฌ์ผ“๋ชฌ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val sort = PokemonSort.BySpeed + // when + val actual = repository.filteredPokemons() + // then + val expect = repository.pokemons() + actual shouldBe expect + } + + @Test + fun `๋ถˆ ํƒ€์ž…์„ ๊ฐ€์ง€๊ณ  ์žˆ๋Š” ํฌ์ผ“๋ชฌ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val type = Type.FIRE + // when + val actual = repository.filteredPokemons(filters = listOf(PokemonFilter.ByType(type))) + // then + val expect = repository.pokemons().filter { it.types.contains(type) } + actual shouldBe expect + actual shouldNotBe repository.pokemons() + } + + @Test + fun `3 ์„ธ๋Œ€์— ํ•ด๋‹นํ•˜๋Š” ํฌ์ผ“๋ชฌ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val generation = PokemonGeneration.of(3) + // when + val actual = + repository.filteredPokemons(filters = listOf(PokemonFilter.ByGeneration(generation))) + // then + val expect = repository.pokemons().filter { it.generation == generation } + actual shouldBe expect + actual shouldNotBe repository.pokemons() + } + + @Test + fun `Speed ์ˆ˜์น˜๊ฐ€ ๋†’์€ ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ๋œ ํฌ์ผ“๋ชฌ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val sort = PokemonSort.BySpeed + // when + val actual = + repository.filteredPokemons( + sort = sort, + ) + // then + val expect = + repository.pokemons() + .sortedByDescending { it.speed } + actual shouldBe expect + actual shouldNotBe repository.pokemons() + } + + @Test + fun `ใ„นใ…ˆ ์ดˆ์„ฑ์— ํ•ด๋‹นํ•˜๊ณ  1์„ธ๋Œ€์— ํ•ด๋‹นํ•˜๋Š” ํฌ์ผ“๋ชฌ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val name = "ใ„นใ…ˆ" + val generation = PokemonGeneration.of(1) + // when + val actual = + repository.filteredPokemons( + name = name, + filters = + listOf( + PokemonFilter.ByGeneration(generation), + ), + ) + // then + val expect = + repository.pokemons() + .filter { it.name.has("ใ„นใ…ˆ") } + .filter { it.generation == generation } + actual shouldBe expect + actual shouldNotBe repository.pokemons() + } + + @Test + fun `๋ถˆ ์†์„ฑ ํƒ€์ž…๊ณผ 1์„ธ๋Œ€์— ํ•ด๋‹นํ•˜๋Š” ํฌ์ผ“๋ชฌ์„ ๊ฐ€์ ธ์˜จ๋‹ค`() = + runTest { + // given + val generation = PokemonGeneration.of(1) + // when + val actual = + repository.filteredPokemons( + filters = + listOf( + PokemonFilter.ByGeneration(generation), + PokemonFilter.ByType(Type.FIRE), + ), + ) + // then + val expect = + repository.pokemons() + .filter { it.generation == generation } + .filter { it.types.contains(Type.FIRE) } + actual shouldBe expect + actual shouldNotBe repository.pokemons() + } +} diff --git a/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeTypeRepositoryTest.kt b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeTypeRepositoryTest.kt new file mode 100644 index 00000000..c206f312 --- /dev/null +++ b/android/testing/src/test/java/poke/rogue/helper/testing/data/repository/FakeTypeRepositoryTest.kt @@ -0,0 +1,48 @@ +package poke.rogue.helper.testing.data.repository + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.junit5.KoinTestExtension +import poke.rogue.helper.data.model.Type +import poke.rogue.helper.data.repository.TypeRepository +import poke.rogue.helper.testing.di.testingModule + +class FakeTypeRepositoryTest : KoinTest { + private val repository: TypeRepository + get() = get() + + @JvmField + @RegisterExtension + val koinExtension = + KoinTestExtension.create { + modules(testingModule) + } + + @Test + fun `์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚ด Type ID๊ฐ’์œผ๋กœ ์กฐํšŒํ•˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค`() = + runTest { + assertThrows { + repository.matchedTypesAgainstMyType(-1) + } + } + + @Test + fun `์œ ํšจํ•˜์ง€ ์•Š์€ ์ƒ๋Œ€ Type ID๊ฐ’์œผ๋กœ ์กฐํšŒํ•˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค`() = + runTest { + assertThrows { + repository.matchedTypesAgainstOpponent(-1) + } + } + + @Test + fun `์œ ํšจํ•˜์ง€ ์•Š์€ Type ID๊ฐ’์ด ํ•˜๋‚˜๋ผ๋„ ํฌํ•จ๋  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค`() = + runTest { + assertThrows { + repository.matchedTypes(Type.FAIRY.id, listOf(-1)) + } + } +}