diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3e3db81..a1fb41b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,10 +8,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: set up JDK 1.11 - uses: actions/setup-java@v1 + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 1.11 + java-version: 17 + distribution: temurin - name: Build with Gradle - run: ./gradlew assembleDebug + run: ./gradlew assembleDebug \ No newline at end of file diff --git a/README.md b/README.md index c684e9a..4993255 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,38 @@ ![Android CI](https://github.com/ashwini009/TvFlix/workflows/Android%20CI/badge.svg?branch=master&event=push) ![GitHub top language](https://img.shields.io/github/languages/top/ashwini009/TvFlix?style=plastic) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21) ![GitHub stars](https://img.shields.io/github/stars/ashwini009/TvFlix?style=social) ![GitHub forks](https://img.shields.io/github/forks/ashwini009/TvFlix?style=social) -# TvFlix :tv: +# TvFlix :tv: -The aim of this app is to replicate the high level functionality of www.tvmaze.com and showcase an android app out of it. +The aim of this app is to replicate the high level functionality of www.tvmaze.com and showcase an android app out of it. It connects with [TVDB API](https://api.thetvdb.com) to give you popular shows and let you mark anyone as favorite. TvFlix consists of 3 pieces of UI right now: 1. Home with Popular Shows 2. Favorites 3. All Shows -This app is under development. :construction_worker: :hammer_and_wrench: - *Note: TvFlix is an unofficial app built only for learning and sharing the latest concepts with #AndroidDevs* ## Android Development and Architecture * The entire codebase is in [Kotlin](https://kotlinlang.org/) -* Uses Kotlin [Coroutines](https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html). +* Uses [Jetpack Compose](https://developer.android.com/jetpack/compose) for UI +* Uses Kotlin [Coroutines](https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html) and [StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) for reactive state management * Uses MVVM Architecture by [Architecture Components](https://developer.android.com/topic/libraries/architecture/). Room, ViewModel, Paging * Uses [Hilt Android](https://developer.android.com/training/dependency-injection/hilt-android) with [Dagger](https://dagger.dev/) for dependency injection +* Uses [KSP](https://developer.android.com/build/migrate-to-ksp) for annotation processing * Unit Testing by [Mockito](https://github.com/mockito/mockito) * Tests Coroutines and architecture components like ViewModel -* UI Test by [Espresso](https://developer.android.com/training/testing/espresso) based on [Robot Pattern](https://academy.realm.io/posts/kau-jake-wharton-testing-robots/) +* UI Test by [Compose Testing](https://developer.android.com/jetpack/compose/testing) * Uses [Kotlin Coroutines Test](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/) to unit test Kotlin Coroutines -* Uses [StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) as a replacement over LiveData as a state-holder observable * Uses [Firebase Remote Config](https://firebase.google.com/products/remote-config) for experimentation and feature rollout * Uses [Firebase App Distribution](https://firebase.google.com/products/app-distribution) for internal distribution and quality testing - -## Further Reading - -There are several articles written on this repository which state the design and architecture. - -### Kotlin Everywhere. Coroutines, Tests, Robots and much more… - -The TvFlix complete repository has been re-written in Kotlin with Coroutines covering -Unit Tests across ViewModels and UI tests for the app. -Know more: -[Kotlin Everywhere. Coroutines, Tests, Robots and much more…](https://proandroiddev.com/kotlin-everywhere-coroutines-tests-robots-and-much-more-b02030206cc9) - -### MVVM using Android Architecture Components - -The codebase tries to follow Uncle Bob Clean Code Architecture with [SOLID principles](https://en.wikipedia.org/wiki/SOLID). -Know more: -[Migration from MVP to MVVM using Android Architecture Components](https://medium.com/@kumarashwini/migration-from-mvp-to-mvvm-using-android-architecture-components-4bc058a1f73c) - -### Pagination using Paging Library - -The Shows screen displays the list of shows fetched from TvMaze API using [Paging3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) of Android Architecture Components. It also handles the retry if any network error occurred. Recently the repository has been [migrated to use Paging3](https://github.com/reactivedroid/TvFlix/pull/14). -Paging3 is in heavy development, and if you want to catch up with stable library(Paging 2), then check out this blog -[Pagination using Paging Library with RxJava and Dagger](https://medium.com/@kumarashwini/pagination-using-paging-library-with-rxjava-and-dagger-d9d05dbd8eac) - -### Room Persistence Library - -The Favourites screen displays the list of shows marked favourites from the Home screen. The user can add/remove from -the favorites as and when required. The implementation of the favorites is done using `Room` Persistence Library with RxJava and Dagger. -Know more: -[Room with RxJava and Dagger](https://medium.com/@kumarashwini/room-with-rxjava-and-dagger-2722f4420651) - -### Static Code Analysis - -TvFlix has Static Code Analysis tools like FindBugs, PMD and Checkstyle integrated. These tools help in finding potential bugs that would have been missed and help in making the codebase clean. -Know more: -[Static Code Analysis for Android Using FindBugs, PMD and CheckStyle](https://blog.mindorks.com/static-code-analysis-for-android-using-findbugs-pmd-and-checkstyle-3a2861834c6a) +* Uses [Paging3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) for paginated show listing ## Contributions If you have found an issue in this sample, please file it. Better yet, if you want to contribute to the repository, go ahead, any kind of patches are encouraged, -and may be submitted by forking this project and submitting a pull request. +and may be submitted by forking this project and submitting a pull request. If you have something big in mind, or any architectural change, please raise an issue first to discuss it. ## License @@ -93,4 +57,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +``` \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0550fd6..b237a40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,8 @@ plugins { id("com.android.application") kotlin("android") - kotlin("kapt") + kotlin("plugin.compose") + id("com.google.devtools.ksp") kotlin("plugin.parcelize") id("dagger.hilt.android.plugin") id("com.google.gms.google-services") @@ -21,6 +22,7 @@ android { buildFeatures { viewBinding = true dataBinding = true + compose = true buildConfig = true } @@ -31,17 +33,11 @@ android { versionCode = Deps.Versions.app_version_code versionName = Deps.Versions.app_version_name testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - javaCompileOptions { - annotationProcessorOptions { - // Refer https://developer.android.com/jetpack/androidx/releases/room#compiler-options - arguments( - mapOf( - "room.schemaLocation" to "$projectDir/schemas", - "room.incremental" to "true", - "room.expandProjection" to "true" - ) - ) - } + + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + arg("room.expandProjection", "true") } } flavorDimensions.addAll(listOf("default")) @@ -76,8 +72,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } testOptions { @@ -85,17 +81,9 @@ android { animationsDisabled = true } - kapt { - useBuildCache = true - javacOptions { - // Increase the max count of errors from annotation processors. - // Default is 100. - option("-Xmaxerrs", 500) - } - } testBuildType = "debug" - packagingOptions { + packaging { resources.excludes.addAll( listOf( "META-INF/ASL2.0", @@ -110,8 +98,9 @@ android { } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } + namespace = "com.android.tvflix" } dependencies { @@ -121,13 +110,22 @@ dependencies { implementation(Deps.AndroidX.ktx_core) implementation(Deps.AndroidX.ktx_fragment) implementation(Deps.AndroidX.ktx_activity) + implementation(Deps.AndroidX.compose_activity) implementation(Deps.AndroidX.constraint_layout) - kapt(Deps.AndroidX.Lifecycle.compiler) + implementation(platform(Deps.AndroidX.Compose.bom)) + implementation(Deps.AndroidX.Compose.ui) + implementation(Deps.AndroidX.Compose.foundation) + implementation(Deps.AndroidX.Compose.material) + implementation(Deps.AndroidX.Compose.material_icons) + implementation(Deps.AndroidX.Compose.tooling_preview) + implementation(Deps.AndroidX.Compose.navigation) + implementation(Deps.AndroidX.Compose.hilt_navigation) implementation(Deps.AndroidX.Lifecycle.viewmodel) implementation(Deps.AndroidX.Paging.runtime) + implementation(Deps.AndroidX.Paging.compose) testImplementation(Deps.AndroidX.Paging.common) implementation(Deps.AndroidX.Room.runtime) - kapt(Deps.AndroidX.Room.compiler) + ksp(Deps.AndroidX.Room.compiler) testImplementation(Deps.AndroidX.Room.testing) implementation(Deps.AndroidX.Room.ktx) implementation(Deps.AndroidX.annotation) @@ -138,7 +136,8 @@ dependencies { implementation(Deps.OkHttp.logging_interceptor) implementation(Deps.Glide.runtime) implementation(Deps.Glide.okhttp_integration) - kapt(Deps.Glide.compiler) + implementation(Deps.Coil.compose) + ksp(Deps.Glide.ksp) implementation(Deps.Retrofit.main) implementation(Deps.Retrofit.moshi) @@ -154,27 +153,25 @@ dependencies { testImplementation(Deps.AndroidX.Test.core) androidTestImplementation(Deps.AndroidX.Test.runner) androidTestImplementation(Deps.AndroidX.Test.junit) - // Espresso - androidTestImplementation(Deps.AndroidX.Test.Espresso.core) - androidTestImplementation(Deps.AndroidX.Test.Espresso.contrib) - androidTestImplementation(Deps.AndroidX.Test.Espresso.idling_resource) - androidTestImplementation(Deps.AndroidX.Test.rules) + androidTestImplementation(Deps.AndroidX.Compose.test_junit4) testImplementation(Deps.Test.truth) testImplementation(Deps.Test.robolectric) testImplementation(Deps.Coroutines.test) // end-region Test implementation(Deps.Moshi.kotlin) - kapt(Deps.Moshi.codegen) + ksp(Deps.Moshi.codegen) implementation(Deps.Coroutines.core) implementation(Deps.Coroutines.android) debugImplementation(Deps.Chucker.debug) + debugImplementation(Deps.AndroidX.Compose.tooling) + debugImplementation(Deps.AndroidX.Compose.test_manifest) releaseImplementation(Deps.Chucker.release) implementation(Deps.Hilt.android) - kapt(Deps.Hilt.android_compiler) + ksp(Deps.Hilt.android_compiler) // start-region Firebase implementation(platform(Deps.Firebase.firebase_bom)) @@ -184,5 +181,3 @@ dependencies { implementation(Deps.Firebase.remote_config) // end-region Firebase } - - diff --git a/app/src/androidTest/java/com/android/tvflix/home/HomeRobot.kt b/app/src/androidTest/java/com/android/tvflix/home/HomeRobot.kt deleted file mode 100644 index 69630b1..0000000 --- a/app/src/androidTest/java/com/android/tvflix/home/HomeRobot.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.android.tvflix.home - -import android.app.Activity -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition -import androidx.test.espresso.matcher.RootMatchers.withDecorView -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import com.android.tvflix.R -import com.android.tvflix.utils.MatcherUtils -import com.android.tvflix.utils.ViewActionUtils -import org.hamcrest.Matchers.`is` -import org.hamcrest.Matchers.allOf -import org.hamcrest.Matchers.not - -fun launchHome(func: HomeRobot.() -> Unit) = HomeRobot().apply { func() } -class HomeRobot { - fun verifyHome() { - onView(withId(R.id.popular_show_header)).check(matches(isDisplayed())) - onView(allOf(withId(R.id.popular_shows), isDisplayed())) - } - - fun verifyFavorite() { - // Click on 1st item and mark as favorite - onView(withId(R.id.popular_shows)) - .perform( - actionOnItemAtPosition - (0, ViewActionUtils.clickChildViewWithId(R.id.favorite)) - ) - } - - fun verifyToast(activity: Activity) { - onView(withText(R.string.added_to_favorites)).inRoot(withDecorView(not(`is`(activity.window.decorView)))) - .check(matches(isDisplayed())) - } - - fun verifyFavoriteScreen() { - onView(withId(R.id.action_favorites)) - .perform(click()) - onView(withText(R.string.favorite_shows)).check(matches(isDisplayed())) - onView(withId(R.id.shows)) - .check(matches(MatcherUtils.withListSize(1))) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/tvflix/home/HomeTest.kt b/app/src/androidTest/java/com/android/tvflix/home/HomeTest.kt deleted file mode 100644 index 165dc55..0000000 --- a/app/src/androidTest/java/com/android/tvflix/home/HomeTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.android.tvflix.home - -import androidx.test.espresso.Espresso.pressBack -import androidx.test.espresso.IdlingRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import androidx.test.rule.ActivityTestRule -import com.android.tvflix.idlingresource.LoadingIdlingResource -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@LargeTest -@RunWith(AndroidJUnit4::class) -class HomeTest { - @get:Rule - val homeActivityTestRule = ActivityTestRule(HomeActivity::class.java) - private lateinit var loadingIdlingResource: LoadingIdlingResource - - @Before - fun setUp() { - loadingIdlingResource = - LoadingIdlingResource(homeActivityTestRule.activity) - IdlingRegistry.getInstance().register(loadingIdlingResource) - } - - @Test - fun testHomePageWithFavorites() { - launchHome { - verifyHome() - // Click on add to favorites icon - verifyFavorite() - // Verify that added to favorites toast is shown - verifyToast(homeActivityTestRule.activity) - verifyFavoriteScreen() - // Verify that pressing back from favorites goes to home - pressBack() - verifyHome() - } - } - - @After - fun tearDown() { - IdlingRegistry.getInstance().unregister(loadingIdlingResource) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/tvflix/idlingresource/LoadingIdlingResource.kt b/app/src/androidTest/java/com/android/tvflix/idlingresource/LoadingIdlingResource.kt deleted file mode 100644 index c74d3c2..0000000 --- a/app/src/androidTest/java/com/android/tvflix/idlingresource/LoadingIdlingResource.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.android.tvflix.idlingresource - -import android.app.Activity -import android.view.View -import android.widget.ProgressBar -import androidx.test.espresso.IdlingResource -import com.android.tvflix.R - -/** - * An idling resource which checks which tells Espresso to wait until Progress Bar dismisses - */ -class LoadingIdlingResource constructor(private val activity: Activity) : IdlingResource { - private var resourceCallback: IdlingResource.ResourceCallback? = null - override fun getName(): String { - return "LoadingIdlingResource" - } - - override fun isIdleNow(): Boolean { - return if (!isProgressShowing()) { - resourceCallback?.onTransitionToIdle() - true - } else { - false - } - } - - override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { - resourceCallback = callback - } - - private fun isProgressShowing(): Boolean { - val progress = activity.findViewById(R.id.progress) - return progress.visibility == View.VISIBLE - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/tvflix/main/MainNavigationComposeTest.kt b/app/src/androidTest/java/com/android/tvflix/main/MainNavigationComposeTest.kt new file mode 100644 index 0000000..e8da11d --- /dev/null +++ b/app/src/androidTest/java/com/android/tvflix/main/MainNavigationComposeTest.kt @@ -0,0 +1,55 @@ +package com.android.tvflix.main + +import androidx.compose.ui.test.assertExists +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class MainNavigationComposeTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun appLaunchesAndReachesHomeShell() { + waitForHomeShell() + composeRule.onNodeWithTag("nav_shows").assertExists() + } + + @Test + fun navigateToShowsRouteFromHome() { + waitForHomeShell() + composeRule.onNodeWithTag("nav_shows").performClick() + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("shows_list").fetchSemanticsNodes().isNotEmpty() || + composeRule.onAllNodesWithTag("retry").fetchSemanticsNodes().isNotEmpty() + } + } + + @Test + fun navigateToFavoritesRouteWhenFeatureEnabled() { + waitForHomeShell() + val favoritesNodeCount = composeRule.onAllNodesWithTag("nav_favorites") + .fetchSemanticsNodes().size + if (favoritesNodeCount > 0) { + composeRule.onNodeWithTag("nav_favorites").performClick() + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("favorites_list").fetchSemanticsNodes().isNotEmpty() || + composeRule.onAllNodesWithTag("retry").fetchSemanticsNodes().isNotEmpty() + } + } + } + + private fun waitForHomeShell() { + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("nav_shows").fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/app/src/androidTest/java/com/android/tvflix/shows/AllShowsRobot.kt b/app/src/androidTest/java/com/android/tvflix/shows/AllShowsRobot.kt deleted file mode 100644 index 2e8fcb9..0000000 --- a/app/src/androidTest/java/com/android/tvflix/shows/AllShowsRobot.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.android.tvflix.shows - -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.espresso.matcher.ViewMatchers.withText -import com.android.tvflix.R -import com.android.tvflix.utils.MatcherUtils - -fun launchAllShows(func: AllShowsRobot.() -> Unit) = AllShowsRobot().apply { func() } -class AllShowsRobot { - fun verifyAllShows() { - onView(withText(R.string.shows)).check(matches(isDisplayed())) - onView(withId(R.id.shows)) - .check(matches(MatcherUtils.withListSize(1))) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/tvflix/shows/AllShowsTest.kt b/app/src/androidTest/java/com/android/tvflix/shows/AllShowsTest.kt deleted file mode 100644 index f37c6f9..0000000 --- a/app/src/androidTest/java/com/android/tvflix/shows/AllShowsTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.android.tvflix.shows - -import androidx.test.espresso.IdlingRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import androidx.test.rule.ActivityTestRule -import com.android.tvflix.idlingresource.LoadingIdlingResource -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@LargeTest -@RunWith(AndroidJUnit4::class) -class AllShowsTest { - // @get:Rule - // val homeActivityTestRule = ActivityTestRule(HomeActivity::class.java) - @get:Rule - val allShowsActivityTestRule = ActivityTestRule(AllShowsActivity::class.java) - - private lateinit var loadingIdlingResource: LoadingIdlingResource - @Before - fun setUp() { - loadingIdlingResource = - LoadingIdlingResource(allShowsActivityTestRule.activity) - IdlingRegistry.getInstance().register(loadingIdlingResource) - } - - @Test - fun testAllShowsViaHome() { - launchAllShows { - verifyAllShows() - } - } - - @After - fun tearDown() { - IdlingRegistry.getInstance().unregister(loadingIdlingResource) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/tvflix/utils/MatcherUtils.kt b/app/src/androidTest/java/com/android/tvflix/utils/MatcherUtils.kt deleted file mode 100644 index ac8fb01..0000000 --- a/app/src/androidTest/java/com/android/tvflix/utils/MatcherUtils.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.android.tvflix.utils - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.hamcrest.Description -import org.hamcrest.Matcher -import org.hamcrest.TypeSafeMatcher - -object MatcherUtils { - fun withListSize(size: Int): Matcher { - return object : TypeSafeMatcher() { - override fun matchesSafely(view: View): Boolean { - return (view as RecyclerView).adapter!!.itemCount >= size - } - - override fun describeTo(description: Description) { - description.appendText("RecyclerView should have $size items") - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/tvflix/utils/ViewActionUtils.kt b/app/src/androidTest/java/com/android/tvflix/utils/ViewActionUtils.kt deleted file mode 100644 index 2bde316..0000000 --- a/app/src/androidTest/java/com/android/tvflix/utils/ViewActionUtils.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.android.tvflix.utils - -import android.view.View -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction -import org.hamcrest.Matcher - -object ViewActionUtils { - fun clickChildViewWithId(id: Int): ViewAction { - return object : ViewAction { - override fun getConstraints(): Matcher? { - return null - } - - override fun getDescription(): String { - return "Click on a child view with id $id" - } - - override fun perform(uiController: UiController, view: View) { - val v = view.findViewById(id) - v.performClick() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fb30919..992dca9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> @@ -15,8 +14,7 @@ android:theme="@style/AppTheme" tools:ignore="LockedOrientationActivity"> @@ -26,20 +24,9 @@ - - - - - \ No newline at end of file + diff --git a/app/src/main/java/com/android/tvflix/analytics/FirebaseAnalyticsHandler.kt b/app/src/main/java/com/android/tvflix/analytics/FirebaseAnalyticsHandler.kt index 5ab66f2..4870405 100644 --- a/app/src/main/java/com/android/tvflix/analytics/FirebaseAnalyticsHandler.kt +++ b/app/src/main/java/com/android/tvflix/analytics/FirebaseAnalyticsHandler.kt @@ -1,32 +1,35 @@ package com.android.tvflix.analytics -import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.analytics.ktx.logEvent -import com.google.firebase.ktx.Firebase +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @Singleton class FirebaseAnalyticsHandler @Inject -constructor() : Analytics { - private val firebaseAnalytics by lazy { Firebase.analytics } +constructor(@ApplicationContext context: Context) : Analytics { + private val firebaseAnalytics = FirebaseAnalytics.getInstance(context) + override fun sendEvent(event: Event) { - firebaseAnalytics.logEvent(event.eventName) { - param(EventParams.CONTENT_ID, event.eventParams[EventParams.CONTENT_ID] as Long) - param(EventParams.CONTENT_TITLE, event.eventParams[EventParams.CONTENT_TITLE] as String) - param( + val bundle = Bundle().apply { + putLong(EventParams.CONTENT_ID, event.eventParams[EventParams.CONTENT_ID] as Long) + putString(EventParams.CONTENT_TITLE, event.eventParams[EventParams.CONTENT_TITLE] as String) + putString( EventParams.IS_FAVORITE_MARKED, event.eventParams[EventParams.IS_FAVORITE_MARKED].toString() ) - param( + putString( EventParams.CLICK_CONTEXT, event.eventParams[EventParams.CLICK_CONTEXT] as String ) } + firebaseAnalytics.logEvent(event.eventName, bundle) } override fun setUserId(userId: String) { firebaseAnalytics.setUserId(userId) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/android/tvflix/config/AppConfig.kt b/app/src/main/java/com/android/tvflix/config/AppConfig.kt index 0859a92..b851036 100644 --- a/app/src/main/java/com/android/tvflix/config/AppConfig.kt +++ b/app/src/main/java/com/android/tvflix/config/AppConfig.kt @@ -1,10 +1,10 @@ package com.android.tvflix.config import com.android.tvflix.BuildConfig -import com.google.firebase.ktx.Firebase -import com.google.firebase.remoteconfig.ktx.remoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume @@ -19,7 +19,7 @@ constructor() { const val MIN_FETCH_INTERVAL_S = 3600L } - private val remoteConfig by lazy { Firebase.remoteConfig } + private val remoteConfig by lazy { FirebaseRemoteConfig.getInstance() } fun initialise() { val minFetchInterval: Long = if (BuildConfig.DEBUG) { @@ -27,10 +27,10 @@ constructor() { } else { MIN_FETCH_INTERVAL_S } - val remoteConfigSettings = remoteConfigSettings { - fetchTimeoutInSeconds = FETCH_TIMEOUT_S - minimumFetchIntervalInSeconds = minFetchInterval - } + val remoteConfigSettings = FirebaseRemoteConfigSettings.Builder() + .setFetchTimeoutInSeconds(FETCH_TIMEOUT_S) + .setMinimumFetchIntervalInSeconds(minFetchInterval) + .build() remoteConfig.apply { setConfigSettingsAsync(remoteConfigSettings) setDefaultsAsync(ConfigDefaults.getDefaultValues()) @@ -40,8 +40,11 @@ constructor() { suspend fun fetch(): Boolean = suspendCancellableCoroutine { continuation -> remoteConfig.fetchAndActivate().addOnSuccessListener { + Timber.i("Config fetched successfully") continuation.resume(true) - }.addOnFailureListener { exc -> continuation.resumeWithException(exc) } + }.addOnFailureListener { exc -> + Timber.i("Config fetch failed") + continuation.resumeWithException(exc) } } fun getString(key: String): String { @@ -63,4 +66,4 @@ constructor() { fun getInt(key: String): Int { return remoteConfig.getLong(key).toInt() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/android/tvflix/db/favouriteshow/FavoriteShow.kt b/app/src/main/java/com/android/tvflix/db/favouriteshow/FavoriteShow.kt index ba4ff31..ca98762 100644 --- a/app/src/main/java/com/android/tvflix/db/favouriteshow/FavoriteShow.kt +++ b/app/src/main/java/com/android/tvflix/db/favouriteshow/FavoriteShow.kt @@ -14,4 +14,3 @@ data class FavoriteShow( var rating: String?, var runtime: Int? ) - diff --git a/app/src/main/java/com/android/tvflix/db/favouriteshow/ShowDao.kt b/app/src/main/java/com/android/tvflix/db/favouriteshow/ShowDao.kt index d789a44..e56f5df 100644 --- a/app/src/main/java/com/android/tvflix/db/favouriteshow/ShowDao.kt +++ b/app/src/main/java/com/android/tvflix/db/favouriteshow/ShowDao.kt @@ -1,6 +1,10 @@ package com.android.tvflix.db.favouriteshow -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query @Dao interface ShowDao { @@ -13,7 +17,7 @@ interface ShowDao { @Delete suspend fun remove(favoriteShow: FavoriteShow) - @Query("SELECT id from favourite_shows") + @Query("SELECT id FROM favourite_shows") suspend fun getFavoriteShowIds(): List @Query("DELETE FROM favourite_shows") diff --git a/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsActivity.kt b/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsActivity.kt deleted file mode 100644 index d9c0967..0000000 --- a/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsActivity.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.android.tvflix.favorite - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ImageSpan -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import com.android.tvflix.R -import com.android.tvflix.databinding.ActivityFavoriteShowsBinding -import com.android.tvflix.db.favouriteshow.FavoriteShow -import com.android.tvflix.utils.GridItemDecoration -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collect - -@AndroidEntryPoint -class FavoriteShowsActivity : AppCompatActivity() { - private val favoriteShowsViewModel: FavoriteShowsViewModel by viewModels() - private val binding by lazy { ActivityFavoriteShowsBinding.inflate(layoutInflater) } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setToolbar() - favoriteShowsViewModel.loadFavoriteShows() - lifecycleScope.launchWhenStarted { - favoriteShowsViewModel.favoriteShowsStateFlow.collect { setViewState(it) } - } - } - - private fun setViewState(favoriteShowState: FavoriteShowState) { - when (favoriteShowState) { - is FavoriteShowState.Loading -> binding.progress.isVisible = true - is FavoriteShowState.AllFavorites -> - showFavorites(favoriteShowState.favoriteShows) - is FavoriteShowState.Error, FavoriteShowState.Empty -> showEmptyState() - is FavoriteShowState.AddedToFavorites -> - Toast.makeText(this, R.string.added_to_favorites, Toast.LENGTH_SHORT).show() - is FavoriteShowState.RemovedFromFavorites -> - Toast.makeText(this, R.string.removed_from_favorites, Toast.LENGTH_SHORT).show() - } - } - - private fun setToolbar() { - val toolbar = binding.toolbar.toolbar - setSupportActionBar(toolbar) - toolbar.setTitleTextColor(ContextCompat.getColor(this, android.R.color.white)) - toolbar.setSubtitleTextColor(ContextCompat.getColor(this, android.R.color.white)) - supportActionBar?.run { setDisplayHomeAsUpEnabled(true) } - setTitle(R.string.favorite_shows) - } - - private fun showFavorites(favoriteShows: List) { - binding.progress.isVisible = false - val layoutManager = GridLayoutManager(this, COLUMNS_COUNT) - binding.shows.layoutManager = layoutManager - val favoriteShowsAdapter = FavoriteShowsAdapter(favoriteShows.toMutableList()) - binding.shows.adapter = favoriteShowsAdapter - val spacing = resources.getDimensionPixelSize(R.dimen.show_grid_spacing) - binding.shows.addItemDecoration(GridItemDecoration(spacing, COLUMNS_COUNT)) - binding.shows.isVisible = true - } - - private fun showEmptyState() { - binding.progress.isVisible = false - val bookmarkSpan = ImageSpan(this, R.drawable.favorite_border) - val spannableString = SpannableString(getString(R.string.favorite_hint_msg)) - spannableString.setSpan( - bookmarkSpan, FAVORITE_ICON_START_OFFSET, - FAVORITE_ICON_END_OFFSET, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - binding.favoriteHint.text = spannableString - binding.favoriteHint.isVisible = true - } - - companion object { - private const val FAVORITE_ICON_START_OFFSET = 13 - private const val FAVORITE_ICON_END_OFFSET = 14 - private const val COLUMNS_COUNT = 2 - - fun start(context: Activity) { - val starter = Intent(context, FavoriteShowsActivity::class.java) - context.startActivity(starter) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return if (item.itemId == android.R.id.home) { - finish() - true - } else { - super.onOptionsItemSelected(item) - } - } -} diff --git a/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsAdapter.kt b/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsAdapter.kt deleted file mode 100644 index 34bdfa5..0000000 --- a/app/src/main/java/com/android/tvflix/favorite/FavoriteShowsAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.android.tvflix.favorite - -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ImageView -import androidx.appcompat.content.res.AppCompatResources -import androidx.recyclerview.widget.RecyclerView -import com.android.tvflix.R -import com.android.tvflix.databinding.ShowListItemBinding -import com.android.tvflix.db.favouriteshow.FavoriteShow -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.RequestOptions - -class FavoriteShowsAdapter( - private val favoriteShows: MutableList -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteShowHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val showListItemBinding = ShowListItemBinding.inflate(layoutInflater, parent, false) - val holder = FavoriteShowHolder(showListItemBinding) - holder.binding.showFavoriteIcon = false - return holder - } - - override fun onBindViewHolder(holder: FavoriteShowHolder, position: Int) { - val favoriteShow = favoriteShows[position] - Glide.with(holder.itemView.context).load(favoriteShow.imageUrl) - .apply(RequestOptions.placeholderOf(R.color.grey)) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(holder.binding.showImage) - } - - override fun getItemCount(): Int { - return favoriteShows.size - } - - class FavoriteShowHolder(val binding: ShowListItemBinding) : - RecyclerView.ViewHolder(binding.root) -} diff --git a/app/src/main/java/com/android/tvflix/home/HomeActivity.kt b/app/src/main/java/com/android/tvflix/home/HomeActivity.kt deleted file mode 100644 index 2b0fa4e..0000000 --- a/app/src/main/java/com/android/tvflix/home/HomeActivity.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.android.tvflix.home - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import com.android.tvflix.R -import com.android.tvflix.config.FavoritesFeatureFlag -import com.android.tvflix.databinding.ActivityHomeBinding -import com.android.tvflix.domain.GetSchedulesUseCase -import com.android.tvflix.favorite.FavoriteShowsActivity -import com.android.tvflix.shows.AllShowsActivity -import com.android.tvflix.utils.GridItemDecoration -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collect -import javax.inject.Inject - -@AndroidEntryPoint -class HomeActivity : AppCompatActivity(), ShowsAdapter.Callback { - private val homeViewModel: HomeViewModel by viewModels() - private lateinit var showsAdapter: ShowsAdapter - private val binding by lazy { ActivityHomeBinding.inflate(layoutInflater) } - - @JvmField - @FavoritesFeatureFlag - @Inject - var favoritesFeatureEnable: Boolean = false - - companion object { - private const val NO_OF_COLUMNS = 2 - - @JvmStatic - fun start( - context: Activity - ) { - val intent = Intent(context, HomeActivity::class.java) - .apply { - flags = (Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - context.startActivity(intent) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setToolbar() - homeViewModel.onScreenCreated() - lifecycleScope.launchWhenStarted { - homeViewModel.homeViewStateFlow.collect { setViewState(it) } - } - } - - private fun setViewState(homeViewState: HomeViewState) { - when (homeViewState) { - is HomeViewState.Loading -> binding.progress.isVisible = true - is HomeViewState.NetworkError -> { - binding.progress.isVisible = false - showError(homeViewState.message!!) - } - is HomeViewState.Success -> { - with(binding) { - progress.isVisible = false - popularShowHeader.text = homeViewState.homeViewData.heading - popularShowHeader.isVisible = true - } - showPopularShows(homeViewState.homeViewData) - } - is HomeViewState.AddedToFavorites -> - Toast.makeText( - this, - getString(R.string.added_to_favorites, homeViewState.show.name), - Toast.LENGTH_SHORT - ).show() - is HomeViewState.RemovedFromFavorites -> - Toast.makeText( - this, - getString(R.string.removed_from_favorites, homeViewState.show.name), - Toast.LENGTH_SHORT - ).show() - } - } - - private fun setToolbar() { - val toolbar = binding.toolbar.toolbar - setSupportActionBar(toolbar) - toolbar.setTitleTextColor(ContextCompat.getColor(this, android.R.color.white)) - toolbar.setSubtitleTextColor(ContextCompat.getColor(this, android.R.color.white)) - setTitle(R.string.app_name) - } - - private fun showPopularShows(homeViewData: HomeViewData) { - val gridLayoutManager = GridLayoutManager(this, NO_OF_COLUMNS) - showsAdapter = ShowsAdapter(this, favoritesFeatureEnable) - showsAdapter.updateList(homeViewData.episodes.toMutableList()) - binding.popularShows.apply { - layoutManager = gridLayoutManager - setHasFixedSize(true) - adapter = showsAdapter - val spacing = resources.getDimensionPixelSize(R.dimen.show_grid_spacing) - addItemDecoration(GridItemDecoration(spacing, NO_OF_COLUMNS)) - } - } - - private fun showError(message: String) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.home_menu, menu) - menu.findItem(R.id.action_favorites).isVisible = favoritesFeatureEnable - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_shows -> { - AllShowsActivity.start(this) - true - } - R.id.action_favorites -> { - FavoriteShowsActivity.start(this) - true - } - else -> super.onOptionsItemSelected(item) - } - } - - override fun onFavoriteClicked(showViewData: HomeViewData.ShowViewData) { - homeViewModel.onFavoriteClick(showViewData) - } -} diff --git a/app/src/main/java/com/android/tvflix/home/ShowDiffUtilCallback.kt b/app/src/main/java/com/android/tvflix/home/ShowDiffUtilCallback.kt deleted file mode 100644 index 4321103..0000000 --- a/app/src/main/java/com/android/tvflix/home/ShowDiffUtilCallback.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.android.tvflix.home - -import android.os.Bundle -import androidx.recyclerview.widget.DiffUtil - -class ShowDiffUtilCallback( - private val oldList: List, - private val newList: List -) : DiffUtil.Callback() { - - override fun getOldListSize(): Int { - return oldList.size - } - - override fun getNewListSize(): Int { - return newList.size - } - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldEpisodeViewData = oldList[oldItemPosition] - val newEpisodeViewData = newList[newItemPosition] - return oldEpisodeViewData.showViewData.show.id == newEpisodeViewData.showViewData.show.id - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldEpisodeViewData = oldList[oldItemPosition] - val newEpisodeViewData = newList[newItemPosition] - return oldEpisodeViewData.showViewData.isFavoriteShow == newEpisodeViewData.showViewData.isFavoriteShow - } - - override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Bundle { - val oldEpisodeViewData = oldList[oldItemPosition] - val newEpisodeViewData = newList[newItemPosition] - val bundle = Bundle() - if (oldEpisodeViewData.showViewData.isFavoriteShow != newEpisodeViewData.showViewData.isFavoriteShow) { - bundle.putBoolean(IS_FAVORITE, newEpisodeViewData.showViewData.isFavoriteShow) - } - return bundle - } -} diff --git a/app/src/main/java/com/android/tvflix/home/ShowsAdapter.kt b/app/src/main/java/com/android/tvflix/home/ShowsAdapter.kt deleted file mode 100644 index 1801f47..0000000 --- a/app/src/main/java/com/android/tvflix/home/ShowsAdapter.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.android.tvflix.home - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ImageView -import androidx.appcompat.content.res.AppCompatResources -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.android.tvflix.R -import com.android.tvflix.databinding.ShowListItemBinding -import com.android.tvflix.network.home.Show -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.RequestOptions - -const val IS_FAVORITE = "IS_FAVORITE" - -class ShowsAdapter( - private val callback: Callback, - private val favoritesFeatureEnable: Boolean -) : RecyclerView.Adapter() { - private var episodeViewDataList: MutableList = mutableListOf() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShowHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val showListItemBinding = ShowListItemBinding - .inflate(layoutInflater, parent, false) - val showHolder = ShowHolder(showListItemBinding) - showHolder.binding.showFavoriteIcon = favoritesFeatureEnable - showHolder.binding.favorite.setOnClickListener { onFavouriteIconClicked(showHolder.absoluteAdapterPosition) } - return showHolder - } - - fun updateList(newList: MutableList) { - val showDiffUtilCallback = ShowDiffUtilCallback(episodeViewDataList, newList) - val diffResult = DiffUtil.calculateDiff(showDiffUtilCallback) - episodeViewDataList = newList - diffResult.dispatchUpdatesTo(this) - } - - private fun onFavouriteIconClicked(position: Int) { - if (position != RecyclerView.NO_POSITION) { - val episodeViewData = episodeViewDataList[position] - val isFavorite = episodeViewData.showViewData.isFavoriteShow - val updatedShowViewData = - episodeViewData.showViewData.copy(isFavoriteShow = !isFavorite) - val updatedEpisodeViewData = episodeViewData.copy(showViewData = updatedShowViewData) - episodeViewDataList[position] = updatedEpisodeViewData - notifyItemChanged(position) - callback.onFavoriteClicked(episodeViewData.showViewData) - } - } - - override fun onBindViewHolder(holder: ShowHolder, position: Int) { - val episodeViewData = episodeViewDataList[position] - configureImage(holder.binding.showImage, episodeViewData.showViewData.show) - configureFavoriteIcon(holder.binding.favorite, episodeViewData.showViewData.isFavoriteShow) - } - - override fun onBindViewHolder( - holder: ShowHolder, - position: Int, - payloads: List - ) { - if (payloads.isNotEmpty()) { - val bundle = payloads[0] as Bundle - val isFavorite = bundle.getBoolean(IS_FAVORITE) - configureFavoriteIcon(holder.binding.favorite, isFavorite) - } else { - onBindViewHolder(holder, position) - } - } - - private fun configureFavoriteIcon(favoriteIcon: ImageView, favorite: Boolean) { - if (favorite) { - val favoriteDrawable = AppCompatResources - .getDrawable(favoriteIcon.context, R.drawable.favorite) - favoriteIcon.setImageDrawable(favoriteDrawable) - } else { - val unFavoriteDrawable = AppCompatResources - .getDrawable(favoriteIcon.context, R.drawable.favorite_border) - favoriteIcon.setImageDrawable(unFavoriteDrawable) - } - } - - private fun configureImage(showImage: ImageView, show: Show) { - if (show.image != null) { - Glide.with(showImage.context).load(show.image[ORIGINAL_IMAGE]) - .apply(RequestOptions.placeholderOf(R.color.grey)) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(showImage) - } - } - - override fun getItemCount(): Int { - return episodeViewDataList.size - } - - class ShowHolder(val binding: ShowListItemBinding) : RecyclerView.ViewHolder(binding.root) - - interface Callback { - fun onFavoriteClicked(showViewData: HomeViewData.ShowViewData) - } - - companion object { - private const val ORIGINAL_IMAGE = "original" - } -} diff --git a/app/src/main/java/com/android/tvflix/main/MainActivity.kt b/app/src/main/java/com/android/tvflix/main/MainActivity.kt new file mode 100644 index 0000000..aafd9da --- /dev/null +++ b/app/src/main/java/com/android/tvflix/main/MainActivity.kt @@ -0,0 +1,560 @@ +package com.android.tvflix.main + +import android.widget.Toast +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.core.text.HtmlCompat +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import coil.compose.AsyncImage +import com.android.tvflix.R +import com.android.tvflix.config.FavoritesFeatureFlag +import com.android.tvflix.db.favouriteshow.FavoriteShow +import com.android.tvflix.favorite.FavoriteShowState +import com.android.tvflix.favorite.FavoriteShowsViewModel +import com.android.tvflix.home.HomeViewData +import com.android.tvflix.home.HomeViewModel +import com.android.tvflix.home.HomeViewState +import com.android.tvflix.network.home.Show +import com.android.tvflix.shows.ShowsViewModel +import com.android.tvflix.splash.SplashViewModel +import com.android.tvflix.splash.SplashViewState +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @JvmField + @FavoritesFeatureFlag + @Inject + var favoritesFeatureEnable: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TvFlixApp(favoritesFeatureEnable = favoritesFeatureEnable) + } + } +} + +private object Route { + const val Splash = "splash" + const val Home = "home" + const val Shows = "shows" + const val Favorites = "favorites" +} + +@Composable +private fun TvFlixApp(favoritesFeatureEnable: Boolean) { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route ?: Route.Splash + + Scaffold( + topBar = { + AppBar( + route = currentRoute, + favoritesFeatureEnable = favoritesFeatureEnable, + onShowsClick = { navController.navigate(Route.Shows) { launchSingleTop = true } }, + onFavoritesClick = { navController.navigate(Route.Favorites) { launchSingleTop = true } }, + onBackClick = { navController.popBackStack() } + ) + } + ) { padding -> + NavHost( + navController = navController, + startDestination = Route.Splash, + modifier = if (currentRoute == Route.Splash) Modifier else Modifier.padding(padding) + ) { + composable(Route.Splash) { + SplashRoute(navController = navController) + } + composable(Route.Home) { + HomeRoute(favoritesFeatureEnable = favoritesFeatureEnable) + } + composable(Route.Shows) { + ShowsRoute() + } + composable(Route.Favorites) { + FavoritesRoute() + } + } + } +} + +@Composable +private fun AppBar( + route: String, + favoritesFeatureEnable: Boolean, + onShowsClick: () -> Unit, + onFavoritesClick: () -> Unit, + onBackClick: () -> Unit +) { + if (route == Route.Splash) return + + TopAppBar( + title = { + Text( + text = when (route) { + Route.Shows -> stringResource(id = R.string.shows) + Route.Favorites -> stringResource(id = R.string.favorite_shows) + else -> stringResource(id = R.string.app_name) + } + ) + }, + backgroundColor = colorResource(id = R.color.colorPrimary), + contentColor = colorResource(id = R.color.white), + navigationIcon = if (route == Route.Shows || route == Route.Favorites) { + { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + } else { + null + }, + actions = { + if (route == Route.Home) { + Text( + text = stringResource(id = R.string.shows), + modifier = Modifier + .testTag("nav_shows") + .clickable(onClick = onShowsClick) + .padding(8.dp) + ) + if (favoritesFeatureEnable) { + IconButton( + onClick = onFavoritesClick, + modifier = Modifier.testTag("nav_favorites") + ) { + Icon( + painter = painterResource(id = R.drawable.favorite), + contentDescription = stringResource(id = R.string.favorite_shows), + tint = colorResource(id = R.color.colorAccent) + ) + } + } + } + } + ) +} + +@Composable +private fun SplashRoute(navController: NavHostController) { + val splashViewModel: SplashViewModel = hiltViewModel() + val splashState by splashViewModel.splashViewStateFlow.collectAsState() + + LaunchedEffect(Unit) { + splashViewModel.fetchConfig() + } + + LaunchedEffect(splashState) { + if (splashState is SplashViewState.NavigateToHome) { + navController.navigate(Route.Home) { + popUpTo(Route.Splash) { inclusive = true } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .testTag("splash_screen") + .background(colorResource(id = R.color.colorPrimaryDark)), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Image( + painter = painterResource(id = R.drawable.ic_splash), + contentDescription = stringResource(id = R.string.app_name), + modifier = Modifier.size(96.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator(color = colorResource(id = R.color.colorAccent)) + } + } +} + +@Composable +private fun HomeRoute(favoritesFeatureEnable: Boolean) { + val homeViewModel: HomeViewModel = hiltViewModel() + val homeState by homeViewModel.homeViewStateFlow.collectAsState() + val context = LocalContext.current + var homeViewData by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + homeViewModel.onScreenCreated() + } + + LaunchedEffect(homeState) { + when (val state = homeState) { + is HomeViewState.Success -> homeViewData = state.homeViewData + is HomeViewState.NetworkError -> + state.message?.let { Toast.makeText(context, it, Toast.LENGTH_LONG).show() } + is HomeViewState.AddedToFavorites -> Toast + .makeText( + context, + context.getString(R.string.added_to_favorites, state.show.name), + Toast.LENGTH_SHORT + ) + .show() + is HomeViewState.RemovedFromFavorites -> Toast + .makeText( + context, + context.getString(R.string.removed_from_favorites, state.show.name), + Toast.LENGTH_SHORT + ) + .show() + HomeViewState.Loading -> Unit + } + } + + when { + homeViewData != null -> { + HomeScreen( + homeViewData = homeViewData!!, + favoritesFeatureEnable = favoritesFeatureEnable, + onFavoriteClick = { episode -> + val updatedList = homeViewData!!.episodes.map { + if (it.id == episode.id) { + it.copy( + showViewData = it.showViewData.copy( + isFavoriteShow = !it.showViewData.isFavoriteShow + ) + ) + } else { + it + } + } + homeViewData = homeViewData!!.copy(episodes = updatedList) + homeViewModel.onFavoriteClick(episode.showViewData) + } + ) + } + homeState is HomeViewState.Loading -> CenterLoader() + else -> CenterError(text = "Unable to load shows.") + } +} + +@Composable +private fun HomeScreen( + homeViewData: HomeViewData, + favoritesFeatureEnable: Boolean, + onFavoriteClick: (HomeViewData.EpisodeViewData) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + ) { + Text( + text = homeViewData.heading, + style = MaterialTheme.typography.h6, + color = colorResource(id = R.color.white), + modifier = Modifier + .testTag("home_heading") + .padding(vertical = 8.dp) + ) + val rows = remember(homeViewData.episodes) { homeViewData.episodes.chunked(2) } + LazyColumn( + modifier = Modifier.testTag("home_list"), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(rows.size) { rowIndex -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + rows[rowIndex].forEach { episode -> + ShowPosterCard( + imageUrl = episode.showViewData.show.image?.get("original"), + favoriteEnabled = favoritesFeatureEnable, + isFavorite = episode.showViewData.isFavoriteShow, + onFavoriteClick = { onFavoriteClick(episode) }, + modifier = Modifier.weight(1f) + ) + } + if (rows[rowIndex].size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +@Composable +private fun ShowPosterCard( + imageUrl: String?, + favoriteEnabled: Boolean, + isFavorite: Boolean, + onFavoriteClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(260.dp) + .clip(RoundedCornerShape(4.dp)) + ) { + AsyncImage( + model = imageUrl, + contentDescription = stringResource(id = R.string.show_image), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + if (favoriteEnabled) { + IconButton( + onClick = onFavoriteClick, + modifier = Modifier + .padding(8.dp) + .background( + color = Color.Black.copy(alpha = 0.35f), + shape = RoundedCornerShape(20.dp) + ) + .align(Alignment.TopStart) + ) { + Icon( + painter = painterResource( + id = if (isFavorite) R.drawable.favorite else R.drawable.favorite_border + ), + contentDescription = "Favorite", + tint = colorResource(id = R.color.colorAccent) + ) + } + } + } +} + +@Composable +private fun ShowsRoute() { + val showsViewModel: ShowsViewModel = hiltViewModel() + val shows = remember(showsViewModel) { showsViewModel.shows() }.collectAsLazyPagingItems() + + when (val refresh = shows.loadState.refresh) { + is LoadState.Loading -> if (shows.itemCount == 0) CenterLoader() + is LoadState.Error -> if (shows.itemCount == 0) { + CenterError( + text = refresh.error.localizedMessage ?: "No internet connection", + showRetry = true, + onRetry = shows::retry + ) + } + is LoadState.NotLoading -> Unit + } + + if (shows.itemCount > 0) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag("shows_list"), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(shows.itemCount) { index -> + shows[index]?.let { ShowDetailsCard(show = it) } + } + if (shows.loadState.append is LoadState.Loading) { + item { CenterLoader(modifier = Modifier.fillMaxWidth()) } + } + if (shows.loadState.append is LoadState.Error) { + item { + CenterError( + text = (shows.loadState.append as LoadState.Error).error.localizedMessage + ?: "Failed to load more", + showRetry = true, + onRetry = shows::retry + ) + } + } + } + } +} + +@Composable +private fun ShowDetailsCard(show: Show) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(8.dp) + ) { + AsyncImage( + model = show.image?.get("original"), + contentDescription = stringResource(id = R.string.show_image), + modifier = Modifier + .width(120.dp) + .height(150.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.size(8.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = show.name, style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) + show.summary?.let { + Text( + text = HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString(), + maxLines = 4, + style = MaterialTheme.typography.body2 + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (show.rating?.get("average") != null) { + stringResource(id = R.string.rating, show.rating?.get("average") ?: "") + } else { + stringResource(id = R.string.not_rated) + }, + style = MaterialTheme.typography.caption + ) + show.premiered?.let { + Text( + text = stringResource(id = R.string.premiered_on, it), + style = MaterialTheme.typography.caption + ) + } + } + } + } +} + +@Composable +private fun FavoritesRoute() { + val favoriteShowsViewModel: FavoriteShowsViewModel = hiltViewModel() + val favoriteState by favoriteShowsViewModel.favoriteShowsStateFlow.collectAsState() + + LaunchedEffect(Unit) { + favoriteShowsViewModel.loadFavoriteShows() + } + + when (val state = favoriteState) { + FavoriteShowState.Loading -> CenterLoader() + is FavoriteShowState.AllFavorites -> FavoriteShowsGrid(favoriteShows = state.favoriteShows) + FavoriteShowState.Empty -> CenterError(text = stringResource(id = R.string.favorite_hint_msg)) + is FavoriteShowState.Error -> CenterError(text = state.message) + is FavoriteShowState.AddedToFavorites -> Unit + is FavoriteShowState.RemovedFromFavorites -> Unit + } +} + +@Composable +private fun FavoriteShowsGrid(favoriteShows: List) { + val rows = remember(favoriteShows) { favoriteShows.chunked(2) } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag("favorites_list") + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(rows.size) { rowIndex -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + rows[rowIndex].forEach { show -> + ShowPosterCard( + imageUrl = show.imageUrl, + favoriteEnabled = false, + isFavorite = false, + onFavoriteClick = {}, + modifier = Modifier.weight(1f) + ) + } + if (rows[rowIndex].size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun CenterLoader(modifier: Modifier = Modifier.fillMaxSize()) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = colorResource(id = R.color.colorAccent)) + } +} + +@Composable +private fun CenterError( + text: String, + showRetry: Boolean = false, + onRetry: (() -> Unit)? = null +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = text, color = colorResource(id = R.color.red)) + if (showRetry && onRetry != null) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onRetry, + modifier = Modifier.testTag("retry") + ) { + Text(text = stringResource(id = R.string.retry)) + } + } + } + } +} diff --git a/app/src/main/java/com/android/tvflix/shows/AllShowsActivity.kt b/app/src/main/java/com/android/tvflix/shows/AllShowsActivity.kt deleted file mode 100644 index 0dcf945..0000000 --- a/app/src/main/java/com/android/tvflix/shows/AllShowsActivity.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.android.tvflix.shows - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.paging.LoadState -import com.android.tvflix.R -import com.android.tvflix.databinding.ActivityAllShowsBinding -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest - -@AndroidEntryPoint -class AllShowsActivity : AppCompatActivity() { - private val showsViewModel: ShowsViewModel by viewModels() - private lateinit var adapter: ShowsPagedAdapter - private lateinit var showsBinding: ActivityAllShowsBinding - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - showsBinding = ActivityAllShowsBinding.inflate(LayoutInflater.from(this)) - setContentView(showsBinding.root) - setToolbar() - initAdapter() - getShows() - showsBinding.retry.setOnClickListener { adapter.retry() } - } - - private fun getShows() { - lifecycleScope.launchWhenStarted { - showsViewModel.shows().collectLatest { adapter.submitData(it) } - } - } - - private fun initAdapter() { - adapter = ShowsPagedAdapter(ShowDiffUtilItemCallback()) - showsBinding.shows.adapter = adapter.withLoadStateFooter( - footer = ShowsLoadStateAdapter { adapter.retry() } - ) - - adapter.addLoadStateListener { combinedLoadStates -> - // Handle the initial load state - when (val loadState = combinedLoadStates.source.refresh) { - is LoadState.NotLoading -> { - showsBinding.progress.isVisible = false - showsBinding.shows.isVisible = true - showsBinding.errorGroup.isVisible = false - } - is LoadState.Loading -> { - showsBinding.progress.isVisible = true - showsBinding.errorGroup.isVisible = false - } - is LoadState.Error -> { - showsBinding.progress.isVisible = false - showsBinding.errorGroup.isVisible = true - showsBinding.errorMsg.text = loadState.error.localizedMessage - } - } - - // Show message to the user when an error comes while loading the next page - val errorState = combinedLoadStates.source.append as? LoadState.Error - ?: combinedLoadStates.append as? LoadState.Error - errorState?.let { - Toast.makeText(this, errorState.error.localizedMessage, Toast.LENGTH_SHORT).show() - } - } - } - - private fun setToolbar() { - val toolbar = showsBinding.toolbar.toolbar - setSupportActionBar(toolbar) - toolbar.setTitleTextColor(ContextCompat.getColor(this, android.R.color.white)) - toolbar.setSubtitleTextColor(ContextCompat.getColor(this, android.R.color.white)) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - setTitle(R.string.shows) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return if (item.itemId == android.R.id.home) { - finish() - true - } else { - super.onOptionsItemSelected(item) - } - } - - companion object { - fun start(context: Context) { - val starter = Intent(context, AllShowsActivity::class.java) - context.startActivity(starter) - } - } -} diff --git a/app/src/main/java/com/android/tvflix/shows/ShowDiffUtilItemCallback.kt b/app/src/main/java/com/android/tvflix/shows/ShowDiffUtilItemCallback.kt deleted file mode 100644 index a0626b5..0000000 --- a/app/src/main/java/com/android/tvflix/shows/ShowDiffUtilItemCallback.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.android.tvflix.shows - -import androidx.recyclerview.widget.DiffUtil -import com.android.tvflix.network.home.Show - -class ShowDiffUtilItemCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Show, newItem: Show): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Show, newItem: Show): Boolean { - return oldItem.name == newItem.name - } -} diff --git a/app/src/main/java/com/android/tvflix/shows/ShowsLoadStateAdapter.kt b/app/src/main/java/com/android/tvflix/shows/ShowsLoadStateAdapter.kt deleted file mode 100644 index 9589bdd..0000000 --- a/app/src/main/java/com/android/tvflix/shows/ShowsLoadStateAdapter.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.android.tvflix.shows - -import android.view.ViewGroup -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter - -class ShowsLoadStateAdapter(private val retry: () -> Unit) : - LoadStateAdapter() { - override fun onBindViewHolder(holder: ShowsStateViewHolder, loadState: LoadState) { - holder.bind(loadState) - } - - override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ShowsStateViewHolder { - return ShowsStateViewHolder.create(parent, retry) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/android/tvflix/shows/ShowsPagedAdapter.kt b/app/src/main/java/com/android/tvflix/shows/ShowsPagedAdapter.kt deleted file mode 100644 index 23c50e1..0000000 --- a/app/src/main/java/com/android/tvflix/shows/ShowsPagedAdapter.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.android.tvflix.shows - -import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.android.tvflix.R -import com.android.tvflix.databinding.AllShowListItemBinding -import com.android.tvflix.network.home.Show -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.RequestOptions - -class ShowsPagedAdapter constructor( - diffCallback: DiffUtil.ItemCallback -) : PagingDataAdapter(diffCallback) { - private lateinit var context: Context - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - context = parent.context - val layoutInflater = LayoutInflater.from(context) - val showListItemBinding = AllShowListItemBinding.inflate(layoutInflater, parent, false) - return ShowHolder(showListItemBinding) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is ShowHolder) { - val show = getItem(position) - holder.binding.show = show - if (show!!.rating != null) { - holder.binding.rating = show.rating!!["average"] - } - configureImage(holder, show) - } - } - - private fun configureImage(holder: ShowHolder, show: Show) { - if (show.image != null) { - Glide.with(context).load(show.image["original"]) - .apply(RequestOptions.placeholderOf(R.color.grey)) - .centerCrop() - .transition(DrawableTransitionOptions.withCrossFade()) - .into(holder.binding.showImage) - } - } - - class ShowHolder(val binding: AllShowListItemBinding) : RecyclerView.ViewHolder(binding.root) -} diff --git a/app/src/main/java/com/android/tvflix/shows/ShowsStateViewHolder.kt b/app/src/main/java/com/android/tvflix/shows/ShowsStateViewHolder.kt deleted file mode 100644 index a7a100a..0000000 --- a/app/src/main/java/com/android/tvflix/shows/ShowsStateViewHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.android.tvflix.shows - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.paging.LoadState -import androidx.recyclerview.widget.RecyclerView -import com.android.tvflix.databinding.NetworkFailureListItemBinding - -class ShowsStateViewHolder( - private val binding: NetworkFailureListItemBinding, - retry: () -> Unit -) : RecyclerView.ViewHolder(binding.root) { - init { - binding.retry.setOnClickListener { retry.invoke() } - } - - fun bind(loadState: LoadState) { - if (loadState is LoadState.Error) { - binding.errorMsg.text = loadState.error.localizedMessage - } - binding.progress.isVisible = loadState is LoadState.Loading - binding.errorGroup.isVisible = loadState !is LoadState.Loading - } - - companion object { - fun create(parent: ViewGroup, retry: () -> Unit): ShowsStateViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = NetworkFailureListItemBinding.inflate(layoutInflater, parent, false) - return ShowsStateViewHolder(binding, retry) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/android/tvflix/splash/SplashActivity.kt b/app/src/main/java/com/android/tvflix/splash/SplashActivity.kt deleted file mode 100644 index 01b2cdd..0000000 --- a/app/src/main/java/com/android/tvflix/splash/SplashActivity.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.android.tvflix.splash - -import android.os.Bundle -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import com.android.tvflix.R -import com.android.tvflix.home.HomeActivity -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collect - -@AndroidEntryPoint -class SplashActivity : AppCompatActivity() { - private val splashViewModel: SplashViewModel by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_splash) - splashViewModel.fetchConfig() - lifecycleScope.launchWhenStarted { - splashViewModel.splashViewStateFlow.collect { - when (it) { - is SplashViewState.Idle -> { - // do nothing - } - is SplashViewState.NavigateToHome -> HomeActivity.start(this@SplashActivity) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/android/tvflix/splash/SplashViewModel.kt b/app/src/main/java/com/android/tvflix/splash/SplashViewModel.kt index 5cb475d..210491f 100644 --- a/app/src/main/java/com/android/tvflix/splash/SplashViewModel.kt +++ b/app/src/main/java/com/android/tvflix/splash/SplashViewModel.kt @@ -24,7 +24,13 @@ constructor( val splashViewStateFlow = _splashViewStateFlow.asStateFlow() fun fetchConfig() { viewModelScope.launch(dispatcher) { - appConfig.fetch() + try { + appConfig.fetch() + } catch (_: Exception) { + // Remote config fetch can fail due to network issues or + // Firebase Installations service unavailability. Continue + // with cached/default config values. + } _splashViewStateFlow.emit(SplashViewState.NavigateToHome) } } diff --git a/app/src/main/java/com/android/tvflix/utils/GridItemDecoration.kt b/app/src/main/java/com/android/tvflix/utils/GridItemDecoration.kt deleted file mode 100644 index 0aa8d19..0000000 --- a/app/src/main/java/com/android/tvflix/utils/GridItemDecoration.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.android.tvflix.utils - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -class GridItemDecoration(private val space: Int, private val noOfColumns: Int) : - RecyclerView.ItemDecoration() { - - override fun getItemOffsets( - outRect: Rect, view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - outRect.left = space - outRect.right = space - outRect.bottom = space - outRect.top = space - when { - parent.getChildLayoutPosition(view) % noOfColumns == 0 -> { - outRect.left = 0 - outRect.right = space - } - parent.getChildLayoutPosition(view) % noOfColumns == noOfColumns - 1 -> { - outRect.left = space - outRect.right = 0 - } - } - } -} diff --git a/app/src/main/res/drawable/favorite_gradient.xml b/app/src/main/res/drawable/favorite_gradient.xml deleted file mode 100644 index fc19973..0000000 --- a/app/src/main/res/drawable/favorite_gradient.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml deleted file mode 100644 index 7a67ec3..0000000 --- a/app/src/main/res/drawable/splash_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_all_shows.xml b/app/src/main/res/layout/activity_all_shows.xml deleted file mode 100644 index 72fec58..0000000 --- a/app/src/main/res/layout/activity_all_shows.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - -