diff --git a/.github/actions/add_labels/action.yml b/.github/actions/add_labels/action.yml
new file mode 100644
index 00000000..d70663f8
--- /dev/null
+++ b/.github/actions/add_labels/action.yml
@@ -0,0 +1,50 @@
+name: Add Labels Action
+
+permissions:
+ contents: read
+ pull-requests: write
+
+runs:
+ using: 'composite'
+ steps:
+ - name: add FEAT โจ labels
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(github.event.pull_request.title, 'FEAT') }}
+ with:
+ labels: |
+ AN_FEAT โจ
+
+ - name: add UI ๐จ labels
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(github.event.pull_request.title, 'UI') }}
+ with:
+ labels: |
+ AN_UI ๐จ
+
+ - name: add REFACTOR โ๏ธ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(github.event.pull_request.title, 'REFACTOR') }}
+ with:
+ labels: |
+ AN_REFACTOR โ๏ธ
+
+ - name: add FIX ๐ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(github.event.pull_request.title, 'FIX') }}
+ with:
+ labels: |
+ AN_FIX ๐
+
+ - name: add CI/CD ๐ค label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(github.event.pull_request.title, 'CI') || contains(github.event.pull_request.title, 'CD') }}
+ with:
+ labels: |
+ AN_CI/CD ๐ค
+
+ - name: add CONFIG ๐งญ label
+ uses: actions-ecosystem/action-add-labels@v1
+ if: ${{ contains(github.event.pull_request.title, 'CONFIG') }}
+ with:
+ labels: |
+ AN_CONFIG ๐งญ
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/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_PR_AUTO_ASSIGN.yml b/.github/workflows/Android_PR_AUTO_ASSIGN.yml
new file mode 100644
index 00000000..09245b25
--- /dev/null
+++ b/.github/workflows/Android_PR_AUTO_ASSIGN.yml
@@ -0,0 +1,20 @@
+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
\ No newline at end of file
diff --git a/.github/workflows/Android_PR_Builder.yml b/.github/workflows/Android_PR_Builder.yml
index de39d226..5987a54a 100644
--- a/.github/workflows/Android_PR_Builder.yml
+++ b/.github/workflows/Android_PR_Builder.yml
@@ -3,6 +3,8 @@ name: Android PR Builder
on:
pull_request:
branches: [ an/develop ]
+ push:
+ branches: [ an/develop ]
jobs:
build:
@@ -10,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Gradle cache
uses: actions/cache@v3
@@ -28,45 +30,63 @@ jobs:
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 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
+ working-directory: android
+
+
- name: Create Local Properties
run: touch local.properties
+ working-directory: android
-# - 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: Access Local Properties
+ env:
+ POKE_BASE_URL: ${{ secrets.POKE_BASE_URL }}
+ # POKE_RELEASE_URL: ${{ secrets.HOST_RELEASE_URI }}
+ # KEYSTORE_PATH: ${{ secrets.KEYSTORE_PATH }}
+ # STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+ # KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ # KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ # STORE_FILE: ${{ secrets.STORE_FILE }}
+ 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
+ # echo STORE_FILE= $STORE_FILE >> local.properties
+ working-directory: android
-# - 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: 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
+ working-directory: android
- name: Lint Check
run: ./gradlew ktlintCheck
+ working-directory: android
- - name: run rest
- run: ./gradlew test
+ - name: run debug unit test
+ run: ./gradlew testDebugUnitTest
+ working-directory: android
- - name: Build with Gradle
- run: ./gradlew build
+ - name: Build debug APK
+ run: ./gradlew assembleDebug
+ working-directory: android
diff --git a/.github/workflows/Android_Release_CD.yml b/.github/workflows/Android_Release_CD.yml
new file mode 100644
index 00000000..e7d749c8
--- /dev/null
+++ b/.github/workflows/Android_Release_CD.yml
@@ -0,0 +1,110 @@
+name: Android PR Builder
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ build:
+ name: CD 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: 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: Extract Version Name
+ shell: bash
+ env:
+ title: ${{ inputs.title }}
+ run: |
+ version=$(echo '${{ github.event.pull_request.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: Create Github Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: ${{ env.version }}
+ release_name: ${{ env.version }}
+ generate_release_notes: true
+ files: |
+ app/build/outputs/apk/release/app-release.apk
diff --git a/.github/workflows/Android_Release_CI.yml b/.github/workflows/Android_Release_CI.yml
new file mode 100644
index 00000000..909bfea7
--- /dev/null
+++ b/.github/workflows/Android_Release_CI.yml
@@ -0,0 +1,89 @@
+name: Android PR Builder
+
+on:
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build:
+ name: CD 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: KT Lint
+ run: ./gradlew ktlintCheck
+ working-directory: android
+
+ - name: Unit Test Release
+ run: ./gradlew testReleaseUnitTest
+ working-directory: android
+
+ - name: Build Release APK
+ run: ./gradlew assembleRelease
+ 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..6ebb2e18
--- /dev/null
+++ b/android/analytics/build.gradle.kts
@@ -0,0 +1,52 @@
+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)
+}
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/AnalyticsLogger.kt b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsLogger.kt
new file mode 100644
index 00000000..f5989a82
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/AnalyticsLogger.kt
@@ -0,0 +1,38 @@
+package poke.rogue.helper.analytics
+
+private const val DEBUG_MODE = "debug"
+private const val ALPHA_MODE = "alpha"
+private const val BETA_MODE = "beta"
+private 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/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..95ba3d8c
--- /dev/null
+++ b/android/analytics/src/main/java/poke/rogue/helper/analytics/FireBaseAnalyticsLogger.kt
@@ -0,0 +1,27 @@
+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
+
+internal object FireBaseAnalyticsLogger : AnalyticsLogger {
+ override fun logEvent(event: AnalyticsEvent) {
+ 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?,
+ ) {
+ message ?: Firebase.crashlytics.log("Error: $message")
+ Firebase.crashlytics.recordException(throwable)
+ }
+}
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 373bcbe6..b726e91e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,48 +1,206 @@
+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.jks")
+ 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
+ managedDevices {
+ localDevices {
+ create("pixel4api27") {
+ device = "Pixel 4"
+ apiLevel = 27
+ systemImageSource = "aosp"
+ }
+ create("pixel4api28") {
+ device = "Pixel 4"
+ apiLevel = 28
+ systemImageSource = "aosp"
+ }
+ create("pixel4api29") {
+ device = "Pixel 4"
+ apiLevel = 29
+ systemImageSource = "aosp"
+ }
+ create("pixel4api30") {
+ device = "Pixel 4"
+ apiLevel = 30
+ systemImageSource = "aosp"
+ }
+ create("pixel4api31") {
+ device = "Pixel 4"
+ apiLevel = 31
+ systemImageSource = "aosp"
+ }
+ create("pixel4api32") {
+ device = "Pixel 4"
+ apiLevel = 32
+ systemImageSource = "aosp"
+ }
+ create("pixel4api33") {
+ device = "Pixel 4"
+ apiLevel = 33
+ systemImageSource = "aosp"
+ }
+ create("pixel4api34") {
+ device = "Pixel 4"
+ apiLevel = 34
+ systemImageSource = "aosp"
+ }
+ }
+ groups {
+ create("phones") {
+ targetDevices.add(devices["pixel4api27"])
+ targetDevices.add(devices["pixel4api28"])
+ targetDevices.add(devices["pixel4api29"])
+ targetDevices.add(devices["pixel4api30"])
+ targetDevices.add(devices["pixel4api31"])
+ targetDevices.add(devices["pixel4api32"])
+ targetDevices.add(devices["pixel4api33"])
+ targetDevices.add(devices["pixel4api34"])
+ }
+ }
+ }
}
}
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)
+ // android test
+ androidTestImplementation(libs.bundles.android.test)
+ androidTestRuntimeOnly(libs.junit5.android.test.runner)
+}
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..287d6799
--- /dev/null
+++ b/android/app/src/androidTest/java/poke/rogue/helper/presentation/home/HomeActivityTest.kt
@@ -0,0 +1,71 @@
+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()))
+ }
+
+ @Test
+ @DisplayName("ํ๋ฉด ํ์ ์์๋ ๊ฟํ ๋ฉ๋ด ๋ฒํผ์ด ๋ณด์ธ๋ค")
+ fun test4() {
+ // when
+ activityRule.scenario.onActivity { activity ->
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ onIdle()
+
+ // then
+ onView(withId(R.id.cv_home_land_tip))
+ .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..334a46ce 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,25 +2,70 @@
+
+
+
+ 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..c4d644a6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/PokeRogueHelperApp.kt
@@ -0,0 +1,57 @@
+package poke.rogue.helper
+
+import android.app.Application
+import com.google.firebase.analytics.ktx.analytics
+import com.google.firebase.crashlytics.ktx.crashlytics
+import com.google.firebase.ktx.Firebase
+import poke.rogue.helper.data.repository.DefaultDexRepository
+import timber.log.Timber
+
+class PokeRogueHelperApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ initTimber()
+ initFirebase()
+ DefaultDexRepository.init(this)
+ }
+
+ private fun initTimber() {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(
+ object : Timber.DebugTree() {
+ override fun createStackElementTag(element: StackTraceElement): String {
+ return "${element.fileName} : ${element.lineNumber} - ${element.methodName}"
+ }
+ },
+ )
+ }
+ }
+
+ private fun initFirebase() {
+ 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")
+ }
+ }
+
+ companion object {
+ private const val DEBUG_MODE = "debug"
+ private const val ALPHA_MODE = "alpha"
+ private const val BETA_MODE = "beta"
+ private const val RELEASE_MODE = "release"
+ }
+}
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..0916d9b5
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/AbilityFragment.kt
@@ -0,0 +1,99 @@
+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 androidx.fragment.app.viewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultAbilityRepository
+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 viewModels {
+ AbilityViewModel.factory(
+ DefaultAbilityRepository.instance(),
+ )
+ }
+ 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..33c66ea2
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/ability/detail/AbilityDetailFragment.kt
@@ -0,0 +1,116 @@
+package poke.rogue.helper.presentation.ability.detail
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.viewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultAbilityRepository
+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 viewModels {
+ AbilityDetailViewModel.factory(
+ DefaultAbilityRepository.instance(),
+ )
+ }
+
+ 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..ad9ffabc
--- /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 poke.rogue.helper.R
+import poke.rogue.helper.analytics.AnalyticsLogger
+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?
+ private val logger: AnalyticsLogger = analyticsLogger()
+
+ 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/BattleActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleActivity.kt
new file mode 100644
index 00000000..36db5ba7
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleActivity.kt
@@ -0,0 +1,137 @@
+package poke.rogue.helper.presentation.battle
+
+import WeatherSpinnerAdapter
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.widget.AdapterView
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultBattleRepository
+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.util.parcelable
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.setImage
+
+class BattleActivity : ToolbarActivity(R.layout.activity_battle) {
+ private val viewModel by viewModels {
+ BattleViewModel.factory(DefaultBattleRepository.instance())
+ }
+ private val weatherAdapter by lazy {
+ WeatherSpinnerAdapter(this)
+ }
+ 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 =
+ object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(
+ parent: AdapterView<*>,
+ view: View?,
+ position: Int,
+ id: Long,
+ ) {
+ val selectedWeather = parent.getItemAtPosition(position) as WeatherUiModel
+ viewModel.updateWeather(selectedWeather)
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {
+ }
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ viewModel.weathers.collect {
+ weatherAdapter.updateWeathers(it)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.selectedState.collect {
+ if (it.minePokemon is BattleSelectionUiState.Selected) {
+ val selected = it.minePokemon.selected
+ binding.ivMinePokemon.setImage(selected.backImageUrl)
+ binding.tvMinePokemon.text = selected.name
+ }
+
+ if (it.skill is BattleSelectionUiState.Selected) {
+ binding.tvSkillTitle.text = it.skill.selected.name
+ }
+
+ if (it.opponentPokemon is BattleSelectionUiState.Selected) {
+ val selected = it.opponentPokemon.selected
+ binding.ivOpponentPokemon.setImage(selected.frontImageUrl)
+ binding.tvOpponentPokemon.text = selected.name
+ }
+
+ if (it.weather is BattleSelectionUiState.Selected) {
+ val selected = it.weather.selected
+ binding.tvWeatherDescription.text = selected.effect
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.navigateToSelection.collect { previousSelection ->
+ val intent =
+ BattleSelectionActivity.intent(
+ this@BattleActivity,
+ previousSelection,
+ )
+ startActivityForResult(intent, REQUEST_CODE)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.battleResult.collect {
+ if (it is BattleResultUiState.Success) {
+ val result = it.result
+ binding.tvPowerContent.text = result.power
+ binding.tvMultiplierContent.text = result.multiplier
+ binding.tvCalculatedPowerContent.text = result.calculatedResult
+ binding.tvAccuracyContent.text = getString(R.string.battle_accuracy_title, result.accuracy)
+ }
+ }
+ }
+ }
+
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ data: Intent?,
+ ) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode != REQUEST_CODE) return
+ if (resultCode == RESULT_OK) {
+ val result =
+ data?.parcelable(BattleSelectionActivity.KEY_SELECTION_RESULT)
+ if (result != null) {
+ viewModel.updatePokemonSelection(result)
+ }
+ }
+ }
+
+ companion object {
+ private const val REQUEST_CODE = 1
+ }
+}
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..e4e07e1a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleNavigationHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.battle
+
+interface BattleNavigationHandler {
+ fun navigateToSelection(hasSkillSelection: Boolean)
+}
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..2f50b1e7
--- /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 selected: T) : BattleSelectionUiState
+
+ data object Empty : BattleSelectionUiState
+}
+
+fun BattleSelectionUiState.isSelected(): Boolean = this is BattleSelectionUiState.Selected
+
+fun BattleSelectionUiState.selectedData(): T? = (this as? BattleSelectionUiState.Selected)?.selected
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..a59fc901
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleSelectionsState.kt
@@ -0,0 +1,26 @@
+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() =
+ minePokemon.isSelected() && skill.isSelected() && 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..486cdf74
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/BattleViewModel.kt
@@ -0,0 +1,155 @@
+package poke.rogue.helper.presentation.battle
+
+import androidx.lifecycle.ViewModelProvider
+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.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.presentation.base.BaseViewModelFactory
+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.SkillSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.WeatherUiModel
+import poke.rogue.helper.presentation.battle.model.toUi
+
+class BattleViewModel(
+ private val battleRepository: BattleRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), BattleNavigationHandler {
+ private val _weathers = MutableStateFlow(emptyList())
+ val weathers = _weathers.asStateFlow()
+
+ private val _selectedState = MutableStateFlow(BattleSelectionsState.DEFAULT)
+ val selectedState = _selectedState.asStateFlow()
+
+ private val _navigateToSelection = MutableSharedFlow()
+ val navigateToSelection = _navigateToSelection.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()
+ }
+
+ private suspend fun fetchBattlePredictionResult(): BattlePredictionUiModel {
+ with(selectedState.value) {
+ val weatherId = weather.selectedData()?.id
+ val myPokemonId = minePokemon.selectedData()?.id
+ val mySkillId = skill.selectedData()?.id
+ val opponentPokemonId = opponentPokemon.selectedData()?.id
+ 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
+ }
+ }
+
+ fun updateWeather(newWeather: WeatherUiModel) {
+ viewModelScope.launch {
+ val selectedWeather = BattleSelectionUiState.Selected(newWeather)
+ _selectedState.value = selectedState.value.copy(weather = selectedWeather)
+ }
+ }
+
+ fun updatePokemonSelection(selection: SelectionData) {
+ when (selection) {
+ is SelectionData.WithSkill ->
+ updateMyPokemon(
+ selection.selectedPokemon,
+ selection.selectedSkill,
+ )
+
+ is SelectionData.WithoutSkill -> updateOpponentPokemon(selection.selectedPokemon)
+ is SelectionData.NoSelection -> {}
+ }
+ }
+
+ private fun updateMyPokemon(
+ pokemon: PokemonSelectionUiModel,
+ skill: SkillSelectionUiModel,
+ ) {
+ viewModelScope.launch {
+ val selectedPokemon = BattleSelectionUiState.Selected(pokemon)
+ val selectedSkill = BattleSelectionUiState.Selected(skill)
+ _selectedState.value =
+ selectedState.value.copy(minePokemon = selectedPokemon, skill = selectedSkill)
+ }
+ }
+
+ private fun updateOpponentPokemon(pokemon: PokemonSelectionUiModel) {
+ viewModelScope.launch {
+ val selectedPokemon = BattleSelectionUiState.Selected(pokemon)
+ _selectedState.value = selectedState.value.copy(opponentPokemon = selectedPokemon)
+ }
+ }
+
+ override fun navigateToSelection(hasSkillSelection: Boolean) {
+ viewModelScope.launch {
+ val previousPokemonSelection =
+ if (hasSkillSelection) {
+ selectedState.value.minePokemon.selectedData()
+ } else {
+ selectedState.value.opponentPokemon.selectedData()
+ }
+ val previousSelection =
+ if (previousPokemonSelection != null) {
+ previousSelection(hasSkillSelection, previousPokemonSelection)
+ } else {
+ SelectionData.NoSelection(hasSkillSelection)
+ }
+ _navigateToSelection.emit(previousSelection)
+ }
+ }
+
+ private fun previousSelection(
+ hasSkillSelection: Boolean,
+ previousPokemonSelection: PokemonSelectionUiModel,
+ ): SelectionData {
+ return if (hasSkillSelection) {
+ val skill =
+ selectedState.value.skill.selectedData()
+ ?: throw IllegalStateException("์คํฌ์ด ์ ํ๋์ด์ผ ํฉ๋๋ค.")
+ SelectionData.WithSkill(previousPokemonSelection, skill)
+ } else {
+ SelectionData.WithoutSkill(previousPokemonSelection)
+ }
+ }
+
+ companion object {
+ fun factory(battleRepository: BattleRepository): ViewModelProvider.Factory =
+ BaseViewModelFactory { BattleViewModel(battleRepository) }
+ }
+}
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..6ffa3dcc
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/BattlePredictionUiModel.kt
@@ -0,0 +1,13 @@
+package poke.rogue.helper.presentation.battle.model
+
+import poke.rogue.helper.data.model.BattlePrediction
+
+data class BattlePredictionUiModel(val power: String, val accuracy: String, val multiplier: String, val calculatedResult: String)
+
+fun BattlePrediction.toUi(format: String = "%.1f"): BattlePredictionUiModel =
+ BattlePredictionUiModel(
+ power = power.toString(),
+ accuracy = String.format(format, accuracy),
+ multiplier = String.format(format, multiplier),
+ calculatedResult = String.format(format, calculatedResult),
+ )
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..e8fc6a67
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SelectionData.kt
@@ -0,0 +1,39 @@
+package poke.rogue.helper.presentation.battle.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+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 class NoSelection(val isSkillSelectionRequired: Boolean) : SelectionData()
+}
+
+fun SelectionData.isSkillSelectionRequired(): Boolean =
+ this is SelectionData.WithSkill || (this as? SelectionData.NoSelection)?.isSkillSelectionRequired == true
+
+fun SelectionData.selectedPokemonOrNull(): PokemonSelectionUiModel? {
+ return when (this) {
+ is SelectionData.NoSelection -> null
+ is SelectionData.WithSkill -> this.selectedPokemon
+ is SelectionData.WithoutSkill -> this.selectedPokemon
+ }
+}
+
+fun SelectionData.selectedSkillOrNull(): 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/SkillSelectionUiModel.kt b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SkillSelectionUiModel.kt
new file mode 100644
index 00000000..ce903a5a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/model/SkillSelectionUiModel.kt
@@ -0,0 +1,27 @@
+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.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 {
+ companion object {
+ val DUMMY = listOf()
+ }
+}
+
+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..46ad0f42
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionActivity.kt
@@ -0,0 +1,91 @@
+package poke.rogue.helper.presentation.battle.selection
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+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.util.parcelable
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.setImage
+
+class BattleSelectionActivity :
+ ErrorHandleActivity(R.layout.activity_battle_selection) {
+ private val viewModel by viewModels {
+ BattleSelectionViewModel.factory(previousSelection)
+ }
+ private val previousSelection by lazy {
+ intent.parcelable(KEY_PREVIOUS_SELECTION) ?: throw IllegalArgumentException("์๋ชป๋ ์ ํ ๋ฐ์ดํฐ")
+ }
+ 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()
+ initObserver()
+ }
+
+ private fun initViews() {
+ binding.lifecycleOwner = this
+ binding.vm = viewModel
+ binding.pagerBattleSelection.adapter = selectionPagerAdapter
+ binding.pagerBattleSelection.isUserInputEnabled = false
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ viewModel.selectedPokemon.collect { selectionState ->
+ if (selectionState is BattleSelectionUiState.Selected) {
+ val selected = selectionState.selected
+ 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 {
+ private const val KEY_PREVIOUS_SELECTION = "previousSelection"
+ const val KEY_SELECTION_RESULT = "selectionResult"
+
+ fun intent(
+ context: Context,
+ previousSelection: SelectionData,
+ ): Intent =
+ Intent(context, BattleSelectionActivity::class.java).apply {
+ 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..ea6e372a
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/BattleSelectionViewModel.kt
@@ -0,0 +1,139 @@
+package poke.rogue.helper.presentation.battle.selection
+
+import androidx.lifecycle.ViewModelProvider
+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.BaseViewModelFactory
+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.SkillSelectionUiModel
+import poke.rogue.helper.presentation.battle.model.isSkillSelectionRequired
+import poke.rogue.helper.presentation.battle.model.selectedPokemonOrNull
+import poke.rogue.helper.presentation.battle.model.selectedSkillOrNull
+import poke.rogue.helper.presentation.battle.selectedData
+
+class BattleSelectionViewModel(
+ val previousSelection: SelectionData,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), NavigationHandler {
+ private val _selectedPokemon: MutableStateFlow>
+ val selectedPokemon: StateFlow>
+
+ private val _selectedSkill: MutableStateFlow>
+ val selectedSkill: StateFlow>
+
+ private val _currentStep = MutableStateFlow(SelectionStep.POKEMON_SELECTION)
+ val currentStep: StateFlow = _currentStep.asStateFlow()
+
+ val isLastStep: StateFlow =
+ currentStep.map {
+ it.isLastStep(previousSelection.isSkillSelectionRequired())
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ private val _completeSelection = MutableSharedFlow()
+ val completeSelection = _completeSelection.asSharedFlow()
+
+ init {
+ _selectedPokemon = MutableStateFlow(initializeSelectedPokemon())
+ _selectedSkill = MutableStateFlow(initializeSelectedSkill())
+
+ selectedPokemon = _selectedPokemon.asStateFlow()
+ selectedSkill = _selectedSkill.asStateFlow()
+ }
+
+ 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.selectedPokemonOrNull()
+ return if (selectedPokemon != null) {
+ BattleSelectionUiState.Selected(selectedPokemon)
+ } else {
+ BattleSelectionUiState.Empty
+ }
+ }
+
+ private fun initializeSelectedSkill(): BattleSelectionUiState {
+ val selectedSkill = previousSelection.selectedSkillOrNull()
+ return if (selectedSkill != null) {
+ BattleSelectionUiState.Selected(selectedSkill)
+ } else {
+ BattleSelectionUiState.Empty
+ }
+ }
+
+ 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)
+ if (nextPage != null) {
+ _currentStep.value = nextPage
+ }
+ }
+
+ private fun handleSelectionResult() {
+ val pokemon =
+ selectedPokemon.value.selectedData() ?: throw IllegalStateException("ํฌ์ผ๋ชฌ์ ์ ํํ์ธ์")
+ val result =
+ if (previousSelection.isSkillSelectionRequired()) {
+ val skill =
+ selectedSkill.value.selectedData() ?: throw IllegalStateException("์คํฌ์ ์ ํํ์ธ์")
+ 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
+ }
+ }
+
+ companion object {
+ fun factory(previousSelection: SelectionData): ViewModelProvider.Factory =
+ BaseViewModelFactory { BattleSelectionViewModel(previousSelection) }
+ }
+}
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..7d1a2f9d
--- /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 hasPreviousStep(): 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/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..3cca8686
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionFragment.kt
@@ -0,0 +1,71 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultDexRepository
+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.selectedPokemonOrNull
+import poke.rogue.helper.presentation.battle.selection.BattleSelectionViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonSelectionFragment :
+ ErrorHandleFragment(R.layout.fragment_pokemon_selection) {
+ private val sharedViewModel: BattleSelectionViewModel by activityViewModels()
+ private val viewModel: PokemonSelectionViewModel by viewModels {
+ PokemonSelectionViewModel.factory(
+ DefaultDexRepository.instance(),
+ sharedViewModel.previousSelection.selectedPokemonOrNull(),
+ )
+ }
+ 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()
+ initObserver()
+ }
+
+ private fun initViews() {
+ binding.handler = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+
+ with(binding.rvPokemons) {
+ adapter = pokemonAdapter
+ addItemDecoration(
+ LinearSpacingItemDecoration(spacing = 4.dp, false),
+ )
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ viewModel.filteredPokemon.collect {
+ pokemonAdapter.submitList(it)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.pokemonSelectedEvent.collect {
+ 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..506ab1e6
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewHolder.kt
@@ -0,0 +1,28 @@
+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.biome.BiomeTypesAdapter
+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
+
+ val typeAdapter = BiomeTypesAdapter(context = binding.root.context, binding.flexboxTypes)
+ typeAdapter.addTypes(
+ 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..7f607f5b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/pokemon/PokemonSelectionViewModel.kt
@@ -0,0 +1,92 @@
+package poke.rogue.helper.presentation.battle.selection.pokemon
+
+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.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.BaseViewModelFactory
+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)
+ }
+ }
+
+ companion object {
+ fun factory(
+ dexRepository: DexRepository,
+ previousSelection: PokemonSelectionUiModel?,
+ ): ViewModelProvider.Factory = BaseViewModelFactory { PokemonSelectionViewModel(dexRepository, previousSelection) }
+ }
+}
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..6a7ef0ba
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionFragment.kt
@@ -0,0 +1,85 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import android.os.Bundle
+import android.view.View
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultBattleRepository
+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.model.selectedPokemonOrNull
+import poke.rogue.helper.presentation.battle.selectedData
+import poke.rogue.helper.presentation.battle.selection.BattleSelectionViewModel
+import poke.rogue.helper.presentation.util.repeatOnStarted
+import poke.rogue.helper.presentation.util.view.LinearSpacingItemDecoration
+import poke.rogue.helper.presentation.util.view.dp
+
+class SkillSelectionFragment :
+ ErrorHandleFragment(R.layout.fragment_skill_selection) {
+ private val sharedViewModel: BattleSelectionViewModel by activityViewModels()
+ private val viewModel: SkillSelectionViewModel by viewModels {
+ SkillSelectionViewModel.factory(
+ DefaultBattleRepository.instance(),
+ 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()
+ initObserver()
+ }
+
+ private fun initViews() {
+ with(binding.rvSkills) {
+ adapter = skillAdapter
+ addItemDecoration(
+ LinearSpacingItemDecoration(spacing = 4.dp, false),
+ )
+ }
+ binding.handler = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ sharedViewModel.selectedPokemon.collect {
+ val dexNumber = it.selectedData()?.dexNumber
+ val selectedPokemonId = it.selectedData()?.id
+ val previousSelectedPokemonId =
+ sharedViewModel.previousSelection.selectedPokemonOrNull()?.id
+
+ if (dexNumber != null && previousSelectedPokemonId != selectedPokemonId) {
+ viewModel.updateSkills(dexNumber)
+ }
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.filteredSkills.collect {
+ skillAdapter.submitList(it)
+ }
+ }
+
+ repeatOnStarted {
+ viewModel.skillSelectedEvent.collect {
+ 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..58181859
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/battle/selection/skill/SkillSelectionViewModel.kt
@@ -0,0 +1,106 @@
+package poke.rogue.helper.presentation.battle.selection.skill
+
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+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.BattleRepository
+import poke.rogue.helper.presentation.base.BaseViewModelFactory
+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.toSelectableModelsBy
+import poke.rogue.helper.presentation.dex.filter.toSelectableModelsWithAllDeselected
+import poke.rogue.helper.stringmatcher.has
+
+class SkillSelectionViewModel(
+ private val battleRepository: BattleRepository,
+ previousSelection: SelectionData.WithSkill?,
+ logger: AnalyticsLogger = analyticsLogger(),
+) : ErrorHandleViewModel(logger), SkillSelectionHandler, QueryHandler {
+ 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.map { skillsList ->
+ if (query.isBlank()) {
+ skillsList
+ } else {
+ skillsList.filter { it.data.name.has(query) }
+ }
+ }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), skills.value)
+
+ init {
+ if (previousSelection != null) {
+ viewModelScope.launch(errorHandler) {
+ val availableSkills =
+ battleRepository.availableSkills(previousSelection.selectedPokemon.dexNumber)
+ .map { it.toUi() }
+ _skills.value =
+ availableSkills.toSelectableModelsBy { it.id == previousSelection.selectedSkill.id }
+ }
+ }
+ }
+
+ 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)
+ }
+ }
+
+ fun updateSkills(pokemonDexNumber: Long) {
+ viewModelScope.launch(errorHandler) {
+ _skills.value = emptyList()
+ val availableSkills =
+ battleRepository.availableSkills(pokemonDexNumber).map { it.toUi() }
+ delay(50)
+ _skills.value = availableSkills.toSelectableModelsWithAllDeselected()
+ }
+ }
+
+ override fun queryName(name: String) {
+ viewModelScope.launch {
+ searchQuery.emit(name)
+ }
+ }
+
+ companion object {
+ fun factory(
+ battleRepository: BattleRepository,
+ previousSelection: SelectionData.WithSkill?,
+ ): ViewModelProvider.Factory = BaseViewModelFactory { SkillSelectionViewModel(battleRepository, previousSelection) }
+ }
+}
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..4d7b3670
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeActivity.kt
@@ -0,0 +1,90 @@
+package poke.rogue.helper.presentation.biome
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+import androidx.core.view.isVisible
+import poke.rogue.helper.R
+import poke.rogue.helper.analytics.AnalyticsLogger
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.DefaultBiomeRepository
+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.model.toUi
+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 logger: AnalyticsLogger = analyticsLogger()
+ private val viewModel by viewModels {
+ BiomeViewModel.factory(
+ DefaultBiomeRepository.instance(),
+ )
+ }
+ 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)
+
+ initView()
+ initAdapter()
+ initObservers()
+ }
+
+ private fun initView() {
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ }
+
+ private fun initAdapter() {
+ binding.rvBiomeList.apply {
+ adapter = biomeAdapter
+ addItemDecoration(
+ GridSpacingItemDecoration(
+ 2,
+ 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.biome.collect { biome ->
+ when (biome) {
+ is BiomeUiState.Loading -> {
+ binding.biomeLoading.isVisible = true
+ }
+
+ is BiomeUiState.Success -> {
+ biomeAdapter.submitList(biome.data.toUi())
+ binding.biomeLoading.isVisible = false
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val NAVIGATE_TO_BIOME_DETAIL = "Nav_Biome_Detail"
+ }
+}
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/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..40cf57b9
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeUiEventHandler.kt
@@ -0,0 +1,5 @@
+package poke.rogue.helper.presentation.biome
+
+interface BiomeUiEventHandler {
+ fun navigateToDetail(biomeId: String)
+}
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..7e084f98
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/BiomeViewModel.kt
@@ -0,0 +1,61 @@
+package poke.rogue.helper.presentation.biome
+
+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.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.launch
+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.BaseViewModelFactory
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+
+class BiomeViewModel(
+ private val biomeRepository: BiomeRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) :
+ ErrorHandleViewModel(logger),
+ BiomeUiEventHandler {
+ private val _biome = MutableStateFlow>>(BiomeUiState.Loading)
+ val biome = _biome.asStateFlow()
+
+ private val _navigationToDetailEvent = MutableSharedFlow()
+ val navigationToDetailEvent: SharedFlow = _navigationToDetailEvent.asSharedFlow()
+
+ init {
+ refreshEvent
+ .onStart {
+ emit(Unit)
+ }.onEach {
+ updateBiomes()
+ }.launchIn(viewModelScope)
+ }
+
+ private fun updateBiomes() {
+ viewModelScope.launch(errorHandler) {
+ val biomes = biomeRepository.biomes()
+ _biome.value = BiomeUiState.Success(biomes)
+ }
+ }
+
+ override fun navigateToDetail(biomeId: String) {
+ viewModelScope.launch {
+ _navigationToDetailEvent.emit(biomeId)
+ }
+ }
+
+ companion object {
+ fun factory(biomeRepository: BiomeRepository): ViewModelProvider.Factory =
+ BaseViewModelFactory {
+ BiomeViewModel(biomeRepository)
+ }
+ }
+}
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..86033115
--- /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.id == newItem.id },
+ 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..96aafdd2
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailActivity.kt
@@ -0,0 +1,91 @@
+package poke.rogue.helper.presentation.biome.detail
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+import com.google.android.material.tabs.TabLayoutMediator
+import poke.rogue.helper.R
+import poke.rogue.helper.analytics.analyticsLogger
+import poke.rogue.helper.data.repository.DefaultBiomeRepository
+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.dex.detail.PokemonDetailActivity
+import poke.rogue.helper.presentation.util.context.startActivity
+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: BiomeDetailViewModel by viewModels {
+ BiomeDetailViewModel.factory(
+ DefaultBiomeRepository.instance(),
+ analyticsLogger(),
+ )
+ }
+ override val errorViewModel: ErrorHandleViewModel
+ get() = viewModel
+ override val toolbar: Toolbar
+ get() = binding.toolbarBiomeDetail
+
+ 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()
+ }
+
+ 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(BiomeDetailActivity.intent(this@BiomeDetailActivity, biomeId))
+ analyticsLogger().logClickEvent(NAVIGATE_TO_NEXT_BIOME_DETAIL)
+ }
+ }
+ is BiomeDetailUiEvent.NavigateToPokemonDetail -> {
+ val pokemonId = event.pokemonId
+ startActivity {
+ putExtras(PokemonDetailActivity.intent(this@BiomeDetailActivity, pokemonId))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val BIOME_ID = "biomeId"
+ private const val NAVIGATE_TO_NEXT_BIOME_DETAIL = "Nav_Next_Biome_Detail"
+
+ 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..b75f4aae
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/BiomeDetailViewModel.kt
@@ -0,0 +1,98 @@
+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.combine
+import kotlinx.coroutines.flow.filter
+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.BaseViewModelFactory
+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)
+
+ // 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,
+ )
+
+ fun init(id: String) {
+ if (id.isBlank()) return handlePokemonError(IllegalArgumentException("biomeId is blank"))
+ biomeId.value = id
+ }
+
+ override fun navigateToBiomeDetail(id: String) {
+ viewModelScope.launch {
+ _uiEvent.emit(BiomeDetailUiEvent.NavigateToNextBiomeDetail(id))
+ }
+ }
+
+ override fun navigateToPokemonDetail(id: String) {
+ viewModelScope.launch {
+ _uiEvent.emit(BiomeDetailUiEvent.NavigateToPokemonDetail(id))
+ }
+ }
+
+ companion object {
+ private const val IDLE_ID = "IDLE"
+
+ fun factory(
+ biomeRepository: BiomeRepository,
+ analytics: AnalyticsLogger,
+ ) = BaseViewModelFactory {
+ BiomeDetailViewModel(biomeRepository, analytics)
+ }
+ }
+}
+
+sealed interface BiomeDetailUiEvent {
+ data class NavigateToNextBiomeDetail(val biomeId: String) : BiomeDetailUiEvent
+
+ data class NavigateToPokemonDetail(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..55bad61c
--- /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 == newItem },
+ 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..be99b3e7
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/boss/BiomeBossFragment.kt
@@ -0,0 +1,43 @@
+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
+
+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 ->
+ 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..5a80e061
--- /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 == newItem },
+ 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..e282efca
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/gym/BiomeGymFragment.kt
@@ -0,0 +1,43 @@
+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
+
+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 ->
+ 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..83ab3cae
--- /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 == newItem },
+ 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..81bfc97e
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/detail/wild/BiomeWildViewHolder.kt
@@ -0,0 +1,26 @@
+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) }
+
+ fun bind(wildPokemon: BiomePokemonUiModel) {
+ binding.biomePokemon = wildPokemon
+
+ val decoration = GridSpacingItemDecoration(3, 18.dp, false)
+ binding.rvBiomeWildPokemon.addItemDecoration(decoration)
+ wildPokemon.pokemons.let(pokemonAdapter::submitList)
+ binding.rvBiomeWildPokemon.adapter = pokemonAdapter
+ }
+}
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..6bea7991
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/biome/model/BiomePokemonUiModel.kt
@@ -0,0 +1,49 @@
+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.map(BiomePokemon::toPokemonUiModel),
+ )
+
+fun BossPokemon.toUi(): BiomePokemonUiModel =
+ BiomePokemonUiModel(
+ grade = tier,
+ gymLeaderUrl = null,
+ type = null,
+ pokemons = pokemons.map(BiomePokemon::toPokemonUiModel),
+ )
+
+fun GymPokemon.toUi(): BiomePokemonUiModel =
+ BiomePokemonUiModel(
+ grade = gymLeaderName,
+ gymLeaderUrl = gymLeaderImage,
+ type = gymLeaderTypeLogos.firstOrNull()?.toUi(),
+ pokemons = pokemons.map(BiomePokemon::toPokemonUiModel),
+ )
+
+fun BiomePokemon.toPokemonUiModel(): PokemonUiModel =
+ PokemonUiModel(
+ id = id,
+ 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/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..502fa60c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListActivity.kt
@@ -0,0 +1,150 @@
+package poke.rogue.helper.presentation.dex
+
+import android.content.res.Configuration
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.FragmentManager
+import androidx.recyclerview.widget.GridLayoutManager
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultDexRepository
+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 viewModels {
+ PokemonListViewModel.factory(
+ DefaultDexRepository.instance(),
+ )
+ }
+ 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.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))
+ }
+ }
+ val fm: FragmentManager = supportFragmentManager
+
+ fm.setFragmentResultListener(FILTER_RESULT_KEY, this) { key, bundle ->
+ val filterArgs: PokeFilterUiModel =
+ PokemonFilterBottomSheetFragment.argsFrom(bundle)
+ ?: return@setFragmentResultListener
+ viewModel.filterPokemon(filterArgs)
+ }
+ fm.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..ef527289
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/PokemonListViewModel.kt
@@ -0,0 +1,171 @@
+package poke.rogue.helper.presentation.dex
+
+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.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.BaseViewModelFactory
+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 pokemonListRepository: 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) }
+ pokemonListRepository.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
+ }
+ }
+
+ fun sortPokemon(sort: PokemonSortUiModel) {
+ viewModelScope.launch {
+ pokeSort.value = sort
+ }
+ }
+
+ companion object {
+ fun factory(pokemonListRepository: DexRepository): ViewModelProvider.Factory =
+ BaseViewModelFactory {
+ PokemonListViewModel(pokemonListRepository)
+ }
+ }
+}
+
+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..6d78a79d
--- /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
+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 =
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.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/PokemonDetailActivity.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt
new file mode 100644
index 00000000..d4ee468b
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailActivity.kt
@@ -0,0 +1,163 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.LinearLayout.LayoutParams
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+import com.google.android.material.tabs.TabLayoutMediator
+import poke.rogue.helper.R
+import poke.rogue.helper.data.repository.DefaultDexRepository
+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.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.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 viewModels {
+ PokemonDetailViewModel.factory(DefaultDexRepository.instance())
+ }
+
+ private lateinit var pokemonTypesAdapter: PokemonTypesAdapter
+ private lateinit var pokemonDetailPagerAdapter: PokemonDetailPagerAdapter
+
+ override val toolbar: Toolbar
+ get() = binding.toolbarPokemonDetail
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.updatePokemonDetail(intent.getStringExtra(POKEMON_ID).toString())
+
+ binding.eventHandler = viewModel
+ binding.lifecycleOwner = this
+
+ initAdapter()
+ initObservers()
+ }
+
+ 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()
+ }
+
+ 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 bindPokemonDetail(pokemonDetail: PokemonDetailUiState.Success) {
+ with(binding) {
+ ivPokemonDetailPokemon.loadImageWithProgress(
+ pokemonDetail.pokemon.imageUrl,
+ progressIndicatorPokemonDetail,
+ )
+
+ tvPokemonDetailPokemonName.text =
+ stringOf(
+ R.string.pokemon_list_poke_name_format,
+ pokemonDetail.pokemon.name,
+ pokemonDetail.pokemon.dexNumber,
+ )
+ }
+
+ pokemonTypesAdapter.addTypes(
+ types = pokemonDetail.pokemon.types,
+ config = typesUiConfig,
+ spacingBetweenTypes = 0.dp,
+ )
+ }
+
+ companion object {
+ private const val POKEMON_ID = "pokemonId"
+ val TAG: String = PokemonDetailActivity::class.java.simpleName
+
+ private val typesUiConfig =
+ TypeChip.PokemonTypeViewConfiguration(
+ width = LayoutParams.WRAP_CONTENT,
+ nameSize = 16.dp,
+ iconSize = 20.dp,
+ hasBackGround = false,
+ )
+
+ 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..48bd17bd
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailNavigateHandler.kt
@@ -0,0 +1,11 @@
+package poke.rogue.helper.presentation.dex.detail
+
+interface PokemonDetailNavigateHandler {
+ fun navigateToAbilityDetail(abilityId: String)
+
+ fun navigateToBiomeDetail(biomeId: String)
+
+ fun navigateToHome()
+
+ fun navigateToPokemonDetail(pokemonId: String)
+}
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..72b28857
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailUiState.kt
@@ -0,0 +1,39 @@
+package poke.rogue.helper.presentation.dex.detail
+
+import poke.rogue.helper.data.model.PokemonBiome
+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.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(): 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,
+ )
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..8bbe2256
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/PokemonDetailViewModel.kt
@@ -0,0 +1,82 @@
+package poke.rogue.helper.presentation.dex.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.DexRepository
+import poke.rogue.helper.presentation.base.BaseViewModelFactory
+import poke.rogue.helper.presentation.base.error.ErrorHandleViewModel
+
+class PokemonDetailViewModel(
+ private val dexRepository: DexRepository,
+ logger: AnalyticsLogger = analyticsLogger(),
+) :
+ ErrorHandleViewModel(logger),
+ PokemonDetailNavigateHandler {
+ private val _uiState: MutableStateFlow =
+ MutableStateFlow(PokemonDetailUiState.IsLoading)
+ val uiState = _uiState.asStateFlow()
+
+ val isEmpty: StateFlow =
+ uiState.map { it is PokemonDetailUiState.IsLoading }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), true)
+
+ private val _navigationToAbilityDetailEvent = MutableSharedFlow()
+ val navigationToAbilityDetailEvent: SharedFlow = _navigationToAbilityDetailEvent.asSharedFlow()
+
+ private val _navigationToBiomeDetailEvent = MutableSharedFlow()
+ val navigationToBiomeDetailEvent: SharedFlow = _navigationToBiomeDetailEvent.asSharedFlow()
+
+ private val _navigateToHomeEvent = MutableSharedFlow()
+ val navigateToHomeEvent = _navigateToHomeEvent.asSharedFlow()
+
+ private val _navigateToPokemonDetailEvent = MutableSharedFlow()
+ val navigateToPokemonDetailEvent = _navigateToPokemonDetailEvent.asSharedFlow()
+
+ 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)
+ }
+ }
+
+ companion object {
+ fun factory(dexRepository: DexRepository): ViewModelProvider.Factory =
+ BaseViewModelFactory { PokemonDetailViewModel(dexRepository) }
+ }
+}
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/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..13fe2065
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/evolution/PokemonEvolutionFragment.kt
@@ -0,0 +1,63 @@
+package poke.rogue.helper.presentation.dex.detail.evolution
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+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 activityViewModels()
+
+ private val evolutionDepth0Adapter by lazy { EvolutionAdapter(activityViewModel) }
+ private val evolutionDepth1Adapter by lazy { EvolutionAdapter(activityViewModel) }
+ private val evolutionDepth2Adapter by lazy { EvolutionAdapter(activityViewModel) }
+ private val evolutionDepth3Adapter by lazy { EvolutionAdapter(activityViewModel) }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initObserver()
+ }
+
+ private fun initAdapter() {
+ binding.apply {
+ rvPokemonEvolutionDepth0.recyclerView.adapter = evolutionDepth0Adapter
+ rvPokemonEvolutionDepth1.recyclerView.adapter = evolutionDepth1Adapter
+ rvPokemonEvolutionDepth2.recyclerView.adapter = evolutionDepth2Adapter
+ rvPokemonEvolutionDepth3.recyclerView.adapter = evolutionDepth3Adapter
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ activityViewModel.uiState.collect { uiState ->
+ when (uiState) {
+ is PokemonDetailUiState.IsLoading -> {}
+ is PokemonDetailUiState.Success -> {
+ binding.evolutions = uiState.evolutions
+ uiState.evolutions.apply {
+ evolutions(depth = 0).let(evolutionDepth0Adapter::submitList)
+ evolutions(depth = 1).let(evolutionDepth1Adapter::submitList)
+ evolutions(depth = 2).let(evolutionDepth2Adapter::submitList)
+ evolutions(depth = 3).let(evolutionDepth3Adapter::submitList)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ binding.root.requestLayout()
+ }
+}
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..866107c5
--- /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.data.model.PokemonBiome
+import poke.rogue.helper.databinding.ItemPokemonDetailInformationBiomeBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+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/PokemonDetailBiomeViewHolder.kt b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeViewHolder.kt
new file mode 100644
index 00000000..2d27427c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonDetailBiomeViewHolder.kt
@@ -0,0 +1,18 @@
+package poke.rogue.helper.presentation.dex.detail.information
+
+import androidx.recyclerview.widget.RecyclerView
+import poke.rogue.helper.data.model.PokemonBiome
+import poke.rogue.helper.databinding.ItemPokemonDetailInformationBiomeBinding
+import poke.rogue.helper.presentation.dex.detail.PokemonDetailNavigateHandler
+
+class PokemonDetailBiomeViewHolder(
+ private val binding: ItemPokemonDetailInformationBiomeBinding,
+ private val onClickBiomeItem: PokemonDetailNavigateHandler,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(biome: PokemonBiome) {
+ binding.apply {
+ this.biome = biome
+ uiEventHandler = onClickBiomeItem
+ }
+ }
+}
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..a7f57c9e
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/information/PokemonInformationFragment.kt
@@ -0,0 +1,52 @@
+package poke.rogue.helper.presentation.dex.detail.information
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+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
+import poke.rogue.helper.presentation.util.view.dp
+
+class PokemonInformationFragment :
+ BindingFragment(R.layout.fragment_pokemon_information) {
+ private val activityViewModel: PokemonDetailViewModel by activityViewModels()
+ private val biomesAdapter: PokemonDetailBiomeAdapter by lazy { PokemonDetailBiomeAdapter(activityViewModel) }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initObserver()
+ }
+
+ private fun initAdapter() {
+ binding.rvPokemonDetailInformation.apply {
+ adapter = biomesAdapter
+ addItemDecoration(GridSpacingItemDecoration(2, 9.dp, false))
+ }
+ }
+
+ private fun initObserver() {
+ repeatOnStarted {
+ activityViewModel.uiState.collect { pokemonDetailUiState ->
+ when (pokemonDetailUiState) {
+ is PokemonDetailUiState.IsLoading -> {}
+ is PokemonDetailUiState.Success -> biomesAdapter.submitList(pokemonDetailUiState.biomes)
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ binding.root.requestLayout()
+ }
+}
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..05ad9d2c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/skill/PokemonDetailSkillFragment.kt
@@ -0,0 +1,89 @@
+package poke.rogue.helper.presentation.dex.detail.skill
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import com.google.android.material.divider.MaterialDividerItemDecoration
+import com.google.android.material.divider.MaterialDividerItemDecoration.VERTICAL
+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 activityViewModels()
+
+ 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()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ binding.root.requestLayout()
+ }
+
+ 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 -> {}
+ // TODO: skill ์ ํ์ฌ๋ ํ ์ข
๋ฅ์ ์คํฌ ๋ชฉ๋ก๋ง ์ฌ์ฉํ๊ณ ์์..... ์ดํ์๋ ์ฌ๋ฌ๊ฐ์ ์คํฌ์ ๋ฐ์์ผํจ
+ 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..6f3f7d3e
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/detail/stat/PokemonStatFragment.kt
@@ -0,0 +1,101 @@
+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 androidx.fragment.app.activityViewModels
+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 activityViewModels()
+
+ 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 -> bindDatas(uiState)
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ binding.root.requestLayout()
+ }
+
+ 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 bindDatas(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..1b7684ec
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/filter/SelectableUiModel.kt
@@ -0,0 +1,29 @@
+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.toSelectableModelsWithAllDeselected(): List> {
+ return this.mapIndexed { index, t -> SelectableUiModel(index, false, t) }
+}
+
+fun List>.updateSelectionBy(predicate: (T) -> Boolean): List> {
+ return this.map {
+ val isSelected = predicate(it.data)
+ it.copy(isSelected = isSelected)
+ }
+}
+
+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..f389400c
--- /dev/null
+++ b/android/app/src/main/java/poke/rogue/helper/presentation/dex/model/EvolutionsUiModel.kt
@@ -0,0 +1,70 @@
+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(depth: Int) = 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/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