mirror of
https://github.com/aculix/Channelify.git
synced 2026-06-12 21:08:27 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="aculix.channelify.app">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".Channelify"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
|
||||
<activity android:name=".activity.SplashActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/SplashTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".activity.MainActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustPan"/>
|
||||
|
||||
<activity
|
||||
android:name=".activity.VideoPlayerActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||
android:screenOrientation="portrait"
|
||||
/>
|
||||
|
||||
<meta-data
|
||||
android:name="preloaded_fonts"
|
||||
android:resource="@array/preloaded_fonts" />
|
||||
|
||||
<!-- FCM -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_notification" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/colorPrimary" />
|
||||
|
||||
<!-- Admob -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="@string/admob_app_id"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,89 @@
|
||||
package aculix.channelify.app
|
||||
|
||||
import aculix.channelify.app.di.*
|
||||
import aculix.channelify.app.sharedpref.AppPref
|
||||
import android.app.Application
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.chibatching.kotpref.Kotpref
|
||||
import com.google.android.gms.ads.MobileAds
|
||||
import com.google.android.gms.ads.RequestConfiguration
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.startKoin
|
||||
import saschpe.android.customtabs.CustomTabsActivityLifecycleCallbacks
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
class Channelify : Application() {
|
||||
|
||||
companion object {
|
||||
var isAdEnabled = true
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isAdEnabled = resources.getBoolean(R.bool.enable_ads)
|
||||
|
||||
initializeKotpref()
|
||||
setThemeMode()
|
||||
initializeTimber()
|
||||
initializeKoin()
|
||||
initializeCustomTabs()
|
||||
|
||||
if (resources.getBoolean(R.bool.enable_ads)) initializeAdmob()
|
||||
}
|
||||
|
||||
private fun setThemeMode() {
|
||||
if (AppPref.isLightThemeEnabled) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
} else {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun initializeTimber() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeKotpref() {
|
||||
Kotpref.init(this)
|
||||
}
|
||||
|
||||
private fun initializeKoin() {
|
||||
startKoin {
|
||||
androidLogger()
|
||||
androidContext(this@Channelify)
|
||||
modules(
|
||||
listOf(
|
||||
appModule,
|
||||
homeModule,
|
||||
videoPlayerModule,
|
||||
commentsModule,
|
||||
videoDetailsModule,
|
||||
commentRepliesModule,
|
||||
playlistsModule,
|
||||
playlistVideosModule,
|
||||
favoritesModule,
|
||||
searchModule,
|
||||
aboutModule
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeAdmob() {
|
||||
MobileAds.initialize(this) {
|
||||
val testDeviceIds = listOf("7BD04413716C0B3DD5C73F814E02D21A")
|
||||
val configuration =
|
||||
RequestConfiguration.Builder().setTestDeviceIds(testDeviceIds).build()
|
||||
MobileAds.setRequestConfiguration(configuration)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeCustomTabs() {
|
||||
registerActivityLifecycleCallbacks(CustomTabsActivityLifecycleCallbacks())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package aculix.channelify.app.activity
|
||||
|
||||
import aculix.channelify.app.Channelify
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.utils.getAdaptiveBannerAdSize
|
||||
import aculix.core.extensions.makeGone
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.gms.ads.*
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import java.util.*
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var adView: AdView
|
||||
private var initialLayoutComplete = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
|
||||
val navController = findNavController(R.id.navHostFragment)
|
||||
bottomNavView.setupWithNavController(navController)
|
||||
|
||||
if (Channelify.isAdEnabled) setupAd() else adViewContainerMain.makeGone()
|
||||
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (Channelify.isAdEnabled) adView.pause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
if (Channelify.isAdEnabled) adView.resume()
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (Channelify.isAdEnabled) adView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setupAd() {
|
||||
adView = AdView(this)
|
||||
adViewContainerMain.addView(adView)
|
||||
adViewContainerMain.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
if (!initialLayoutComplete) {
|
||||
initialLayoutComplete = true
|
||||
|
||||
adView.adUnitId = getString(R.string.main_banner_ad_id)
|
||||
adView.adSize = getAdaptiveBannerAdSize(adViewContainerMain)
|
||||
adView.loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package aculix.channelify.app.activity
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
|
||||
class SplashActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package aculix.channelify.app.activity
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.fragment.VideoDetailsFragment
|
||||
import aculix.channelify.app.utils.FullScreenHelper
|
||||
import aculix.channelify.app.viewmodel.VideoPlayerViewModel
|
||||
import aculix.core.extensions.toast
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatToggleButton
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.navigation.findNavController
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.InterstitialAd
|
||||
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer
|
||||
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener
|
||||
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerFullScreenListener
|
||||
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.loadOrCueVideo
|
||||
import kotlinx.android.synthetic.main.activity_video_player.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
|
||||
class VideoPlayerActivity : AppCompatActivity(R.layout.activity_video_player) {
|
||||
|
||||
companion object {
|
||||
const val VIDEO_ID = "video_id"
|
||||
|
||||
fun startActivity(context: Context?, videoId: String) {
|
||||
val intent = Intent(context, VideoPlayerActivity::class.java).apply {
|
||||
putExtra(VIDEO_ID, videoId)
|
||||
}
|
||||
context?.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel by viewModel<VideoPlayerViewModel>() // Lazy inject ViewModel
|
||||
|
||||
lateinit var fullScreenHelper: FullScreenHelper
|
||||
lateinit var videoId: String
|
||||
private var videoElapsedTimeInSeconds = 0f
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
fullScreenHelper = FullScreenHelper(this)
|
||||
videoId = intent.getStringExtra(VIDEO_ID)!!
|
||||
|
||||
// Passing the videoId as argument to the start destination
|
||||
findNavController(R.id.navHostVideoPlayer).setGraph(
|
||||
R.navigation.video_player_graph,
|
||||
bundleOf(VideoDetailsFragment.VIDEO_ID to videoId)
|
||||
)
|
||||
|
||||
initYouTubePlayer()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (ytVideoPlayerView.isFullScreen()) ytVideoPlayerView.exitFullScreen() else super.onBackPressed()
|
||||
}
|
||||
|
||||
private fun initYouTubePlayer() {
|
||||
lifecycle.addObserver(ytVideoPlayerView)
|
||||
|
||||
ytVideoPlayerView.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() {
|
||||
override fun onReady(youTubePlayer: YouTubePlayer) {
|
||||
youTubePlayer.loadOrCueVideo(lifecycle, videoId, 0f)
|
||||
addFullScreenListenerToPlayer()
|
||||
setupCustomActions(youTubePlayer)
|
||||
}
|
||||
|
||||
override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) {
|
||||
videoElapsedTimeInSeconds = second
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the forward and rewind action button to the Player
|
||||
*/
|
||||
private fun setupCustomActions(youTubePlayer: YouTubePlayer) {
|
||||
ytVideoPlayerView.getPlayerUiController()
|
||||
.setCustomAction1(
|
||||
ContextCompat.getDrawable(this, R.drawable.ic_rewind)!!,
|
||||
View.OnClickListener {
|
||||
videoElapsedTimeInSeconds -= 10
|
||||
youTubePlayer.seekTo(videoElapsedTimeInSeconds)
|
||||
})
|
||||
.setCustomAction2(
|
||||
ContextCompat.getDrawable(this, R.drawable.ic_forward)!!,
|
||||
View.OnClickListener {
|
||||
videoElapsedTimeInSeconds += 10
|
||||
youTubePlayer.seekTo(videoElapsedTimeInSeconds)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the orientation of the activity based on the
|
||||
* change of the player state (Full screen or not)
|
||||
*/
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
private fun addFullScreenListenerToPlayer() {
|
||||
ytVideoPlayerView.addFullScreenListener(object : YouTubePlayerFullScreenListener {
|
||||
override fun onYouTubePlayerEnterFullScreen() {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
fullScreenHelper.enterFullScreen()
|
||||
}
|
||||
|
||||
override fun onYouTubePlayerExitFullScreen() {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
fullScreenHelper.exitFullScreen()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.ChannelInfo
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface ChannelInfoService {
|
||||
|
||||
@GET("channels")
|
||||
suspend fun getChannelInfo(
|
||||
@Query("id") channelId: String,
|
||||
@Query("part") part: String = "snippet, statistics, brandingSettings",
|
||||
@Query("fields") fields: String = "items(snippet(title, description, publishedAt, thumbnails), statistics(viewCount, subscriberCount, videoCount), brandingSettings(image(bannerMobileHdImageUrl, bannerMobileMediumHdImageUrl)))",
|
||||
@Query("maxResults") maxResults: Int = 1
|
||||
): Response<ChannelInfo>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.ChannelUploadsPlaylistInfo
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface ChannelsService {
|
||||
|
||||
@GET("channels")
|
||||
suspend fun getChannelUploadsPlaylistInfo(
|
||||
@Query("id") channelId: String,
|
||||
@Query("part") part: String = "contentDetails"
|
||||
): Response<ChannelUploadsPlaylistInfo>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface CommentRepliesService {
|
||||
|
||||
/**
|
||||
* Gets the list of replies for a particular comment
|
||||
*/
|
||||
@GET("comments")
|
||||
suspend fun getCommentReplies(
|
||||
@Query("parentId") commentId: String,
|
||||
@Query("pageToken") pageToken: String?,
|
||||
@Query("part") part: String = "snippet",
|
||||
@Query("fields") fields: String = "nextPageToken, items(snippet(authorDisplayName, authorProfileImageUrl, textOriginal, likeCount, publishedAt, updatedAt))",
|
||||
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
|
||||
): Response<CommentReply>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface CommentService {
|
||||
|
||||
/**
|
||||
* Gets the list of comments of a particular video
|
||||
*/
|
||||
@GET("commentThreads")
|
||||
suspend fun getVideoComments(
|
||||
@Query("videoId") videoId: String,
|
||||
@Query("pageToken") pageToken: String?,
|
||||
@Query("order") sortOrder: String,
|
||||
@Query("part") part: String = "snippet",
|
||||
@Query("fields") fields: String = "nextPageToken, items(snippet(topLevelComment(id, snippet(authorDisplayName, authorProfileImageUrl, textOriginal, likeCount, publishedAt, updatedAt)), totalReplyCount))",
|
||||
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
|
||||
): Response<Comment>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.ChannelUploadsPlaylistInfo
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface PlaylistItemsService {
|
||||
|
||||
@GET("playlistItems")
|
||||
suspend fun getPlaylistVideos(
|
||||
@Query("playlistId") playlistId: String,
|
||||
@Query("pageToken") pageToken: String?,
|
||||
@Query("part") part: String = "snippet,contentDetails",
|
||||
@Query("fields") fields: String = "nextPageToken, prevPageToken, items(snippet(title, thumbnails), contentDetails(videoId, videoPublishedAt))",
|
||||
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
|
||||
): Response<PlaylistItemInfo>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.Playlist
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface PlaylistsService {
|
||||
|
||||
@GET("playlists")
|
||||
suspend fun getPlaylists(
|
||||
@Query("channelId") channelId: String,
|
||||
@Query("pageToken") pageToken: String?,
|
||||
@Query("part") part: String = "snippet,contentDetails",
|
||||
@Query("fields") fields: String = "nextPageToken, items(id, snippet(publishedAt, title, description, thumbnails), contentDetails)",
|
||||
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
|
||||
): Response<Playlist>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.QueryMap
|
||||
|
||||
interface SearchVideoService {
|
||||
|
||||
@GET("search")
|
||||
suspend fun searchVideos(
|
||||
@Query("q") searchQuery: String,
|
||||
@Query("channelId") channelId: String,
|
||||
@Query("pageToken") pageToken: String?,
|
||||
@QueryMap defaultQueryMap: HashMap<String, String>,
|
||||
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
|
||||
): Response<SearchedVideo>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package aculix.channelify.app.api
|
||||
|
||||
import aculix.channelify.app.model.Video
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface VideosService {
|
||||
|
||||
@GET("videos")
|
||||
suspend fun getVideoInfo(
|
||||
@Query("id") videoId: String,
|
||||
@Query("part") part: String = "snippet, statistics",
|
||||
@Query("fields") fields: String = "items(snippet(publishedAt, title, description, thumbnails), statistics)",
|
||||
@Query("maxResults") maxResults: Int = 1
|
||||
): Response<Video>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package aculix.channelify.app.db
|
||||
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [FavoriteVideo::class], version = 1)
|
||||
abstract class ChannelifyDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun favoriteVideoDao(): FavoriteVideoDao
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package aculix.channelify.app.db
|
||||
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface FavoriteVideoDao {
|
||||
|
||||
@Insert
|
||||
suspend fun addFavoriteVideo(favoriteVideo: FavoriteVideo)
|
||||
|
||||
@Delete
|
||||
suspend fun removeFavoriteVideo(favoriteVideo: FavoriteVideo)
|
||||
|
||||
@Query("DELETE FROM favorite_videos where id in (:idList)")
|
||||
suspend fun removeMultipleFavoriteVideos(idList: List<String>)
|
||||
|
||||
@Query("SELECT id FROM favorite_videos WHERE id = :id LIMIT 1")
|
||||
suspend fun getFavoriteVideoId(id: String): String?
|
||||
|
||||
@Query("SELECT * FROM favorite_videos ORDER BY timeInMillis DESC")
|
||||
suspend fun getAllFavoriteVideos(): List<FavoriteVideo>
|
||||
|
||||
@Query("DELETE FROM favorite_videos")
|
||||
suspend fun removeAllFavoriteVideos()
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.api.ChannelInfoService
|
||||
import aculix.channelify.app.repository.AboutRepository
|
||||
import aculix.channelify.app.viewmodel.AboutViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val aboutModule = module {
|
||||
|
||||
factory { provideChannelInfoService(get()) }
|
||||
|
||||
single { AboutRepository(get()) }
|
||||
|
||||
viewModel { AboutViewModel(get(), androidContext().getString(R.string.channel_id), androidContext()) }
|
||||
}
|
||||
|
||||
private fun provideChannelInfoService(retrofit: Retrofit) =
|
||||
retrofit.create(ChannelInfoService::class.java)
|
||||
@@ -0,0 +1,120 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.BuildConfig
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.db.ChannelifyDatabase
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.room.Room
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import timber.log.Timber
|
||||
import java.security.MessageDigest
|
||||
|
||||
|
||||
val appModule = module {
|
||||
|
||||
single { provideApplicationSignature(androidContext()) }
|
||||
single { provideOkHttpClient(androidContext(), get()) }
|
||||
single { provideRetrofit(get()) }
|
||||
single { provideRoomDatabase(androidApplication()) }
|
||||
}
|
||||
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
fun provideApplicationSignature(context: Context): List<String> {
|
||||
val signatureList: List<String>
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val signature = context.packageManager.getPackageInfo(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
PackageManager.GET_SIGNING_CERTIFICATES
|
||||
).signingInfo
|
||||
signatureList = if (signature.hasMultipleSigners()) {
|
||||
signature.apkContentsSigners.map {
|
||||
val digest = MessageDigest.getInstance("SHA")
|
||||
digest.update(it.toByteArray())
|
||||
bytesToHex(digest.digest())
|
||||
}
|
||||
} else {
|
||||
signature.signingCertificateHistory.map {
|
||||
val digest = MessageDigest.getInstance("SHA")
|
||||
digest.update(it.toByteArray())
|
||||
bytesToHex(digest.digest())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val signature = context.packageManager.getPackageInfo(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
PackageManager.GET_SIGNATURES
|
||||
).signatures
|
||||
signatureList = signature.map {
|
||||
val digest = MessageDigest.getInstance("SHA")
|
||||
digest.update(it.toByteArray())
|
||||
bytesToHex(digest.digest())
|
||||
}
|
||||
}
|
||||
return signatureList
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e.stackTrace.toString())
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun bytesToHex(bytes: ByteArray): String {
|
||||
val hexArray =
|
||||
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F')
|
||||
val hexChars = CharArray(bytes.size * 2)
|
||||
var v: Int
|
||||
for (j in bytes.indices) {
|
||||
v = bytes[j].toInt() and 0xFF
|
||||
hexChars[j * 2] = hexArray[v.ushr(4)]
|
||||
hexChars[j * 2 + 1] = hexArray[v and 0x0F]
|
||||
}
|
||||
return String(hexChars)
|
||||
}
|
||||
|
||||
private fun provideOkHttpClient(
|
||||
androidContext: Context,
|
||||
signatureList: List<String>
|
||||
): OkHttpClient {
|
||||
val httpClient = OkHttpClient.Builder()
|
||||
|
||||
httpClient.addInterceptor(object : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val originalHttpUrl = original.url
|
||||
|
||||
val url = originalHttpUrl.newBuilder()
|
||||
.addQueryParameter("key", androidContext.getString(R.string.youtube_api_key))
|
||||
.build()
|
||||
|
||||
val requestBuilder = original.newBuilder().url(url)
|
||||
.addHeader("X-Android-Package", BuildConfig.APPLICATION_ID)
|
||||
.addHeader("X-Android-Cert", signatureList[0])
|
||||
val request = requestBuilder.build()
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
})
|
||||
|
||||
return httpClient.build()
|
||||
}
|
||||
|
||||
private fun provideRetrofit(httpClient: OkHttpClient) = Retrofit.Builder()
|
||||
.baseUrl("https://www.googleapis.com/youtube/v3/")
|
||||
.client(httpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
private fun provideRoomDatabase(androidApplication: Application) =
|
||||
Room.databaseBuilder(androidApplication, ChannelifyDatabase::class.java, "channelify-db")
|
||||
.build()
|
||||
@@ -0,0 +1,24 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.api.CommentRepliesService
|
||||
import aculix.channelify.app.api.CommentService
|
||||
import aculix.channelify.app.repository.CommentRepliesRepository
|
||||
import aculix.channelify.app.repository.CommentsRepository
|
||||
import aculix.channelify.app.viewmodel.CommentRepliesViewModel
|
||||
import aculix.channelify.app.viewmodel.CommentsViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val commentRepliesModule = module {
|
||||
|
||||
factory { provideCommentRepliesService(get()) }
|
||||
|
||||
single { CommentRepliesRepository(get()) }
|
||||
|
||||
viewModel { CommentRepliesViewModel(get()) }
|
||||
}
|
||||
|
||||
private fun provideCommentRepliesService(retrofit: Retrofit) =
|
||||
retrofit.create(CommentRepliesService::class.java)
|
||||
@@ -0,0 +1,21 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.api.CommentService
|
||||
import aculix.channelify.app.repository.CommentsRepository
|
||||
import aculix.channelify.app.viewmodel.CommentsViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val commentsModule = module {
|
||||
|
||||
factory { provideCommentService(get()) }
|
||||
|
||||
single { CommentsRepository(get()) }
|
||||
|
||||
viewModel { CommentsViewModel(get(), androidContext()) }
|
||||
}
|
||||
|
||||
private fun provideCommentService(retrofit: Retrofit) =
|
||||
retrofit.create(CommentService::class.java)
|
||||
@@ -0,0 +1,15 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.repository.FavoritesRepository
|
||||
import aculix.channelify.app.viewmodel.FavoritesViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val favoritesModule = module {
|
||||
|
||||
// FavoriteVideoDao is already injected in videoDetailsModule
|
||||
|
||||
single { FavoritesRepository(get()) }
|
||||
|
||||
viewModel { FavoritesViewModel(get()) }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.api.ChannelsService
|
||||
import aculix.channelify.app.api.PlaylistItemsService
|
||||
import aculix.channelify.app.api.SearchVideoService
|
||||
import aculix.channelify.app.repository.HomeRepository
|
||||
import aculix.channelify.app.viewmodel.HomeViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val homeModule = module {
|
||||
|
||||
factory { provideChannelsService(get()) }
|
||||
factory { providePlaylistItemsService(get()) }
|
||||
|
||||
single { HomeRepository(get(), get(), get()) }
|
||||
|
||||
viewModel { HomeViewModel(get(), androidContext().getString(R.string.channel_id), androidContext()) }
|
||||
}
|
||||
|
||||
private fun provideChannelsService(retrofit: Retrofit) =
|
||||
retrofit.create(ChannelsService::class.java)
|
||||
|
||||
private fun providePlaylistItemsService(retrofit: Retrofit) =
|
||||
retrofit.create(PlaylistItemsService::class.java)
|
||||
@@ -0,0 +1,17 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.repository.PlaylistVideosRepository
|
||||
import aculix.channelify.app.viewmodel.PlaylistVideosViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* No need to provide PlaylistItemsService since it is already being provided by Koin in the
|
||||
* homeModule
|
||||
*/
|
||||
val playlistVideosModule = module {
|
||||
|
||||
single { PlaylistVideosRepository(get()) }
|
||||
|
||||
viewModel { PlaylistVideosViewModel(get()) }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.api.PlaylistsService
|
||||
import aculix.channelify.app.api.SearchVideoService
|
||||
import aculix.channelify.app.repository.PlaylistsRepository
|
||||
import aculix.channelify.app.viewmodel.PlaylistsViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val playlistsModule = module {
|
||||
|
||||
factory { providePlaylistsService(get()) }
|
||||
|
||||
single { PlaylistsRepository(get()) }
|
||||
|
||||
viewModel { PlaylistsViewModel(get(), androidContext().getString(R.string.channel_id)) }
|
||||
}
|
||||
|
||||
private fun providePlaylistsService(retrofit: Retrofit) =
|
||||
retrofit.create(PlaylistsService::class.java)
|
||||
@@ -0,0 +1,22 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.api.SearchVideoService
|
||||
import aculix.channelify.app.repository.SearchRepository
|
||||
import aculix.channelify.app.viewmodel.SearchViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val searchModule = module {
|
||||
|
||||
factory { provideSearchVideoService(get()) }
|
||||
|
||||
single { SearchRepository(get()) }
|
||||
|
||||
viewModel { SearchViewModel(get(), androidContext().getString(R.string.channel_id), androidContext().getString(R.string.error_search_empty_result_title)) }
|
||||
}
|
||||
|
||||
private fun provideSearchVideoService(retrofit: Retrofit) =
|
||||
retrofit.create(SearchVideoService::class.java)
|
||||
@@ -0,0 +1,29 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.api.VideosService
|
||||
import aculix.channelify.app.db.ChannelifyDatabase
|
||||
import aculix.channelify.app.repository.VideoDetailsRepository
|
||||
import aculix.channelify.app.repository.VideoPlayerRepository
|
||||
import aculix.channelify.app.viewmodel.VideoDetailsViewModel
|
||||
import aculix.channelify.app.viewmodel.VideoPlayerViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val videoDetailsModule = module {
|
||||
factory { provideVideosService(get()) }
|
||||
|
||||
single { provideFavoriteVideoDao(get()) }
|
||||
single { VideoDetailsRepository(get(), get()) }
|
||||
|
||||
viewModel { VideoDetailsViewModel(get(), androidContext()) }
|
||||
}
|
||||
|
||||
private fun provideVideosService(retrofit: Retrofit) =
|
||||
retrofit.create(VideosService::class.java)
|
||||
|
||||
private fun provideFavoriteVideoDao(channelifyDatabase: ChannelifyDatabase) =
|
||||
channelifyDatabase.favoriteVideoDao()
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package aculix.channelify.app.di
|
||||
|
||||
import aculix.channelify.app.api.VideosService
|
||||
import aculix.channelify.app.repository.VideoPlayerRepository
|
||||
import aculix.channelify.app.viewmodel.VideoPlayerViewModel
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
|
||||
val videoPlayerModule = module {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import aculix.core.extensions.makeGone
|
||||
import aculix.core.extensions.makeVisible
|
||||
import android.view.View
|
||||
import coil.api.load
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
import de.hdodenhof.circleimageview.CircleImageView
|
||||
|
||||
class CommentItem(val comment: Comment.Item?) : AbstractItem<CommentItem.CommentViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_comment
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_comment_item_id
|
||||
|
||||
override fun getViewHolder(v: View): CommentViewHolder {
|
||||
return CommentViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class CommentViewHolder(private var view: View) : FastAdapter.ViewHolder<CommentItem>(view) {
|
||||
|
||||
private val profilePhoto: CircleImageView = view.findViewById(R.id.ivProfileCommentItem)
|
||||
private val commenterNameTime: MaterialTextView =
|
||||
view.findViewById(R.id.tvNameTimeCommentItem)
|
||||
private val commentContent: MaterialTextView = view.findViewById(R.id.tvContentCommentItem)
|
||||
private val likeCount: MaterialTextView = view.findViewById(R.id.tvLikeCountCommentItem)
|
||||
private val repliesCount: MaterialTextView =
|
||||
view.findViewById(R.id.tvCommentCountCommentItem)
|
||||
private val viewReplies: MaterialButton = view.findViewById(R.id.btnViewRepliesCommentItem)
|
||||
|
||||
override fun bindView(item: CommentItem, payloads: List<Any>) {
|
||||
item.comment?.snippet?.let {
|
||||
// Profile photo
|
||||
profilePhoto.load(it.topLevelComment.snippet.authorProfileImageUrl)
|
||||
|
||||
// Commenter name and published time
|
||||
if(it.topLevelComment.snippet.publishedAt == it.topLevelComment.snippet.updatedAt) {
|
||||
// Comment not edited
|
||||
commenterNameTime.text = view.context.getString(
|
||||
R.string.text_commenter_name_time,
|
||||
it.topLevelComment.snippet.authorDisplayName,
|
||||
DateTimeUtils.getTimeAgo(it.topLevelComment.snippet.publishedAt)
|
||||
)
|
||||
} else {
|
||||
// Edited comment
|
||||
commenterNameTime.text = view.context.getString(
|
||||
R.string.text_commenter_name_time_edited,
|
||||
it.topLevelComment.snippet.authorDisplayName,
|
||||
DateTimeUtils.getTimeAgo(it.topLevelComment.snippet.updatedAt)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Comments count
|
||||
commentContent.text = it.topLevelComment.snippet.textOriginal
|
||||
|
||||
// Comments Like count
|
||||
likeCount.text = it.topLevelComment.snippet.likeCount.toString()
|
||||
|
||||
// Comments Reply count
|
||||
repliesCount.text = it.totalReplyCount.toString()
|
||||
|
||||
// View Replies button
|
||||
if (it.totalReplyCount > 0) {
|
||||
viewReplies.makeVisible()
|
||||
viewReplies.text = view.context.resources.getQuantityString(R.plurals.btn_view_replies, it.totalReplyCount, it.totalReplyCount)
|
||||
} else {
|
||||
viewReplies.makeGone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unbindView(item: CommentItem) {
|
||||
profilePhoto.setImageDrawable(null)
|
||||
commenterNameTime.text = null
|
||||
commentContent.text = null
|
||||
likeCount.text = null
|
||||
repliesCount.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import android.view.View
|
||||
import coil.api.load
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
import de.hdodenhof.circleimageview.CircleImageView
|
||||
|
||||
class CommentReplyItem(val commentReply: CommentReply.Item?) :
|
||||
AbstractItem<CommentReplyItem.CommentReplyViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_comment_reply
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_comment_reply_item_id
|
||||
|
||||
override fun getViewHolder(v: View): CommentReplyViewHolder {
|
||||
return CommentReplyViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class CommentReplyViewHolder(private var view: View) :
|
||||
FastAdapter.ViewHolder<CommentReplyItem>(view) {
|
||||
|
||||
private val profilePhoto: CircleImageView =
|
||||
view.findViewById(R.id.ivProfileCommentReplyItem)
|
||||
private val commenterNameTime: MaterialTextView =
|
||||
view.findViewById(R.id.tvNameTimeCommentReplyItem)
|
||||
private val commentContent: MaterialTextView =
|
||||
view.findViewById(R.id.tvContentCommentReplyItem)
|
||||
private val likeCount: MaterialTextView =
|
||||
view.findViewById(R.id.tvLikeCountCommentReplyItem)
|
||||
|
||||
override fun bindView(item: CommentReplyItem, payloads: List<Any>) {
|
||||
item.commentReply?.snippet?.let {
|
||||
// Profile photo
|
||||
profilePhoto.load(it.authorProfileImageUrl)
|
||||
|
||||
// Commenter name and published time
|
||||
if (it.publishedAt == it.updatedAt) {
|
||||
// Comment not edited
|
||||
commenterNameTime.text = view.context.getString(
|
||||
R.string.text_commenter_name_time,
|
||||
it.authorDisplayName,
|
||||
DateTimeUtils.getTimeAgo(it.publishedAt)
|
||||
)
|
||||
} else {
|
||||
// Edited comment
|
||||
commenterNameTime.text = view.context.getString(
|
||||
R.string.text_commenter_name_time_edited,
|
||||
it.authorDisplayName,
|
||||
DateTimeUtils.getTimeAgo(it.updatedAt)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Comment Content
|
||||
commentContent.text = it.textOriginal
|
||||
|
||||
// Comments Like count
|
||||
likeCount.text = it.likeCount.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun unbindView(item: CommentReplyItem) {
|
||||
profilePhoto.setImageDrawable(null)
|
||||
commenterNameTime.text = null
|
||||
commentContent.text = null
|
||||
likeCount.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import aculix.core.extensions.to64BitHash
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.api.load
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
|
||||
class FavoriteItem(val favoriteVideo: FavoriteVideo) :
|
||||
AbstractItem<FavoriteItem.FavoriteViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_favorite
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_favorite_item_id
|
||||
|
||||
override fun getViewHolder(v: View): FavoriteViewHolder {
|
||||
return FavoriteViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class FavoriteViewHolder(private var view: View) : FastAdapter.ViewHolder<FavoriteItem>(view) {
|
||||
|
||||
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailFavoriteItem)
|
||||
private val favoriteIcon: AppCompatImageView = view.findViewById(R.id.ivHeartFavoriteItem)
|
||||
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitleFavoriteItem)
|
||||
|
||||
override fun bindView(item: FavoriteItem, payloads: List<Any>) {
|
||||
thumbnail.load(item.favoriteVideo.thumbnail)
|
||||
videoTitle.text = item.favoriteVideo.title
|
||||
|
||||
// Favorite Icon
|
||||
if (item.favoriteVideo.isChecked) favoriteIcon.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
view.context,
|
||||
R.drawable.ic_favorite_filled_border
|
||||
)
|
||||
) else favoriteIcon.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
view.context,
|
||||
R.drawable.ic_favorite_border
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun unbindView(item: FavoriteItem) {
|
||||
thumbnail.setImageDrawable(null)
|
||||
videoTitle.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import coil.api.load
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
import timber.log.Timber
|
||||
|
||||
class HomeItem(val playlistItem: PlaylistItemInfo.Item?) : AbstractItem<HomeItem.HomeViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_home
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_home_item_id
|
||||
|
||||
override fun getViewHolder(v: View): HomeViewHolder {
|
||||
return HomeViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class HomeViewHolder(private var view: View) : FastAdapter.ViewHolder<HomeItem>(view) {
|
||||
|
||||
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailHomeItem)
|
||||
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitleHomeItem)
|
||||
private val videoPublishedAt: AppCompatTextView = view.findViewById(R.id.tvTimePublishedHomeItem)
|
||||
|
||||
override fun bindView(item: HomeItem, payloads: List<Any>) {
|
||||
item.playlistItem?.snippet?.let {
|
||||
thumbnail.load(it.thumbnails.standard?.url ?: it.thumbnails.high.url)
|
||||
videoTitle.text = it.title
|
||||
}
|
||||
videoPublishedAt.text = DateTimeUtils.getTimeAgo(item.playlistItem?.contentDetails?.videoPublishedAt!!)
|
||||
}
|
||||
|
||||
override fun unbindView(item: HomeItem) {
|
||||
thumbnail.setImageDrawable(null)
|
||||
videoTitle.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.Playlist
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import coil.api.load
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
|
||||
class PlaylistItem(val playlistItem: Playlist.Item?) :
|
||||
AbstractItem<PlaylistItem.PlaylistViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_playlist
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_playlist_item_id
|
||||
|
||||
override fun getViewHolder(v: View): PlaylistViewHolder {
|
||||
return PlaylistViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class PlaylistViewHolder(private var view: View) : FastAdapter.ViewHolder<PlaylistItem>(view) {
|
||||
|
||||
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailPlaylistItem)
|
||||
private val playlistName: AppCompatTextView = view.findViewById(R.id.tvNamePlaylistItem)
|
||||
private val videoCount: AppCompatTextView = view.findViewById(R.id.tvVideoCountPlaylistItem)
|
||||
|
||||
override fun bindView(item: PlaylistItem, payloads: List<Any>) {
|
||||
thumbnail.load(
|
||||
item.playlistItem?.snippet?.thumbnails?.standard?.url
|
||||
?: item.playlistItem?.snippet?.thumbnails?.high?.url
|
||||
)
|
||||
playlistName.text = item.playlistItem?.snippet?.title
|
||||
videoCount.text = view.context.resources.getQuantityString(R.plurals.text_playlist_video_count, item.playlistItem?.contentDetails?.itemCount!!, item.playlistItem.contentDetails.itemCount)
|
||||
}
|
||||
|
||||
override fun unbindView(item: PlaylistItem) {
|
||||
thumbnail.setImageDrawable(null)
|
||||
playlistName.text = null
|
||||
videoCount.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import coil.api.load
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
|
||||
class PlaylistVideoItem(val playlistItem: PlaylistItemInfo.Item?) :
|
||||
AbstractItem<PlaylistVideoItem.PlaylistVideoViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_playlist_video
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_playlist_video_item_id
|
||||
|
||||
override fun getViewHolder(v: View): PlaylistVideoViewHolder {
|
||||
return PlaylistVideoViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class PlaylistVideoViewHolder(private var view: View) :
|
||||
FastAdapter.ViewHolder<PlaylistVideoItem>(view) {
|
||||
|
||||
private val thumbnail: AppCompatImageView =
|
||||
view.findViewById(R.id.ivThumbnailPlaylistVideoItem)
|
||||
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitlePlaylistVideoItem)
|
||||
private val videoPublishedAt: AppCompatTextView =
|
||||
view.findViewById(R.id.tvTimePublishedPlaylistVideoItem)
|
||||
|
||||
override fun bindView(item: PlaylistVideoItem, payloads: List<Any>) {
|
||||
item.playlistItem?.snippet?.let {
|
||||
thumbnail.load(it.thumbnails.standard?.url ?: it.thumbnails.high.url)
|
||||
videoTitle.text = it.title
|
||||
|
||||
}
|
||||
videoPublishedAt.text =
|
||||
DateTimeUtils.getTimeAgo(item.playlistItem?.contentDetails?.videoPublishedAt!!)
|
||||
}
|
||||
|
||||
override fun unbindView(item: PlaylistVideoItem) {
|
||||
thumbnail.setImageDrawable(null)
|
||||
videoTitle.text = null
|
||||
videoPublishedAt.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import android.view.View
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
|
||||
class ProgressIndicatorItem : AbstractItem<ProgressIndicatorItem.ProgressIndicatorViewHolder>() {
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.progress_indicator_item_id
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_progress_indicator
|
||||
|
||||
override fun getViewHolder(v: View): ProgressIndicatorViewHolder {
|
||||
return ProgressIndicatorViewHolder(v)
|
||||
}
|
||||
|
||||
class ProgressIndicatorViewHolder(view: View) : FastAdapter.ViewHolder<ProgressIndicatorItem>(view) {
|
||||
|
||||
override fun bindView(item: ProgressIndicatorItem, payloads: List<Any>) {
|
||||
// No data needs to be set as only ProgressBar is shown
|
||||
}
|
||||
|
||||
override fun unbindView(item: ProgressIndicatorItem) {
|
||||
// No data set and hence no unbinding needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package aculix.channelify.app.fastadapteritems
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import coil.api.load
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.items.AbstractItem
|
||||
|
||||
class SearchItem(val searchedVideo: SearchedVideo.Item?) :
|
||||
AbstractItem<SearchItem.SearchViewHolder>() {
|
||||
|
||||
override val layoutRes: Int
|
||||
get() = R.layout.item_search
|
||||
|
||||
override val type: Int
|
||||
get() = R.id.fastadapter_search_item_id
|
||||
|
||||
override fun getViewHolder(v: View): SearchViewHolder {
|
||||
return SearchViewHolder(v)
|
||||
}
|
||||
|
||||
|
||||
class SearchViewHolder(private var view: View) : FastAdapter.ViewHolder<SearchItem>(view) {
|
||||
|
||||
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailSearchItem)
|
||||
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitleSearchItem)
|
||||
private val videoPublishedAt: AppCompatTextView =
|
||||
view.findViewById(R.id.tvTimePublishedSearchItem)
|
||||
|
||||
override fun bindView(item: SearchItem, payloads: List<Any>) {
|
||||
item.searchedVideo?.snippet?.let {
|
||||
thumbnail.load(it.thumbnails.standard?.url ?: it.thumbnails.high.url)
|
||||
videoTitle.text = it.title
|
||||
videoPublishedAt.text = DateTimeUtils.getTimeAgo(it.publishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
override fun unbindView(item: SearchItem) {
|
||||
thumbnail.setImageDrawable(null)
|
||||
videoTitle.text = null
|
||||
videoPublishedAt.text = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.View
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.ChannelInfo
|
||||
import aculix.channelify.app.sharedpref.AppPref
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import aculix.channelify.app.viewmodel.AboutViewModel
|
||||
import aculix.core.extensions.*
|
||||
import aculix.core.helper.ResultWrapper
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import coil.api.load
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
||||
import kotlinx.android.synthetic.main.fragment_about.*
|
||||
import kotlinx.android.synthetic.main.widget_toolbar.view.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass.
|
||||
*/
|
||||
class AboutFragment : Fragment(R.layout.fragment_about) {
|
||||
|
||||
private val viewModel by viewModel<AboutViewModel>() // Lazy inject ViewModel
|
||||
|
||||
private lateinit var channelInfoItem: ChannelInfo.Item
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupToolbar()
|
||||
|
||||
setupObservables()
|
||||
fetchChannelInfo()
|
||||
|
||||
btnRetryAbout.setOnClickListener {
|
||||
fetchChannelInfo()
|
||||
}
|
||||
|
||||
btnSubscribeAbout.setOnClickListener {
|
||||
startYouTubeIntent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
ablAbout.toolbarMain.apply {
|
||||
inflateMenu(R.menu.toolbar_menu_about)
|
||||
|
||||
// Change theme menu item icon based on current theme
|
||||
val themeDrawable = if (AppPref.isLightThemeEnabled) ContextCompat.getDrawable(
|
||||
context!!,
|
||||
R.drawable.ic_theme_light
|
||||
) else ContextCompat.getDrawable(context!!, R.drawable.ic_theme_dark)
|
||||
menu.findItem(R.id.miThemeAbout).icon = themeDrawable
|
||||
|
||||
// Store configuration
|
||||
menu.findItem(R.id.miStoreAbout).isVisible = resources.getBoolean(R.bool.enable_store)
|
||||
|
||||
// MenuItem onclick
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.miStoreAbout -> {
|
||||
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
|
||||
}
|
||||
R.id.miThemeAbout -> {
|
||||
showThemeChooserDialog()
|
||||
}
|
||||
R.id.miAppInfoAbout -> {
|
||||
findNavController().navigate(R.id.action_aboutFragment_to_appInfoFragment)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the info of channel
|
||||
*/
|
||||
private fun fetchChannelInfo() {
|
||||
if (isInternetAvailable(context!!)) {
|
||||
viewModel.getChannelInfo()
|
||||
} else {
|
||||
showChannelInfoErrorState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
viewModel.channelInfoLiveData.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
is ResultWrapper.Loading -> {
|
||||
pbAbout.makeVisible()
|
||||
}
|
||||
is ResultWrapper.Error -> {
|
||||
pbAbout.makeGone()
|
||||
showChannelInfoErrorState()
|
||||
}
|
||||
is ResultWrapper.Success<*> -> {
|
||||
pbAbout.makeGone()
|
||||
hideChannelInfoErrorState()
|
||||
channelInfoItem = (it.data as ChannelInfo).items[0]
|
||||
setChannelInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setChannelInfo() {
|
||||
|
||||
// Banner
|
||||
if (channelInfoItem.brandingSettings != null) {
|
||||
ivBannerAbout.load(
|
||||
channelInfoItem.brandingSettings?.image?.bannerMobileHdImageUrl
|
||||
?: channelInfoItem.brandingSettings?.image?.bannerMobileMediumHdImageUrl
|
||||
)
|
||||
} else {
|
||||
ivBannerAbout.makeGone()
|
||||
val constraintSet = ConstraintSet().apply {
|
||||
clone(clAbout)
|
||||
connect(R.id.cvAbout, ConstraintSet.TOP, R.id.ablAbout, ConstraintSet.BOTTOM)
|
||||
}
|
||||
constraintSet.applyTo(clAbout)
|
||||
}
|
||||
|
||||
// Profile Image
|
||||
ivProfileAbout.load(
|
||||
channelInfoItem.snippet.thumbnails.high?.url
|
||||
?: channelInfoItem.snippet.thumbnails.medium.url
|
||||
)
|
||||
|
||||
tvNameAbout.text = channelInfoItem.snippet.title
|
||||
tvJoinDateAbout.text = getString(
|
||||
R.string.text_channel_join_date,
|
||||
DateTimeUtils.getPublishedDate(channelInfoItem.snippet.publishedAt)
|
||||
)
|
||||
tvSubscribersValueAbout.text =
|
||||
channelInfoItem.statistics.subscriberCount.toLong().getFormattedNumberInString()
|
||||
tvVideosValueAbout.text =
|
||||
channelInfoItem.statistics.videoCount.toLong().getFormattedNumberInString()
|
||||
tvViewsValueAbout.text =
|
||||
channelInfoItem.statistics.viewCount.toLong().getFormattedNumberInString()
|
||||
tvDescAbout.text = channelInfoItem.snippet.description
|
||||
}
|
||||
|
||||
private fun showChannelInfoErrorState() {
|
||||
groupResultAbout.makeGone()
|
||||
groupErrorAbout.makeVisible()
|
||||
}
|
||||
|
||||
private fun hideChannelInfoErrorState() {
|
||||
groupErrorAbout.makeGone()
|
||||
groupResultAbout.makeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the YouTube app's Channel screen if YouTube app is installed otherwise opens the URL
|
||||
* in Chrome Custom Tab.
|
||||
*/
|
||||
private fun startYouTubeIntent() {
|
||||
try {
|
||||
val youtubeIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(
|
||||
getString(
|
||||
R.string.text_youtube_channel_intent_url,
|
||||
getString(R.string.channel_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
startActivity(youtubeIntent)
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
context?.openUrl(
|
||||
getString(
|
||||
R.string.text_youtube_channel_intent_url,
|
||||
getString(R.string.channel_id)
|
||||
),
|
||||
R.color.defaultBgColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showThemeChooserDialog() {
|
||||
val themeList = listOf(
|
||||
getString(R.string.dialog_theme_text_light),
|
||||
getString(R.string.dialog_theme_text_dark)
|
||||
)
|
||||
|
||||
val currentThemeIndex = if (AppPref.isLightThemeEnabled) 0 else 1
|
||||
|
||||
MaterialDialog(context!!).show {
|
||||
title(R.string.dialog_theme_title)
|
||||
listItemsSingleChoice(
|
||||
items = themeList,
|
||||
initialSelection = currentThemeIndex
|
||||
) { dialog, index, text ->
|
||||
when (text) {
|
||||
getString(R.string.dialog_theme_text_light) -> {
|
||||
setTheme(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
}
|
||||
getString(R.string.dialog_theme_text_dark) -> {
|
||||
setTheme(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTheme(themeMode: Int) {
|
||||
AppCompatDelegate.setDefaultNightMode(themeMode)
|
||||
AppPref.isLightThemeEnabled = themeMode == AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
import aculix.channelify.app.BuildConfig
|
||||
import aculix.channelify.app.R
|
||||
import aculix.core.extensions.openAppInGooglePlay
|
||||
import aculix.core.extensions.openUrl
|
||||
import aculix.core.extensions.startEmailIntent
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import coil.api.load
|
||||
import kotlinx.android.synthetic.main.fragment_app_info.*
|
||||
|
||||
class AppInfoFragment : Fragment(R.layout.fragment_app_info) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupToolbar()
|
||||
ivLogoAppInfo.load(R.drawable.logo_splash)
|
||||
|
||||
onWebsiteClick()
|
||||
onGooglePlayClick()
|
||||
onInstagramClick()
|
||||
onEmailClick()
|
||||
onNavigationViewMenuItemClick()
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
val navController = findNavController()
|
||||
val appBarConfiguration = AppBarConfiguration(navController.graph)
|
||||
toolbarAppInfo.setupWithNavController(navController, appBarConfiguration)
|
||||
}
|
||||
|
||||
private fun onWebsiteClick() {
|
||||
ivWebsiteAppInfo.setOnClickListener {
|
||||
context?.openUrl(getString(R.string.text_website_url), R.color.defaultBgColor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePlayClick() {
|
||||
ivGooglePlayAppInfo.setOnClickListener {
|
||||
try {
|
||||
// Try to open in the Google Play app
|
||||
startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("market://search?q=pub:${getString(R.string.text_google_play_developer_name)}")
|
||||
)
|
||||
)
|
||||
} catch (exception: Throwable) {
|
||||
// Google Play app is not installed. Open URL in the browser.
|
||||
context?.openUrl(
|
||||
"https://play.google.com/store/apps/dev?id=${getString(R.string.text_google_play_developer_id)}",
|
||||
R.color.defaultBgColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInstagramClick() {
|
||||
ivInstagramAppInfo.setOnClickListener {
|
||||
try {
|
||||
// Try to open in the Instagram app
|
||||
startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("https://instagram.com/_u/${getString(R.string.text_instagram_user_name)}")
|
||||
)
|
||||
)
|
||||
} catch (exception: android.content.ActivityNotFoundException) {
|
||||
// Instagram app is not installed. Open URL in the browser.
|
||||
context?.openUrl(
|
||||
"https://instagram.com/${getString(R.string.text_instagram_user_name)}",
|
||||
R.color.defaultBgColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEmailClick() {
|
||||
ivEmailAppInfo.setOnClickListener {
|
||||
context?.startEmailIntent(getString(R.string.text_contact_email), null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNavigationViewMenuItemClick() {
|
||||
nvAppInfo.setNavigationItemSelectedListener {
|
||||
when (it.itemId) {
|
||||
R.id.miRateAppInfo -> {
|
||||
context?.openAppInGooglePlay(BuildConfig.APPLICATION_ID)
|
||||
}
|
||||
R.id.miFeedbackAppInfo -> {
|
||||
context?.startEmailIntent(
|
||||
getString(R.string.text_contact_email),
|
||||
getString(R.string.text_app_feedback_email_subject)
|
||||
)
|
||||
}
|
||||
R.id.miTosAppInfo -> {
|
||||
context?.openUrl(getString(R.string.text_tos_url), R.color.defaultBgColor)
|
||||
}
|
||||
R.id.miPrivacyPolicyAppInfo -> {
|
||||
context?.openUrl(
|
||||
getString(R.string.text_privacy_policy_url),
|
||||
R.color.defaultBgColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import aculix.channelify.app.Channelify
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.fastadapteritems.CommentReplyItem
|
||||
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.paging.Status
|
||||
import aculix.channelify.app.utils.DividerItemDecorator
|
||||
import aculix.channelify.app.utils.getAdaptiveBannerAdSize
|
||||
import aculix.channelify.app.viewmodel.CommentRepliesViewModel
|
||||
import aculix.core.extensions.makeGone
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdView
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.GenericFastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.mikepenz.fastadapter.paged.PagedModelAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_comment_replies.*
|
||||
import kotlinx.android.synthetic.main.fragment_comments.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CommentRepliesFragment : Fragment() {
|
||||
|
||||
private val viewModel by viewModel<CommentRepliesViewModel>() // Lazy inject ViewModel
|
||||
private val args by navArgs<CommentRepliesFragmentArgs>()
|
||||
|
||||
private lateinit var commentId: String
|
||||
private lateinit var commentRepliesAdapter: GenericFastAdapter
|
||||
private lateinit var commentRepliesPagedModelAdapter: PagedModelAdapter<CommentReply.Item, CommentReplyItem>
|
||||
private var retrySnackbar: Snackbar? = null
|
||||
private lateinit var footerAdapter: GenericItemAdapter
|
||||
private var isFirstPageLoading = true
|
||||
|
||||
private lateinit var adView: AdView
|
||||
private var initialLayoutComplete = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_comment_replies, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
commentId = args.commentId
|
||||
|
||||
setupRecyclerView(savedInstanceState)
|
||||
fetchCommentReplies()
|
||||
setupObservables()
|
||||
|
||||
ivCloseCommentReplies.setOnClickListener { onCloseClick() }
|
||||
|
||||
if (Channelify.isAdEnabled) setupAd() else adViewContainerCommentReplies.makeGone()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
var outState = _outState
|
||||
outState = commentRepliesAdapter.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
|
||||
if (Channelify.isAdEnabled) adView.pause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
if (Channelify.isAdEnabled) adView.resume()
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (Channelify.isAdEnabled) adView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
val asyncDifferConfig = AsyncDifferConfig.Builder<CommentReply.Item>(object :
|
||||
DiffUtil.ItemCallback<CommentReply.Item>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: CommentReply.Item,
|
||||
newItem: CommentReply.Item
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: CommentReply.Item,
|
||||
newItem: CommentReply.Item
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}).build()
|
||||
|
||||
commentRepliesPagedModelAdapter =
|
||||
PagedModelAdapter<CommentReply.Item, CommentReplyItem>(asyncDifferConfig) {
|
||||
CommentReplyItem(it)
|
||||
}
|
||||
|
||||
footerAdapter = ItemAdapter.items()
|
||||
|
||||
commentRepliesAdapter =
|
||||
FastAdapter.with(listOf(commentRepliesPagedModelAdapter, footerAdapter))
|
||||
commentRepliesAdapter.registerTypeInstance(CommentReplyItem(null))
|
||||
commentRepliesAdapter.withSavedInstanceState(savedInstanceState)
|
||||
|
||||
rvCommentReplies.layoutManager = LinearLayoutManager(context)
|
||||
rvCommentReplies.addItemDecoration(
|
||||
DividerItemDecorator(ContextCompat.getDrawable(requireContext(), R.drawable.view_divider_item_decorator)!!)
|
||||
)
|
||||
rvCommentReplies.adapter = commentRepliesAdapter
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
// Observe network live data
|
||||
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
|
||||
when (networkState?.status) {
|
||||
Status.FAILED -> {
|
||||
footerAdapter.clear()
|
||||
createRetrySnackbar()
|
||||
retrySnackbar?.show()
|
||||
}
|
||||
Status.SUCCESS -> {
|
||||
footerAdapter.clear()
|
||||
}
|
||||
Status.LOADING -> {
|
||||
if (!isFirstPageLoading) {
|
||||
showRecyclerViewProgressIndicator()
|
||||
} else {
|
||||
isFirstPageLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Observe latest video live data
|
||||
viewModel.commentRepliesLiveData?.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer<PagedList<CommentReply.Item>> { commentRepliesList ->
|
||||
commentRepliesPagedModelAdapter.submitList(commentRepliesList)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showRecyclerViewProgressIndicator() {
|
||||
footerAdapter.clear()
|
||||
val progressIndicatorItem = ProgressIndicatorItem()
|
||||
footerAdapter.add(progressIndicatorItem)
|
||||
}
|
||||
|
||||
private fun fetchCommentReplies() {
|
||||
viewModel.getCommentReplies(commentId)
|
||||
}
|
||||
|
||||
private fun createRetrySnackbar() {
|
||||
retrySnackbar =
|
||||
Snackbar.make(
|
||||
clCommentReplies,
|
||||
R.string.error_fetch_comment_replies,
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
)
|
||||
.setAction(R.string.btn_retry) {
|
||||
viewModel.refreshFailedRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCloseClick() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun setupAd() {
|
||||
adView = AdView(context)
|
||||
adViewContainerCommentReplies.addView(adView)
|
||||
adViewContainerCommentReplies.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
if (!initialLayoutComplete) {
|
||||
initialLayoutComplete = true
|
||||
|
||||
adView.adUnitId = getString(R.string.comment_replies_banner_ad_id)
|
||||
adView.adSize = activity?.getAdaptiveBannerAdSize(adViewContainerCommentReplies)
|
||||
adView.loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
import aculix.channelify.app.Channelify
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.fastadapteritems.CommentItem
|
||||
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.paging.Status
|
||||
import aculix.channelify.app.utils.DividerItemDecorator
|
||||
import aculix.channelify.app.utils.getAdaptiveBannerAdSize
|
||||
import aculix.channelify.app.viewmodel.CommentsViewModel
|
||||
import aculix.core.extensions.makeGone
|
||||
import aculix.core.extensions.makeVisible
|
||||
import aculix.core.extensions.toast
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.*
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdSize
|
||||
import com.google.android.gms.ads.AdView
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.GenericFastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.mikepenz.fastadapter.listeners.ClickEventHook
|
||||
import com.mikepenz.fastadapter.paged.PagedModelAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_comments.*
|
||||
import kotlinx.android.synthetic.main.item_comment.view.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
class CommentsFragment : Fragment() {
|
||||
|
||||
private val viewModel by viewModel<CommentsViewModel>() // Lazy inject ViewModel
|
||||
private val args by navArgs<CommentsFragmentArgs>()
|
||||
|
||||
private lateinit var videoId: String
|
||||
private lateinit var commentsAdapter: GenericFastAdapter
|
||||
private lateinit var commentsPagedModelAdapter: PagedModelAdapter<Comment.Item, CommentItem>
|
||||
private lateinit var footerAdapter: GenericItemAdapter
|
||||
private var isFirstPageLoading = true
|
||||
private var retrySnackbar: Snackbar? = null
|
||||
private val SORT_BY_RELEVANCE = "relevance"
|
||||
private val SORT_BY_TIME = "time"
|
||||
|
||||
private lateinit var adView: AdView
|
||||
private var initialLayoutComplete = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_comments, container, false)
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
videoId = args.videoId
|
||||
|
||||
setupRecyclerView(savedInstanceState)
|
||||
fetchComments(SORT_BY_RELEVANCE)
|
||||
setupObservables()
|
||||
|
||||
ivSortComments.setOnClickListener { onSortClick(it) }
|
||||
ivCloseComments.setOnClickListener { onCloseClick() }
|
||||
|
||||
if (Channelify.isAdEnabled) setupAd() else adViewContainerComments.makeGone()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
var outState = _outState
|
||||
outState = commentsAdapter.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
|
||||
if (Channelify.isAdEnabled) adView.pause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
if (Channelify.isAdEnabled) adView.resume()
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (Channelify.isAdEnabled) adView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
val asyncDifferConfig = AsyncDifferConfig.Builder<Comment.Item>(object :
|
||||
DiffUtil.ItemCallback<Comment.Item>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: Comment.Item,
|
||||
newItem: Comment.Item
|
||||
): Boolean {
|
||||
return oldItem.snippet.topLevelComment.id == newItem.snippet.topLevelComment.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: Comment.Item,
|
||||
newItem: Comment.Item
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}).build()
|
||||
|
||||
commentsPagedModelAdapter =
|
||||
PagedModelAdapter<Comment.Item, CommentItem>(asyncDifferConfig) {
|
||||
CommentItem(it)
|
||||
}
|
||||
|
||||
footerAdapter = ItemAdapter.items()
|
||||
|
||||
commentsAdapter = FastAdapter.with(listOf(commentsPagedModelAdapter, footerAdapter))
|
||||
commentsAdapter.registerTypeInstance(CommentItem(null))
|
||||
commentsAdapter.withSavedInstanceState(savedInstanceState)
|
||||
onViewRepliesClick()
|
||||
|
||||
rvComments.layoutManager = LinearLayoutManager(context)
|
||||
rvComments.addItemDecoration(DividerItemDecorator(ContextCompat.getDrawable(requireContext(), R.drawable.view_divider_item_decorator)!!))
|
||||
rvComments.adapter = commentsAdapter
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
// Observe Empty State LiveData
|
||||
viewModel.emptyStateLiveData.observe(viewLifecycleOwner, Observer { isResultEmpty ->
|
||||
if (isResultEmpty) {
|
||||
showEmptyState()
|
||||
} else {
|
||||
hideEmptyState()
|
||||
}
|
||||
})
|
||||
|
||||
// Observe network live data
|
||||
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
|
||||
when (networkState?.status) {
|
||||
Status.FAILED -> {
|
||||
footerAdapter.clear()
|
||||
|
||||
createRetrySnackbar()
|
||||
retrySnackbar?.show()
|
||||
}
|
||||
Status.SUCCESS -> {
|
||||
footerAdapter.clear()
|
||||
}
|
||||
Status.LOADING -> {
|
||||
if (!isFirstPageLoading) {
|
||||
showRecyclerViewProgressIndicator()
|
||||
} else {
|
||||
isFirstPageLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Observe latest video live data
|
||||
viewModel.commentsLiveData?.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer<PagedList<Comment.Item>> { commentsList ->
|
||||
commentsPagedModelAdapter.submitList(commentsList)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showRecyclerViewProgressIndicator() {
|
||||
footerAdapter.clear()
|
||||
val progressIndicatorItem = ProgressIndicatorItem()
|
||||
footerAdapter.add(progressIndicatorItem)
|
||||
}
|
||||
|
||||
private fun showEmptyState() {
|
||||
groupEmptyComments.makeVisible()
|
||||
}
|
||||
|
||||
private fun hideEmptyState() {
|
||||
groupEmptyComments.makeGone()
|
||||
}
|
||||
|
||||
private fun fetchComments(sortOrder: String) {
|
||||
viewModel.getVideoComments(videoId, sortOrder)
|
||||
}
|
||||
|
||||
private fun createRetrySnackbar() {
|
||||
retrySnackbar =
|
||||
Snackbar.make(clComments, R.string.error_fetch_comments, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(R.string.btn_retry) {
|
||||
viewModel.refreshFailedRequest()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the Sort icon is clicked
|
||||
*/
|
||||
private fun onSortClick(view: View) {
|
||||
val sortMenu = PopupMenu(requireContext(), view)
|
||||
sortMenu.menuInflater.inflate(R.menu.comments_sort_menu_video_details, sortMenu.menu)
|
||||
sortMenu.show()
|
||||
|
||||
sortMenu.setOnMenuItemClickListener { item: MenuItem? ->
|
||||
when (item?.itemId) {
|
||||
R.id.miTopComments -> {
|
||||
viewModel.sortComments(SORT_BY_RELEVANCE)
|
||||
}
|
||||
R.id.miNewestFirstComments -> {
|
||||
viewModel.sortComments(SORT_BY_TIME)
|
||||
Timber.e("Newest Comments")
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun onCloseClick() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the View Replies button is clicked of a
|
||||
* Comment Item
|
||||
*/
|
||||
private fun onViewRepliesClick() {
|
||||
commentsAdapter.addEventHook(object : ClickEventHook<CommentItem>() {
|
||||
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
|
||||
return if (viewHolder is CommentItem.CommentViewHolder) {
|
||||
viewHolder.itemView.btnViewRepliesCommentItem
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(
|
||||
v: View,
|
||||
position: Int,
|
||||
fastAdapter: FastAdapter<CommentItem>,
|
||||
item: CommentItem
|
||||
) {
|
||||
val action =
|
||||
CommentsFragmentDirections.actionCommentsFragmentToCommentRepliesFragment(item.comment?.snippet?.topLevelComment?.id!!)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupAd() {
|
||||
adView = AdView(context)
|
||||
adViewContainerComments.addView(adView)
|
||||
adViewContainerComments.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
if (!initialLayoutComplete) {
|
||||
initialLayoutComplete = true
|
||||
|
||||
adView.adUnitId = getString(R.string.comments_banner_ad_id)
|
||||
adView.adSize = activity?.getAdaptiveBannerAdSize(adViewContainerComments)
|
||||
adView.loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.View
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.activity.VideoPlayerActivity
|
||||
import aculix.channelify.app.fastadapteritems.FavoriteItem
|
||||
import aculix.channelify.app.viewmodel.FavoritesViewModel
|
||||
import aculix.core.extensions.openUrl
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.FastItemAdapter
|
||||
import com.mikepenz.fastadapter.listeners.ClickEventHook
|
||||
import com.mikepenz.itemanimators.AlphaInAnimator
|
||||
import kotlinx.android.synthetic.main.fragment_favorites.*
|
||||
import kotlinx.android.synthetic.main.item_favorite.view.*
|
||||
import kotlinx.android.synthetic.main.widget_toolbar.view.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass.
|
||||
*/
|
||||
class FavoritesFragment : Fragment(R.layout.fragment_favorites) {
|
||||
|
||||
private val viewModel by viewModel<FavoritesViewModel>() // Lazy inject ViewModel
|
||||
|
||||
private lateinit var favoritesAdapter: FastItemAdapter<FavoriteItem>
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupToolbar()
|
||||
|
||||
setupRecyclerView(savedInstanceState)
|
||||
setupObservables()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
var outState = _outState
|
||||
outState = favoritesAdapter.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
ablFavorites.toolbarMain.apply {
|
||||
inflateMenu(R.menu.main_menu)
|
||||
|
||||
// Store and Search configuration
|
||||
menu.findItem(R.id.miStoreMainMenu).isVisible = resources.getBoolean(R.bool.enable_store)
|
||||
menu.findItem(R.id.miSearchMainMenu).isVisible = resources.getBoolean(R.bool.enable_search)
|
||||
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.miStoreMainMenu -> {
|
||||
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
|
||||
}
|
||||
R.id.miSearchMainMenu -> {
|
||||
findNavController().navigate(R.id.action_favoritesFragment_to_searchFragment)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
favoritesAdapter = FastItemAdapter()
|
||||
favoritesAdapter.setHasStableIds(true)
|
||||
favoritesAdapter.withSavedInstanceState(savedInstanceState)
|
||||
|
||||
rvFavorites.layoutManager = LinearLayoutManager(context)
|
||||
rvFavorites.itemAnimator = AlphaInAnimator()
|
||||
rvFavorites.adapter = favoritesAdapter
|
||||
rvFavorites.itemAnimator = AlphaInAnimator()
|
||||
|
||||
onFavoriteClick()
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
viewModel.favoriteVideosLiveData.observe(viewLifecycleOwner, Observer { favoriteVideoList ->
|
||||
val favoriteItemsList = ArrayList<FavoriteItem>()
|
||||
for (favoriteVideo in favoriteVideoList) {
|
||||
favoriteItemsList.add(FavoriteItem(favoriteVideo))
|
||||
}
|
||||
|
||||
favoritesAdapter.add(favoriteItemsList)
|
||||
showEmptyState(favoritesAdapter.itemCount)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showEmptyState(itemCount: Int) {
|
||||
groupEmptyFavorites.isVisible = itemCount < 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the Heart Icon is clicked of a RecyclerView Item
|
||||
*/
|
||||
private fun onFavoriteClick() {
|
||||
favoritesAdapter.addEventHook(object : ClickEventHook<FavoriteItem>() {
|
||||
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
|
||||
return if (viewHolder is FavoriteItem.FavoriteViewHolder) {
|
||||
viewHolder.itemView.ivHeartFavoriteItem
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(
|
||||
v: View,
|
||||
position: Int,
|
||||
fastAdapter: FastAdapter<FavoriteItem>,
|
||||
item: FavoriteItem
|
||||
) {
|
||||
val favoriteIcon = v as AppCompatImageView
|
||||
|
||||
if (item.favoriteVideo.isChecked) {
|
||||
// Icon unchecked
|
||||
item.favoriteVideo.isChecked = false
|
||||
favoriteIcon.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
context!!,
|
||||
R.drawable.ic_favorite_border
|
||||
)
|
||||
)
|
||||
viewModel.removeVideoFromFavorites(item.favoriteVideo)
|
||||
} else {
|
||||
// Icon checked
|
||||
item.favoriteVideo.isChecked = true
|
||||
favoriteIcon.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
context!!,
|
||||
R.drawable.ic_favorite_filled_border
|
||||
)
|
||||
)
|
||||
viewModel.addVideoToFavorites(item.favoriteVideo)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item of the RecyclerView is clicked
|
||||
*/
|
||||
private fun onItemClick() {
|
||||
favoritesAdapter.onClickListener = { view, adapter, item, position ->
|
||||
VideoPlayerActivity.startActivity(context, item.favoriteVideo.id)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.activity.VideoPlayerActivity
|
||||
import aculix.channelify.app.fastadapteritems.HomeItem
|
||||
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.Status
|
||||
import aculix.channelify.app.utils.DividerItemDecorator
|
||||
import aculix.channelify.app.viewmodel.HomeViewModel
|
||||
import aculix.core.extensions.*
|
||||
import aculix.core.helper.ResultWrapper
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.InterstitialAd
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.GenericFastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.mikepenz.fastadapter.paged.ExperimentalPagedSupport
|
||||
import com.mikepenz.fastadapter.paged.PagedModelAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
import kotlinx.android.synthetic.main.widget_toolbar.view.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
@ExperimentalPagedSupport
|
||||
class HomeFragment : Fragment(R.layout.fragment_home) {
|
||||
|
||||
private val viewModel by viewModel<HomeViewModel>() // Lazy inject ViewModel
|
||||
|
||||
private var homeAdapter: GenericFastAdapter? = null
|
||||
private lateinit var homePagedModelAdapter: PagedModelAdapter<PlaylistItemInfo.Item, HomeItem>
|
||||
private lateinit var footerAdapter: GenericItemAdapter
|
||||
private var isFirstPageLoading = true
|
||||
private var retrySnackbar: Snackbar? = null
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupToolbar()
|
||||
|
||||
if (requireContext().isInternetAvailable()) {
|
||||
viewModel.getLatestVideos()
|
||||
} else {
|
||||
showErrorState()
|
||||
}
|
||||
|
||||
setupUploadsPlaylistIdObservables()
|
||||
setupRecyclerView(savedInstanceState)
|
||||
onRetryButtonClick()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
homeAdapter?.let {
|
||||
var outState = _outState
|
||||
outState = it.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
ablHome.toolbarMain.apply {
|
||||
inflateMenu(R.menu.main_menu)
|
||||
|
||||
// Store and Search configuration
|
||||
menu.findItem(R.id.miStoreMainMenu).isVisible =
|
||||
resources.getBoolean(R.bool.enable_store)
|
||||
menu.findItem(R.id.miSearchMainMenu).isVisible =
|
||||
resources.getBoolean(R.bool.enable_search)
|
||||
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.miStoreMainMenu -> {
|
||||
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
|
||||
}
|
||||
R.id.miSearchMainMenu -> {
|
||||
findNavController().navigate(R.id.action_homeFragment_to_searchFragment)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
val asyncDifferConfig = AsyncDifferConfig.Builder<PlaylistItemInfo.Item>(object :
|
||||
DiffUtil.ItemCallback<PlaylistItemInfo.Item>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: PlaylistItemInfo.Item,
|
||||
newItem: PlaylistItemInfo.Item
|
||||
): Boolean {
|
||||
return oldItem.contentDetails.videoId == newItem.contentDetails.videoId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: PlaylistItemInfo.Item,
|
||||
newItem: PlaylistItemInfo.Item
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}).build()
|
||||
|
||||
homePagedModelAdapter =
|
||||
PagedModelAdapter<PlaylistItemInfo.Item, HomeItem>(asyncDifferConfig) {
|
||||
HomeItem(it)
|
||||
}
|
||||
|
||||
footerAdapter = ItemAdapter.items()
|
||||
|
||||
homeAdapter = FastAdapter.with(listOf(homePagedModelAdapter, footerAdapter))
|
||||
homeAdapter?.registerTypeInstance(HomeItem(null))
|
||||
homeAdapter?.withSavedInstanceState(savedInstanceState)
|
||||
|
||||
rvHome.layoutManager = LinearLayoutManager(context)
|
||||
rvHome.adapter = homeAdapter
|
||||
rvHome.addItemDecoration(
|
||||
DividerItemDecorator(
|
||||
ContextCompat.getDrawable(
|
||||
requireContext(),
|
||||
R.drawable.view_divider_item_decorator
|
||||
)!!
|
||||
)
|
||||
)
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
private fun setupUploadsPlaylistIdObservables() {
|
||||
viewModel.uploadsPlaylistIdLiveData.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
is ResultWrapper.Loading -> {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
is ResultWrapper.Error -> {
|
||||
// Error occurred while fetching the uploads playlist id
|
||||
showErrorState(it.errorMessage)
|
||||
}
|
||||
is ResultWrapper.Success<*> -> {
|
||||
// Success in fetching the uploads playlist id
|
||||
hideErrorState()
|
||||
setupLatestVideosObservables()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupLatestVideosObservables() {
|
||||
// Observe network live data
|
||||
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
|
||||
when (networkState?.status) {
|
||||
Status.FAILED -> {
|
||||
footerAdapter.clear()
|
||||
pbHome.makeGone()
|
||||
createRetrySnackbar()
|
||||
retrySnackbar?.show()
|
||||
}
|
||||
Status.SUCCESS -> {
|
||||
footerAdapter.clear()
|
||||
pbHome.makeGone()
|
||||
}
|
||||
Status.LOADING -> {
|
||||
if (!isFirstPageLoading) {
|
||||
showRecyclerViewProgressIndicator()
|
||||
} else {
|
||||
isFirstPageLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Observe latest video live data
|
||||
viewModel.latestVideoLiveData?.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer<PagedList<PlaylistItemInfo.Item>> { latestVideoList ->
|
||||
homePagedModelAdapter.submitList(latestVideoList)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showRecyclerViewProgressIndicator() {
|
||||
footerAdapter.clear()
|
||||
val progressIndicatorItem = ProgressIndicatorItem()
|
||||
footerAdapter.add(progressIndicatorItem)
|
||||
}
|
||||
|
||||
private fun showErrorState(errorMsg: String = getString(R.string.error_internet_connectivity)) {
|
||||
rvHome.makeGone()
|
||||
pbHome.makeGone()
|
||||
groupErrorHome.makeVisible()
|
||||
tvErrorHome.text = errorMsg
|
||||
}
|
||||
|
||||
private fun hideErrorState() {
|
||||
groupErrorHome.makeGone()
|
||||
rvHome.makeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the Retry button of the error state is clicked
|
||||
*/
|
||||
private fun onRetryButtonClick() {
|
||||
btnRetryHome.setOnClickListener {
|
||||
if (requireContext().isInternetAvailable()) viewModel.getLatestVideos()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item of the RecyclerView is clicked
|
||||
*/
|
||||
private fun onItemClick() {
|
||||
homeAdapter?.onClickListener = { view, adapter, item, position ->
|
||||
if (item is HomeItem) {
|
||||
VideoPlayerActivity.startActivity(
|
||||
context,
|
||||
item.playlistItem?.contentDetails?.videoId!!
|
||||
)
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRetrySnackbar() {
|
||||
retrySnackbar =
|
||||
Snackbar.make(clHome, R.string.error_load_more_videos, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
|
||||
.setAction(R.string.btn_retry) {
|
||||
viewModel.refreshFailedRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.activity.VideoPlayerActivity
|
||||
import aculix.channelify.app.fastadapteritems.HomeItem
|
||||
import aculix.channelify.app.fastadapteritems.PlaylistVideoItem
|
||||
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.Status
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import aculix.channelify.app.utils.DividerItemDecorator
|
||||
import aculix.channelify.app.viewmodel.HomeViewModel
|
||||
import aculix.channelify.app.viewmodel.PlaylistVideosViewModel
|
||||
import aculix.core.extensions.makeGone
|
||||
import aculix.core.extensions.startShareTextIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import coil.api.load
|
||||
import com.afollestad.materialdialogs.LayoutMode
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.bottomsheets.BottomSheet
|
||||
import com.afollestad.materialdialogs.customview.customView
|
||||
import com.afollestad.materialdialogs.customview.getCustomView
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.GenericFastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.mikepenz.fastadapter.paged.PagedModelAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
import kotlinx.android.synthetic.main.fragment_playlist_details.view.*
|
||||
import kotlinx.android.synthetic.main.fragment_playlist_videos.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass to show the list of videos of a particular playlist.
|
||||
*/
|
||||
class PlaylistVideosFragment : Fragment() {
|
||||
|
||||
private val viewModel by viewModel<PlaylistVideosViewModel>() // Lazy inject ViewModel
|
||||
private val args by navArgs<PlaylistVideosFragmentArgs>()
|
||||
|
||||
private lateinit var playlistVideosAdapter: GenericFastAdapter
|
||||
private lateinit var playlistVideosPagedModelAdapter: PagedModelAdapter<PlaylistItemInfo.Item, PlaylistVideoItem>
|
||||
private lateinit var footerAdapter: GenericItemAdapter
|
||||
private var isFirstPageLoading = true
|
||||
private var retrySnackbar: Snackbar? = null
|
||||
private lateinit var playlistId: String
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_playlist_videos, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
playlistId = args.playlistId
|
||||
setupToolbar()
|
||||
|
||||
setupRecyclerView(savedInstanceState)
|
||||
fetchPlaylistVideos()
|
||||
setupObservables()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
var outState = _outState
|
||||
outState = playlistVideosAdapter.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
val navController = findNavController()
|
||||
val appBarConfiguration = AppBarConfiguration(navController.graph)
|
||||
toolbarPlaylistVideos.setupWithNavController(navController, appBarConfiguration)
|
||||
|
||||
// Inflate Menu
|
||||
toolbarPlaylistVideos.inflateMenu(R.menu.toolbar_menu_playlist_videos)
|
||||
toolbarPlaylistVideos.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.miDetailsPlaylistVideos -> {
|
||||
showPlaylistDetails()
|
||||
}
|
||||
R.id.miSharePlaylistVideos -> {
|
||||
sharePlaylistUrl()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
val asyncDifferConfig = AsyncDifferConfig.Builder<PlaylistItemInfo.Item>(object :
|
||||
DiffUtil.ItemCallback<PlaylistItemInfo.Item>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: PlaylistItemInfo.Item,
|
||||
newItem: PlaylistItemInfo.Item
|
||||
): Boolean {
|
||||
return oldItem.contentDetails.videoId == newItem.contentDetails.videoId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: PlaylistItemInfo.Item,
|
||||
newItem: PlaylistItemInfo.Item
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}).build()
|
||||
|
||||
playlistVideosPagedModelAdapter =
|
||||
PagedModelAdapter<PlaylistItemInfo.Item, PlaylistVideoItem>(asyncDifferConfig) {
|
||||
PlaylistVideoItem(it)
|
||||
}
|
||||
|
||||
footerAdapter = ItemAdapter.items()
|
||||
|
||||
playlistVideosAdapter =
|
||||
FastAdapter.with(listOf(playlistVideosPagedModelAdapter, footerAdapter))
|
||||
playlistVideosAdapter.registerTypeInstance(PlaylistVideoItem(null))
|
||||
playlistVideosAdapter.withSavedInstanceState(savedInstanceState)
|
||||
|
||||
rvPlaylistVideos.layoutManager = LinearLayoutManager(context)
|
||||
rvPlaylistVideos.adapter = playlistVideosAdapter
|
||||
rvPlaylistVideos.addItemDecoration(
|
||||
DividerItemDecorator(ContextCompat.getDrawable(requireContext(), R.drawable.view_divider_item_decorator)!!)
|
||||
)
|
||||
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
private fun fetchPlaylistVideos() {
|
||||
viewModel.getPlaylistVideos(playlistId)
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
// Observe network live data
|
||||
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
|
||||
when (networkState?.status) {
|
||||
Status.FAILED -> {
|
||||
footerAdapter.clear()
|
||||
createRetrySnackbar()
|
||||
retrySnackbar?.show()
|
||||
}
|
||||
Status.SUCCESS -> {
|
||||
footerAdapter.clear()
|
||||
}
|
||||
Status.LOADING -> {
|
||||
if (!isFirstPageLoading) {
|
||||
showRecyclerViewProgressIndicator()
|
||||
} else {
|
||||
isFirstPageLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Observe latest video live data
|
||||
viewModel.playlistVideosLiveData?.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer<PagedList<PlaylistItemInfo.Item>> { playlistVideosList ->
|
||||
playlistVideosPagedModelAdapter.submitList(playlistVideosList)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showRecyclerViewProgressIndicator() {
|
||||
footerAdapter.clear()
|
||||
val progressIndicatorItem = ProgressIndicatorItem()
|
||||
footerAdapter.add(progressIndicatorItem)
|
||||
}
|
||||
|
||||
private fun createRetrySnackbar() {
|
||||
retrySnackbar =
|
||||
Snackbar.make(
|
||||
clPlaylistVideos,
|
||||
R.string.error_load_more_videos,
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
)
|
||||
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
|
||||
.setAction(R.string.btn_retry) {
|
||||
viewModel.refreshFailedRequest()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item of the RecyclerView is clicked
|
||||
*/
|
||||
private fun onItemClick() {
|
||||
playlistVideosAdapter.onClickListener = { view, adapter, item, position ->
|
||||
if (item is PlaylistVideoItem) {
|
||||
VideoPlayerActivity.startActivity(context, item.playlistItem?.contentDetails?.videoId!!)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPlaylistDetails() {
|
||||
val playlistDetailsDialog =
|
||||
MaterialDialog(requireContext(), BottomSheet(LayoutMode.WRAP_CONTENT)).show {
|
||||
customView(R.layout.fragment_playlist_details, scrollable = true)
|
||||
|
||||
}
|
||||
|
||||
playlistDetailsDialog.getCustomView().apply {
|
||||
ivThumbnailPlaylistDetails.load(args.playlistThumbUrl)
|
||||
tvNamePlaylistDetails.text = args.playlistName
|
||||
tvVideoCountPlaylistDetails.text = context.resources.getQuantityString(
|
||||
R.plurals.text_playlist_video_count,
|
||||
args.playlistVideoCount.toInt(),
|
||||
args.playlistVideoCount.toInt()
|
||||
)
|
||||
tvTimePublishedPlaylistDetails.text = context.getString(
|
||||
R.string.text_playlist_published_date,
|
||||
DateTimeUtils.getPublishedDate(args.playlistPublishedTime)
|
||||
)
|
||||
tvDescPlaylistDetails.text = args.playlistDesc
|
||||
}
|
||||
}
|
||||
|
||||
private fun sharePlaylistUrl() {
|
||||
context?.startShareTextIntent(
|
||||
getString(R.string.text_share_playlist),
|
||||
getString(R.string.text_playlist_share_url, args.playlistId)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.fastadapteritems.PlaylistItem
|
||||
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
|
||||
import aculix.channelify.app.model.Playlist
|
||||
import aculix.channelify.app.paging.Status
|
||||
import aculix.channelify.app.utils.DividerItemDecorator
|
||||
import aculix.channelify.app.viewmodel.PlaylistsViewModel
|
||||
import aculix.core.extensions.makeGone
|
||||
import aculix.core.extensions.makeVisible
|
||||
import aculix.core.extensions.openUrl
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.GenericFastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.mikepenz.fastadapter.paged.PagedModelAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_playlists.*
|
||||
import kotlinx.android.synthetic.main.widget_toolbar.view.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass to show the list of
|
||||
* Playlists of a channel.
|
||||
*/
|
||||
class PlaylistsFragment : Fragment(R.layout.fragment_playlists) {
|
||||
|
||||
private val viewModel by viewModel<PlaylistsViewModel>() // Lazy inject ViewModel
|
||||
|
||||
private lateinit var playlistsAdapter: GenericFastAdapter
|
||||
private lateinit var playlistsPagedModelAdapter: PagedModelAdapter<Playlist.Item, PlaylistItem>
|
||||
private lateinit var footerAdapter: GenericItemAdapter
|
||||
private var isFirstPageLoading = true
|
||||
private var retrySnackbar: Snackbar? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupToolbar()
|
||||
|
||||
setupRecyclerView(savedInstanceState)
|
||||
fetchPlaylists()
|
||||
setupObservables()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
var outState = _outState
|
||||
outState = playlistsAdapter.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
ablPlaylists.toolbarMain.apply {
|
||||
inflateMenu(R.menu.main_menu)
|
||||
|
||||
// Store and Search configuration
|
||||
menu.findItem(R.id.miStoreMainMenu).isVisible =
|
||||
resources.getBoolean(R.bool.enable_store)
|
||||
menu.findItem(R.id.miSearchMainMenu).isVisible =
|
||||
resources.getBoolean(R.bool.enable_search)
|
||||
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.miStoreMainMenu -> {
|
||||
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
|
||||
}
|
||||
R.id.miSearchMainMenu -> {
|
||||
findNavController().navigate(R.id.action_playlistsFragment_to_searchFragment)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
val asyncDifferConfig = AsyncDifferConfig.Builder<Playlist.Item>(object :
|
||||
DiffUtil.ItemCallback<Playlist.Item>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: Playlist.Item,
|
||||
newItem: Playlist.Item
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: Playlist.Item,
|
||||
newItem: Playlist.Item
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}).build()
|
||||
|
||||
playlistsPagedModelAdapter =
|
||||
PagedModelAdapter<Playlist.Item, PlaylistItem>(asyncDifferConfig) {
|
||||
PlaylistItem(it)
|
||||
}
|
||||
|
||||
footerAdapter = ItemAdapter.items()
|
||||
|
||||
playlistsAdapter = FastAdapter.with(listOf(playlistsPagedModelAdapter, footerAdapter))
|
||||
playlistsAdapter.registerTypeInstance(PlaylistItem(null))
|
||||
playlistsAdapter.withSavedInstanceState(savedInstanceState)
|
||||
|
||||
rvPlaylists.layoutManager = LinearLayoutManager(context)
|
||||
rvPlaylists.addItemDecoration(
|
||||
DividerItemDecorator(
|
||||
ContextCompat.getDrawable(
|
||||
requireContext(),
|
||||
R.drawable.view_divider_item_decorator
|
||||
)!!
|
||||
)
|
||||
)
|
||||
rvPlaylists.adapter = playlistsAdapter
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
// Observe Empty State LiveData
|
||||
viewModel.emptyStateLiveData.observe(viewLifecycleOwner, Observer { isResultEmpty ->
|
||||
if (isResultEmpty) {
|
||||
showEmptyState()
|
||||
} else {
|
||||
hideEmptyState()
|
||||
}
|
||||
})
|
||||
|
||||
// Observe network live data
|
||||
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
|
||||
when (networkState?.status) {
|
||||
Status.FAILED -> {
|
||||
footerAdapter.clear()
|
||||
pbPlaylists.makeGone()
|
||||
createRetrySnackbar()
|
||||
retrySnackbar?.show()
|
||||
}
|
||||
Status.SUCCESS -> {
|
||||
footerAdapter.clear()
|
||||
pbPlaylists.makeGone()
|
||||
}
|
||||
Status.LOADING -> {
|
||||
if (!isFirstPageLoading) {
|
||||
showRecyclerViewProgressIndicator()
|
||||
} else {
|
||||
isFirstPageLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Observe latest video live data
|
||||
viewModel.playlistsLiveData?.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer<PagedList<Playlist.Item>> { playlistsList ->
|
||||
playlistsPagedModelAdapter.submitList(playlistsList)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showRecyclerViewProgressIndicator() {
|
||||
footerAdapter.clear()
|
||||
val progressIndicatorItem = ProgressIndicatorItem()
|
||||
footerAdapter.add(progressIndicatorItem)
|
||||
}
|
||||
|
||||
private fun showEmptyState() {
|
||||
groupEmptyPlaylists.makeVisible()
|
||||
}
|
||||
|
||||
private fun hideEmptyState() {
|
||||
groupEmptyPlaylists.makeGone()
|
||||
}
|
||||
|
||||
private fun fetchPlaylists() {
|
||||
viewModel.getPlaylists()
|
||||
}
|
||||
|
||||
private fun createRetrySnackbar() {
|
||||
retrySnackbar =
|
||||
Snackbar.make(clPlaylists, R.string.error_fetch_playlists, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
|
||||
.setAction(R.string.btn_retry) {
|
||||
viewModel.refreshFailedRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onItemClick() {
|
||||
playlistsAdapter.onClickListener = { view, adapter, item, position ->
|
||||
if (item is PlaylistItem) {
|
||||
val action =
|
||||
PlaylistsFragmentDirections.actionPlaylistsFragmentToPlaylistVideosFragment(
|
||||
item.playlistItem?.snippet?.title!!,
|
||||
item.playlistItem.id,
|
||||
item.playlistItem.snippet.description,
|
||||
item.playlistItem.contentDetails.itemCount.toFloat(),
|
||||
item.playlistItem.snippet.thumbnails.standard?.url
|
||||
?: item.playlistItem.snippet.thumbnails.high.url,
|
||||
item.playlistItem.snippet.publishedAt
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.activity.VideoPlayerActivity
|
||||
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
|
||||
import aculix.channelify.app.fastadapteritems.SearchItem
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.paging.Status
|
||||
import aculix.channelify.app.utils.DividerItemDecorator
|
||||
import aculix.channelify.app.viewmodel.SearchViewModel
|
||||
import aculix.core.extensions.dismissKeyboard
|
||||
import aculix.core.extensions.makeGone
|
||||
import aculix.core.extensions.makeVisible
|
||||
import aculix.core.extensions.showKeyboard
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.SearchView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.GenericFastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.mikepenz.fastadapter.paged.PagedModelAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_search.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class SearchFragment : Fragment() {
|
||||
|
||||
private val viewModel by viewModel<SearchViewModel>() // Lazy inject ViewModel
|
||||
|
||||
private lateinit var searchAdapter: GenericFastAdapter
|
||||
private lateinit var searchPagedModelAdapter: PagedModelAdapter<SearchedVideo.Item, SearchItem>
|
||||
private lateinit var footerAdapter: GenericItemAdapter
|
||||
private var isFirstPageLoading = true
|
||||
private var retrySnackbar: Snackbar? = null
|
||||
private var isSearchRequestInitialized = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_search, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupToolbar()
|
||||
|
||||
setupSearchView()
|
||||
setupRecyclerView(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(_outState: Bundle) {
|
||||
var outState = _outState
|
||||
outState = searchAdapter.saveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
svSearch.dismissKeyboard(context)
|
||||
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
val navController = findNavController()
|
||||
val appBarConfiguration = AppBarConfiguration(navController.graph)
|
||||
toolbarSearch.setupWithNavController(navController, appBarConfiguration)
|
||||
}
|
||||
|
||||
private fun setupSearchView() {
|
||||
svSearch.apply {
|
||||
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
svSearch.dismissKeyboard(context)
|
||||
|
||||
if (query.isNotBlank()) {
|
||||
pbSearch.makeVisible()
|
||||
|
||||
if (!isSearchRequestInitialized) {
|
||||
isSearchRequestInitialized = true
|
||||
viewModel.searchVideos(query)
|
||||
setupObservables() // A bug arises and create() of DataSourceFactory is not called if observables are set before making an initial call :|
|
||||
} else {
|
||||
viewModel.setSearchQuery(query)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(p0: String?): Boolean {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// Set focus on the SearchView and open the keyboard
|
||||
setOnQueryTextFocusChangeListener { view, hasFocus ->
|
||||
if (hasFocus) {
|
||||
svSearch.findFocus().showKeyboard(context)
|
||||
}
|
||||
}
|
||||
requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(savedInstanceState: Bundle?) {
|
||||
val asyncDifferConfig = AsyncDifferConfig.Builder<SearchedVideo.Item>(object :
|
||||
DiffUtil.ItemCallback<SearchedVideo.Item>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: SearchedVideo.Item,
|
||||
newItem: SearchedVideo.Item
|
||||
): Boolean {
|
||||
return oldItem.id.videoId == newItem.id.videoId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: SearchedVideo.Item,
|
||||
newItem: SearchedVideo.Item
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}).build()
|
||||
|
||||
searchPagedModelAdapter =
|
||||
PagedModelAdapter<SearchedVideo.Item, SearchItem>(asyncDifferConfig) {
|
||||
SearchItem(it)
|
||||
}
|
||||
|
||||
footerAdapter = ItemAdapter.items()
|
||||
|
||||
searchAdapter = FastAdapter.with(listOf(searchPagedModelAdapter, footerAdapter))
|
||||
searchAdapter.registerTypeInstance(SearchItem(null))
|
||||
searchAdapter.withSavedInstanceState(savedInstanceState)
|
||||
|
||||
rvSearch.layoutManager = LinearLayoutManager(context)
|
||||
rvSearch.addItemDecoration(
|
||||
DividerItemDecorator(
|
||||
ContextCompat.getDrawable(
|
||||
requireContext(),
|
||||
R.drawable.view_divider_item_decorator
|
||||
)!!
|
||||
)
|
||||
)
|
||||
rvSearch.adapter = searchAdapter
|
||||
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
// Observe Empty State LiveData
|
||||
viewModel.emptyStateLiveData.observe(viewLifecycleOwner, Observer { isResultEmpty ->
|
||||
if (isResultEmpty) {
|
||||
// True as no pages are loaded. If not done two loaders are shown when searched again.
|
||||
isFirstPageLoading = true
|
||||
showEmptyState()
|
||||
} else {
|
||||
hideEmptyState()
|
||||
}
|
||||
})
|
||||
|
||||
// Observe network live data
|
||||
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
|
||||
when (networkState?.status) {
|
||||
Status.FAILED -> {
|
||||
footerAdapter.clear()
|
||||
pbSearch.makeGone()
|
||||
createRetrySnackbar()
|
||||
retrySnackbar?.show()
|
||||
}
|
||||
|
||||
Status.SUCCESS -> {
|
||||
footerAdapter.clear()
|
||||
pbSearch.makeGone()
|
||||
hideEmptyState()
|
||||
}
|
||||
|
||||
Status.LOADING -> {
|
||||
if (!isFirstPageLoading) {
|
||||
showRecyclerViewProgressIndicator()
|
||||
} else {
|
||||
isFirstPageLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Observe latest video live data
|
||||
viewModel.searchResultLiveData?.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer<PagedList<SearchedVideo.Item>> { videoList ->
|
||||
searchPagedModelAdapter.submitList(videoList)
|
||||
})
|
||||
}
|
||||
|
||||
private fun showRecyclerViewProgressIndicator() {
|
||||
footerAdapter.clear()
|
||||
val progressIndicatorItem = ProgressIndicatorItem()
|
||||
footerAdapter.add(progressIndicatorItem)
|
||||
}
|
||||
|
||||
private fun createRetrySnackbar() {
|
||||
retrySnackbar =
|
||||
Snackbar.make(clSearch, R.string.error_load_more_videos, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
|
||||
.setAction(R.string.btn_retry) {
|
||||
viewModel.refreshFailedRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmptyState() {
|
||||
groupEmptySearch.makeVisible()
|
||||
}
|
||||
|
||||
private fun hideEmptyState() {
|
||||
groupEmptySearch.makeGone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item of the RecyclerView is clicked
|
||||
*/
|
||||
private fun onItemClick() {
|
||||
searchAdapter.onClickListener = { view, adapter, item, position ->
|
||||
if (item is SearchItem) {
|
||||
VideoPlayerActivity.startActivity(context, item.searchedVideo?.id?.videoId!!)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package aculix.channelify.app.fragment
|
||||
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import aculix.channelify.app.model.Video
|
||||
import aculix.channelify.app.utils.DateTimeUtils
|
||||
import aculix.channelify.app.viewmodel.VideoDetailsViewModel
|
||||
import aculix.core.extensions.*
|
||||
import aculix.core.helper.ResultWrapper
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import kotlinx.android.synthetic.main.fragment_video_details.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class VideoDetailsFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
const val VIDEO_ID = "videoId"
|
||||
}
|
||||
|
||||
private val viewModel by viewModel<VideoDetailsViewModel>() // Lazy inject ViewModel
|
||||
private val args by navArgs<VideoDetailsFragmentArgs>()
|
||||
|
||||
private lateinit var videoId: String
|
||||
private lateinit var videoItem: Video.Item
|
||||
private var isVideoAddedToFavorite = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_video_details, container, false)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
setupObservables()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
videoId = args.videoId
|
||||
|
||||
fetchVideoInfo()
|
||||
fetchVideoFavoriteStatus()
|
||||
|
||||
setupBottomAppBar()
|
||||
tvCommentsVideoDetails.setOnClickListener { onCommentsClick() }
|
||||
btnRetryVideoDetails.setOnClickListener { onRetryClick() }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the info of video
|
||||
*/
|
||||
private fun fetchVideoInfo() {
|
||||
if (isInternetAvailable(requireContext())) {
|
||||
viewModel.getVideoInfo(videoId)
|
||||
} else {
|
||||
showVideoInfoErrorState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current playing video is already added to favorites or not
|
||||
*/
|
||||
private fun fetchVideoFavoriteStatus() {
|
||||
viewModel.getVideoFavoriteStatus(videoId)
|
||||
}
|
||||
|
||||
private fun setupObservables() {
|
||||
viewModel.videoInfoLiveData.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
is ResultWrapper.Error -> {
|
||||
showVideoInfoErrorState()
|
||||
}
|
||||
is ResultWrapper.Success<*> -> {
|
||||
hideVideoInfoErrorState()
|
||||
videoItem = (it.data as Video).items[0]
|
||||
setVideoInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.favoriteVideoLiveData.observe(viewLifecycleOwner, Observer { isFavorite ->
|
||||
isVideoAddedToFavorite = isFavorite
|
||||
|
||||
if (isVideoAddedToFavorite) {
|
||||
babVideoDetails.menu.findItem(R.id.miFavoriteBabVideoDetails)
|
||||
.setIcon(R.drawable.ic_favorite_filled)
|
||||
} else {
|
||||
babVideoDetails.menu.findItem(R.id.miFavoriteBabVideoDetails)
|
||||
.setIcon(R.drawable.ic_favorite_border)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setVideoInfo() {
|
||||
with(videoItem) {
|
||||
tvVideoTitleVideoDetails.text = snippet.title
|
||||
tvViewCountVideoDetails.text =
|
||||
statistics.viewCount?.toLong()?.getFormattedNumberInString() ?: getString(
|
||||
R.string.text_count_unavailable
|
||||
)
|
||||
tvLikeCountVideoDetails.text =
|
||||
statistics.likeCount?.toLong()?.getFormattedNumberInString() ?: getString(
|
||||
R.string.text_count_unavailable
|
||||
)
|
||||
tvDislikeCountVideoDetails.text =
|
||||
statistics.dislikeCount?.toLong()?.getFormattedNumberInString() ?: getString(
|
||||
R.string.text_count_unavailable
|
||||
)
|
||||
tvCommentCountVideoDetails.text =
|
||||
statistics.commentCount.toLong().getFormattedNumberInString()
|
||||
tvVideoDescVideoDetails.text = getString(
|
||||
R.string.text_video_description,
|
||||
DateTimeUtils.getPublishedDate(snippet.publishedAt),
|
||||
snippet.description
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVideoInfoErrorState() {
|
||||
groupInfoVideoDetails.makeGone()
|
||||
groupErrorVideoDetails.makeVisible()
|
||||
}
|
||||
|
||||
private fun hideVideoInfoErrorState() {
|
||||
groupErrorVideoDetails.makeGone()
|
||||
groupInfoVideoDetails.makeVisible()
|
||||
}
|
||||
|
||||
|
||||
private fun setupBottomAppBar() {
|
||||
babVideoDetails.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.miFavoriteBabVideoDetails -> {
|
||||
if (this::videoItem.isInitialized) {
|
||||
// Add or remove from favorites only after videoItem details are fetched
|
||||
isVideoAddedToFavorite = if (isVideoAddedToFavorite) {
|
||||
item.setIcon(R.drawable.ic_favorite_border)
|
||||
removeVideoFromFavorites()
|
||||
false
|
||||
} else {
|
||||
// Add video to favorites
|
||||
item.setIcon(R.drawable.ic_favorite_filled)
|
||||
addVideoToFavorites()
|
||||
true
|
||||
}
|
||||
} else {
|
||||
context?.toast(getString(R.string.text_fetch_video_details_wait_msg))
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.miShareBabVideoDetails -> {
|
||||
shareVideoUrl()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun addVideoToFavorites() {
|
||||
val favoriteVideo = FavoriteVideo(
|
||||
videoId,
|
||||
videoItem.snippet.title,
|
||||
videoItem.snippet.thumbnails.standard?.url ?: videoItem.snippet.thumbnails.high.url,
|
||||
System.currentTimeMillis(),
|
||||
true
|
||||
)
|
||||
viewModel.addVideoToFavorites(favoriteVideo)
|
||||
}
|
||||
|
||||
private fun removeVideoFromFavorites() {
|
||||
val favoriteVideo = FavoriteVideo(
|
||||
videoId,
|
||||
videoItem.snippet.title,
|
||||
videoItem.snippet.thumbnails.standard?.url ?: videoItem.snippet.thumbnails.high.url,
|
||||
System.currentTimeMillis(),
|
||||
true
|
||||
)
|
||||
viewModel.removeVideoFromFavorites(favoriteVideo)
|
||||
}
|
||||
|
||||
private fun shareVideoUrl() {
|
||||
context?.startShareTextIntent(
|
||||
getString(R.string.text_share_video),
|
||||
getString(R.string.text_video_share_url, videoId)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onCommentsClick() {
|
||||
findNavController().navigate(
|
||||
VideoDetailsFragmentDirections.actionVideoDetailsFragmentToCommentsFragment(
|
||||
videoId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onRetryClick() {
|
||||
fetchVideoInfo()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
data class ChannelInfo(
|
||||
val items: List<Item>
|
||||
) {
|
||||
data class Item(
|
||||
val brandingSettings: BrandingSettings?,
|
||||
val snippet: Snippet,
|
||||
val statistics: Statistics
|
||||
) {
|
||||
data class BrandingSettings(
|
||||
val image: Image?
|
||||
) {
|
||||
data class Image(
|
||||
val bannerMobileHdImageUrl: String?,
|
||||
val bannerMobileMediumHdImageUrl: String
|
||||
)
|
||||
}
|
||||
|
||||
data class Snippet(
|
||||
val description: String,
|
||||
val publishedAt: String,
|
||||
val thumbnails: Thumbnails,
|
||||
val title: String
|
||||
) {
|
||||
data class Thumbnails(
|
||||
val default: Default,
|
||||
val high: High?,
|
||||
val medium: Medium
|
||||
) {
|
||||
data class Default(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class High(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Medium(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Statistics(
|
||||
val subscriberCount: String,
|
||||
val videoCount: String,
|
||||
val viewCount: String
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Model class that is returned when an API call is made to
|
||||
* request details about the Uploads playlist of the channel
|
||||
*
|
||||
* URL: https://www.googleapis.com/youtube/v3/channels
|
||||
*/
|
||||
data class ChannelUploadsPlaylistInfo(
|
||||
val items: List<Item>
|
||||
) {
|
||||
data class Item(
|
||||
val contentDetails: ContentDetails
|
||||
) {
|
||||
data class ContentDetails(
|
||||
val relatedPlaylists: RelatedPlaylists
|
||||
) {
|
||||
data class RelatedPlaylists(
|
||||
val uploads: String
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Comment(
|
||||
val items: List<Item>,
|
||||
val nextPageToken: String?
|
||||
) {
|
||||
data class Item(
|
||||
val snippet: Snippet
|
||||
) {
|
||||
data class Snippet(
|
||||
val topLevelComment: TopLevelComment,
|
||||
val totalReplyCount: Int
|
||||
) {
|
||||
data class TopLevelComment(
|
||||
val id: String,
|
||||
val snippet: Snippet
|
||||
) {
|
||||
data class Snippet(
|
||||
val authorDisplayName: String,
|
||||
val authorProfileImageUrl: String,
|
||||
val likeCount: Int,
|
||||
val publishedAt: String,
|
||||
val textOriginal: String,
|
||||
val updatedAt: String
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class CommentReply(
|
||||
val items: List<Item>,
|
||||
val nextPageToken: String?
|
||||
) {
|
||||
data class Item(
|
||||
val id: String,
|
||||
val snippet: Snippet
|
||||
) {
|
||||
data class Snippet(
|
||||
val authorDisplayName: String,
|
||||
val authorProfileImageUrl: String,
|
||||
val likeCount: Int,
|
||||
val publishedAt: String,
|
||||
val textOriginal: String,
|
||||
val updatedAt: String
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "favorite_videos")
|
||||
data class FavoriteVideo(@PrimaryKey val id: String,
|
||||
val title: String,
|
||||
val thumbnail: String,
|
||||
val timeInMillis: Long,
|
||||
var isChecked: Boolean)
|
||||
@@ -0,0 +1,57 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Playlist(
|
||||
val items: List<Item>,
|
||||
val nextPageToken: String
|
||||
) {
|
||||
data class Item(
|
||||
val contentDetails: ContentDetails,
|
||||
val id: String,
|
||||
val snippet: Snippet
|
||||
) {
|
||||
data class ContentDetails(
|
||||
val itemCount: Int
|
||||
)
|
||||
|
||||
data class Snippet(
|
||||
val description: String,
|
||||
val publishedAt: String,
|
||||
val thumbnails: Thumbnails,
|
||||
val title: String
|
||||
) {
|
||||
data class Thumbnails(
|
||||
val default: Default,
|
||||
val high: High,
|
||||
val medium: Medium,
|
||||
val standard: Standard?
|
||||
) {
|
||||
data class Default(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class High(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Medium(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Standard(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class PlaylistItemInfo(
|
||||
val nextPageToken: String?,
|
||||
val prevPageToken: String?,
|
||||
val items: List<Item>
|
||||
) {
|
||||
data class Item(
|
||||
val contentDetails: ContentDetails,
|
||||
val snippet: Snippet
|
||||
) {
|
||||
data class ContentDetails(
|
||||
val videoId: String,
|
||||
val videoPublishedAt: String
|
||||
)
|
||||
|
||||
data class Snippet(
|
||||
val thumbnails: Thumbnails,
|
||||
val title: String
|
||||
) {
|
||||
data class Thumbnails(
|
||||
val default: Default,
|
||||
val high: High,
|
||||
val maxres: Maxres,
|
||||
val medium: Medium,
|
||||
val standard: Standard?
|
||||
) {
|
||||
data class Default(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class High(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Maxres(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Medium(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Standard(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
/**
|
||||
* Model class that is returned when a Video Search API call is made.
|
||||
*
|
||||
* URL: https://www.googleapis.com/youtube/v3/search
|
||||
*/
|
||||
data class SearchedVideo(
|
||||
val items: List<Item>,
|
||||
val nextPageToken: String?
|
||||
) {
|
||||
data class Item(
|
||||
val id: Id,
|
||||
val snippet: Snippet
|
||||
) {
|
||||
data class Id(
|
||||
val videoId: String
|
||||
)
|
||||
|
||||
data class Snippet(
|
||||
val publishedAt: String,
|
||||
val thumbnails: Thumbnails,
|
||||
val title: String
|
||||
) {
|
||||
data class Thumbnails(
|
||||
val default: Default,
|
||||
val high: High,
|
||||
val medium: Medium,
|
||||
val standard: Standard?
|
||||
|
||||
) {
|
||||
data class Default(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class High(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Medium(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Standard(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package aculix.channelify.app.model
|
||||
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Video(
|
||||
val items: List<Item>
|
||||
) {
|
||||
data class Item(
|
||||
val snippet: Snippet,
|
||||
val statistics: Statistics
|
||||
) {
|
||||
|
||||
data class Snippet(
|
||||
val description: String,
|
||||
val publishedAt: String,
|
||||
val title: String,
|
||||
val thumbnails: Thumbnails
|
||||
) {
|
||||
data class Thumbnails(
|
||||
val default: Default,
|
||||
val high: High,
|
||||
val maxres: Maxres,
|
||||
val medium: Medium,
|
||||
val standard: Standard?
|
||||
) {
|
||||
data class Default(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class High(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Maxres(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Medium(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
|
||||
data class Standard(
|
||||
val height: Int,
|
||||
val url: String,
|
||||
val width: Int
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Statistics(
|
||||
val commentCount: String,
|
||||
val dislikeCount: String?,
|
||||
val favoriteCount: String,
|
||||
val likeCount: String?,
|
||||
val viewCount: String?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package aculix.channelify.app.paging
|
||||
|
||||
/**
|
||||
* Class used to handle network state
|
||||
*/
|
||||
data class NetworkState constructor(val status: Status,
|
||||
val msg: String? = null) {
|
||||
|
||||
companion object {
|
||||
val LOADED = NetworkState(Status.SUCCESS)
|
||||
val LOADING = NetworkState(Status.LOADING)
|
||||
fun error(msg: String?) = NetworkState(Status.FAILED, msg)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Status {
|
||||
LOADING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package aculix.channelify.app.paging.datasource
|
||||
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.CommentRepliesRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PageKeyedDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class CommentRepliesDataSource(
|
||||
private val commentRepliesRepository: CommentRepliesRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val commentId: String
|
||||
) : PageKeyedDataSource<String, CommentReply.Item>() {
|
||||
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private val networkState = MutableLiveData<NetworkState>()
|
||||
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
|
||||
private var nextPageToken: String? = null
|
||||
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<String, CommentReply.Item>
|
||||
) {
|
||||
retryQuery = { loadInitial(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, null, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, CommentReply.Item>
|
||||
) {
|
||||
retryQuery = { loadAfter(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, CommentReply.Item>
|
||||
) {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
|
||||
private fun executeQuery(callback: (List<CommentReply.Item>) -> Unit) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
|
||||
val commentReply = commentRepliesRepository.getCommentReplies(commentId, nextPageToken).body()
|
||||
nextPageToken = commentReply?.nextPageToken
|
||||
val commentRepliesList = commentReply?.items
|
||||
retryQuery = null
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
callback(commentRepliesList ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
|
||||
Timber.e("An error happened: $e")
|
||||
networkState.postValue(
|
||||
NetworkState.error(
|
||||
e.localizedMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
|
||||
}
|
||||
|
||||
fun getNetworkState(): LiveData<NetworkState> = networkState
|
||||
|
||||
fun refresh() = this.invalidate()
|
||||
|
||||
fun retryFailedQuery() {
|
||||
val prevQuery = retryQuery
|
||||
retryQuery = null
|
||||
prevQuery?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package aculix.channelify.app.paging.datasource
|
||||
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.CommentsRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PageKeyedDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class CommentsDataSource(
|
||||
private val commentsRepository: CommentsRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val videoId: String,
|
||||
var sortOrder: String
|
||||
) : PageKeyedDataSource<String, Comment.Item>() {
|
||||
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private val networkState = MutableLiveData<NetworkState>()
|
||||
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
|
||||
private var nextPageToken: String? = null
|
||||
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<String, Comment.Item>
|
||||
) {
|
||||
retryQuery = { loadInitial(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, null, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, Comment.Item>
|
||||
) {
|
||||
retryQuery = { loadAfter(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, Comment.Item>
|
||||
) {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
|
||||
private fun executeQuery(callback: (List<Comment.Item>) -> Unit) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
|
||||
val comment = commentsRepository.getVideoComments(videoId, nextPageToken, sortOrder).body()
|
||||
nextPageToken = comment?.nextPageToken
|
||||
val commentsList = comment?.items
|
||||
retryQuery = null
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
callback(commentsList ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
|
||||
Timber.e("An error happened: $e")
|
||||
networkState.postValue(
|
||||
NetworkState.error(
|
||||
e.localizedMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
|
||||
}
|
||||
|
||||
fun getNetworkState(): LiveData<NetworkState> = networkState
|
||||
|
||||
fun refresh() = this.invalidate()
|
||||
|
||||
fun retryFailedQuery() {
|
||||
val prevQuery = retryQuery
|
||||
retryQuery = null
|
||||
prevQuery?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package aculix.channelify.app.paging.datasource
|
||||
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.HomeRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PageKeyedDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class HomeDataSource(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val playlistId: String
|
||||
) : PageKeyedDataSource<String, PlaylistItemInfo.Item>() {
|
||||
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private val networkState = MutableLiveData<NetworkState>()
|
||||
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
|
||||
private var nextPageToken: String? = null
|
||||
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<String, PlaylistItemInfo.Item>
|
||||
) {
|
||||
retryQuery = { loadInitial(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, null, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, PlaylistItemInfo.Item>
|
||||
) {
|
||||
retryQuery = { loadAfter(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, PlaylistItemInfo.Item>
|
||||
) {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
|
||||
private fun executeQuery(callback: (List<PlaylistItemInfo.Item>) -> Unit) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
|
||||
val playlistItemInfo = homeRepository.getLatestVideos(playlistId, nextPageToken).body()
|
||||
nextPageToken = playlistItemInfo?.nextPageToken
|
||||
val latestVideosList = playlistItemInfo?.items
|
||||
|
||||
retryQuery = null
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
callback(latestVideosList ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
|
||||
Timber.e("An error happened: $e")
|
||||
networkState.postValue(
|
||||
NetworkState.error(
|
||||
e.localizedMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
|
||||
}
|
||||
|
||||
fun getNetworkState(): LiveData<NetworkState> = networkState
|
||||
|
||||
fun refresh() = this.invalidate()
|
||||
|
||||
fun retryFailedQuery() {
|
||||
val prevQuery = retryQuery
|
||||
retryQuery = null
|
||||
prevQuery?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package aculix.channelify.app.paging.datasource
|
||||
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.PlaylistVideosRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PageKeyedDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class PlaylistVideosDataSource(
|
||||
private val repository: PlaylistVideosRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val playlistId: String
|
||||
) : PageKeyedDataSource<String, PlaylistItemInfo.Item>() {
|
||||
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private val networkState = MutableLiveData<NetworkState>()
|
||||
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
|
||||
private var nextPageToken: String? = null
|
||||
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<String, PlaylistItemInfo.Item>
|
||||
) {
|
||||
retryQuery = { loadInitial(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, null, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, PlaylistItemInfo.Item>
|
||||
) {
|
||||
retryQuery = { loadAfter(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, PlaylistItemInfo.Item>
|
||||
) {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
|
||||
private fun executeQuery(callback: (List<PlaylistItemInfo.Item>) -> Unit) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
|
||||
val playlistItemInfo = repository.getPlaylistVideos(playlistId, nextPageToken).body()
|
||||
nextPageToken = playlistItemInfo?.nextPageToken
|
||||
val playlistVideosList = playlistItemInfo?.items
|
||||
|
||||
retryQuery = null
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
callback(playlistVideosList ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
|
||||
Timber.e("An error happened: $e")
|
||||
networkState.postValue(
|
||||
NetworkState.error(
|
||||
e.localizedMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
|
||||
}
|
||||
|
||||
fun getNetworkState(): LiveData<NetworkState> = networkState
|
||||
|
||||
fun refresh() = this.invalidate()
|
||||
|
||||
fun retryFailedQuery() {
|
||||
val prevQuery = retryQuery
|
||||
retryQuery = null
|
||||
prevQuery?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package aculix.channelify.app.paging.datasource
|
||||
|
||||
import aculix.channelify.app.model.Playlist
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.PlaylistsRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PageKeyedDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class PlaylistsDataSource(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val channelId: String
|
||||
) : PageKeyedDataSource<String, Playlist.Item>() {
|
||||
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private val networkState = MutableLiveData<NetworkState>()
|
||||
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
|
||||
private var nextPageToken: String? = null
|
||||
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<String, Playlist.Item>
|
||||
) {
|
||||
retryQuery = { loadInitial(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, null, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, Playlist.Item>
|
||||
) {
|
||||
retryQuery = { loadAfter(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, Playlist.Item>
|
||||
) {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
|
||||
private fun executeQuery(callback: (List<Playlist.Item>) -> Unit) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
|
||||
val response = repository.getPlaylists(channelId, nextPageToken).body()
|
||||
nextPageToken = response?.nextPageToken
|
||||
val playlistList = response?.items
|
||||
|
||||
retryQuery = null
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
callback(playlistList ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
|
||||
Timber.e("An error happened: $e")
|
||||
networkState.postValue(
|
||||
NetworkState.error(
|
||||
e.localizedMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
|
||||
}
|
||||
|
||||
fun getNetworkState(): LiveData<NetworkState> = networkState
|
||||
|
||||
fun refresh() = this.invalidate()
|
||||
|
||||
fun retryFailedQuery() {
|
||||
val prevQuery = retryQuery
|
||||
retryQuery = null
|
||||
prevQuery?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package aculix.channelify.app.paging.datasource
|
||||
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.SearchRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PageKeyedDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import timber.log.Timber
|
||||
|
||||
class SearchDataSource(
|
||||
private val searchRepository: SearchRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val channelId: String,
|
||||
private var searchQuery: String,
|
||||
private val emptySearchResultText: String
|
||||
) : PageKeyedDataSource<String, SearchedVideo.Item>() {
|
||||
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private val networkState = MutableLiveData<NetworkState>()
|
||||
private var retryQuery: (() -> Any)? =
|
||||
null // Keep reference of the last query (to be able to retry it if necessary)
|
||||
private var nextPageToken: String? = null
|
||||
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<String, SearchedVideo.Item>
|
||||
) {
|
||||
retryQuery = { loadInitial(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, null, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, SearchedVideo.Item>
|
||||
) {
|
||||
retryQuery = { loadAfter(params, callback) }
|
||||
executeQuery {
|
||||
callback.onResult(it, nextPageToken)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(
|
||||
params: LoadParams<String>,
|
||||
callback: LoadCallback<String, SearchedVideo.Item>
|
||||
) {
|
||||
// Data is always fetched from the next page and hence loadBefore is never needed
|
||||
}
|
||||
|
||||
private fun executeQuery(callback: (List<SearchedVideo.Item>) -> Unit) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
|
||||
val searchVideoResult =
|
||||
searchRepository.searchVideos(searchQuery, channelId, nextPageToken).body()
|
||||
nextPageToken = searchVideoResult?.nextPageToken
|
||||
val videoList = searchVideoResult?.items
|
||||
retryQuery = null
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
callback(videoList ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
|
||||
Timber.e("An error happened: $e")
|
||||
networkState.postValue(
|
||||
NetworkState.error(
|
||||
e.localizedMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
|
||||
}
|
||||
|
||||
fun getNetworkState(): LiveData<NetworkState> = networkState
|
||||
|
||||
fun refresh() = this.invalidate()
|
||||
|
||||
fun retryFailedQuery() {
|
||||
val prevQuery = retryQuery
|
||||
retryQuery = null
|
||||
prevQuery?.invoke()
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package aculix.channelify.app.paging.datasourcefactory
|
||||
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.paging.datasource.CommentRepliesDataSource
|
||||
import aculix.channelify.app.repository.CommentRepliesRepository
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class CommentRepliesDataSourceFactory(
|
||||
private val commentRepliesRepository: CommentRepliesRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val commentId: String
|
||||
) : DataSource.Factory<String, CommentReply.Item>() {
|
||||
|
||||
private var commentRepliesDataSource: CommentRepliesDataSource? = null
|
||||
val commentRepliesDataSourceLiveData = MutableLiveData<CommentRepliesDataSource>()
|
||||
|
||||
override fun create(): DataSource<String, CommentReply.Item> {
|
||||
// Also called every time when invalidate() is executed
|
||||
commentRepliesDataSource =
|
||||
CommentRepliesDataSource(
|
||||
commentRepliesRepository,
|
||||
coroutineScope,
|
||||
commentId
|
||||
)
|
||||
commentRepliesDataSourceLiveData.postValue(commentRepliesDataSource)
|
||||
|
||||
return commentRepliesDataSource!!
|
||||
}
|
||||
|
||||
fun getSource() = commentRepliesDataSourceLiveData.value
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package aculix.channelify.app.paging.datasourcefactory
|
||||
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.paging.datasource.CommentsDataSource
|
||||
import aculix.channelify.app.repository.CommentsRepository
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import timber.log.Timber
|
||||
|
||||
class CommentsDataSourceFactory(
|
||||
private val commentsRepository: CommentsRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val videoId: String,
|
||||
private var sortOrder: String
|
||||
) : DataSource.Factory<String, Comment.Item>() {
|
||||
|
||||
private var commentsDataSource: CommentsDataSource? = null
|
||||
val commentsDataSourceLiveData = MutableLiveData<CommentsDataSource>()
|
||||
|
||||
override fun create(): DataSource<String, Comment.Item> {
|
||||
// Also called every time when invalidate() is executed
|
||||
commentsDataSource =
|
||||
CommentsDataSource(
|
||||
commentsRepository,
|
||||
coroutineScope,
|
||||
videoId,
|
||||
sortOrder
|
||||
)
|
||||
commentsDataSourceLiveData.postValue(commentsDataSource)
|
||||
|
||||
return commentsDataSource!!
|
||||
}
|
||||
|
||||
fun setSortOrder(updatedSortOrder: String) {
|
||||
sortOrder = updatedSortOrder
|
||||
}
|
||||
|
||||
fun getSource() = commentsDataSourceLiveData.value
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package aculix.channelify.app.paging.datasourcefactory
|
||||
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.datasource.HomeDataSource
|
||||
import aculix.channelify.app.repository.HomeRepository
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class HomeDataSourceFactory(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val playlistId: String
|
||||
) : DataSource.Factory<String, PlaylistItemInfo.Item>() {
|
||||
|
||||
private var homeDataSource: HomeDataSource? = null
|
||||
val homeDataSourceLiveData = MutableLiveData<HomeDataSource>()
|
||||
|
||||
override fun create(): DataSource<String, PlaylistItemInfo.Item> {
|
||||
if (homeDataSource == null) {
|
||||
homeDataSource =
|
||||
HomeDataSource(
|
||||
homeRepository,
|
||||
coroutineScope,
|
||||
playlistId
|
||||
)
|
||||
homeDataSourceLiveData.postValue(homeDataSource)
|
||||
}
|
||||
return homeDataSource!!
|
||||
}
|
||||
|
||||
fun getSource() = homeDataSourceLiveData.value
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package aculix.channelify.app.paging.datasourcefactory
|
||||
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.datasource.PlaylistVideosDataSource
|
||||
import aculix.channelify.app.repository.PlaylistVideosRepository
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class PlaylistVideosDataSourceFactory(
|
||||
private val playlistVideosRepository: PlaylistVideosRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val playlistId: String
|
||||
) : DataSource.Factory<String, PlaylistItemInfo.Item>() {
|
||||
|
||||
private var playlistVideosDataSource: PlaylistVideosDataSource? = null
|
||||
val playlistVideosDataSourceLiveData = MutableLiveData<PlaylistVideosDataSource>()
|
||||
|
||||
override fun create(): DataSource<String, PlaylistItemInfo.Item> {
|
||||
if (playlistVideosDataSource == null) {
|
||||
playlistVideosDataSource =
|
||||
PlaylistVideosDataSource(
|
||||
playlistVideosRepository,
|
||||
coroutineScope,
|
||||
playlistId
|
||||
)
|
||||
playlistVideosDataSourceLiveData.postValue(playlistVideosDataSource)
|
||||
}
|
||||
return playlistVideosDataSource!!
|
||||
}
|
||||
|
||||
fun getSource() = playlistVideosDataSourceLiveData.value
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package aculix.channelify.app.paging.datasourcefactory
|
||||
|
||||
import aculix.channelify.app.model.Playlist
|
||||
import aculix.channelify.app.paging.datasource.PlaylistsDataSource
|
||||
import aculix.channelify.app.repository.PlaylistsRepository
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class PlaylistsDataSourceFactory(
|
||||
private val playlistRepository: PlaylistsRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val channelId: String
|
||||
) : DataSource.Factory<String, Playlist.Item>() {
|
||||
|
||||
private var playlistsDataSource: PlaylistsDataSource? = null
|
||||
val playlistsDataSourceLiveData = MutableLiveData<PlaylistsDataSource>()
|
||||
|
||||
override fun create(): DataSource<String, Playlist.Item> {
|
||||
if (playlistsDataSource == null) {
|
||||
playlistsDataSource =
|
||||
PlaylistsDataSource(
|
||||
playlistRepository,
|
||||
coroutineScope,
|
||||
channelId
|
||||
)
|
||||
playlistsDataSourceLiveData.postValue(playlistsDataSource)
|
||||
}
|
||||
return playlistsDataSource!!
|
||||
}
|
||||
|
||||
fun getSource() = playlistsDataSourceLiveData.value
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package aculix.channelify.app.paging.datasourcefactory
|
||||
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.paging.datasource.SearchDataSource
|
||||
import aculix.channelify.app.repository.SearchRepository
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import timber.log.Timber
|
||||
|
||||
class SearchDataSourceFactory(
|
||||
private val searchRepository: SearchRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val channelId: String,
|
||||
private var searchQuery: String,
|
||||
private val emptySearchResultText: String
|
||||
) : DataSource.Factory<String, SearchedVideo.Item>() {
|
||||
|
||||
private var searchDataSource: SearchDataSource? = null
|
||||
val searchDataSourceLiveData = MutableLiveData<SearchDataSource>()
|
||||
|
||||
override fun create(): DataSource<String, SearchedVideo.Item> {
|
||||
// Also called every time when invalidate() is executed
|
||||
searchDataSource =
|
||||
SearchDataSource(
|
||||
searchRepository,
|
||||
coroutineScope,
|
||||
channelId,
|
||||
searchQuery,
|
||||
emptySearchResultText
|
||||
)
|
||||
searchDataSourceLiveData.postValue(searchDataSource)
|
||||
|
||||
return searchDataSource!!
|
||||
}
|
||||
|
||||
fun setSearchQuery(updatedSearchQuery: String) {
|
||||
searchQuery = updatedSearchQuery
|
||||
}
|
||||
|
||||
fun getSource() = searchDataSourceLiveData.value
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.ChannelInfoService
|
||||
|
||||
class AboutRepository(private val channelInfoService: ChannelInfoService) {
|
||||
|
||||
suspend fun getChannelInfo(channelId: String) =
|
||||
channelInfoService.getChannelInfo(channelId)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.CommentRepliesService
|
||||
import aculix.channelify.app.api.CommentService
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import retrofit2.Response
|
||||
|
||||
class CommentRepliesRepository(private val commentRepliesService: CommentRepliesService) {
|
||||
|
||||
suspend fun getCommentReplies(commentId: String, pageToken: String?): Response<CommentReply> =
|
||||
commentRepliesService.getCommentReplies(commentId, pageToken)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.CommentService
|
||||
import aculix.channelify.app.model.Comment
|
||||
import retrofit2.Response
|
||||
|
||||
class CommentsRepository(private val commentService: CommentService) {
|
||||
|
||||
suspend fun getVideoComments(videoId: String, pageToken: String?, sortOrder: String): Response<Comment> =
|
||||
commentService.getVideoComments(videoId, pageToken, sortOrder)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.db.FavoriteVideoDao
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import androidx.lifecycle.LiveData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class FavoritesRepository(private val favoriteVideoDao: FavoriteVideoDao) {
|
||||
|
||||
suspend fun getFavoriteVideosFromDb(): List<FavoriteVideo> = withContext(Dispatchers.IO) {
|
||||
favoriteVideoDao.getAllFavoriteVideos()
|
||||
}
|
||||
|
||||
suspend fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
|
||||
withContext(Dispatchers.IO) {
|
||||
favoriteVideoDao.removeFavoriteVideo(favoriteVideo)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
|
||||
withContext(Dispatchers.IO) {
|
||||
favoriteVideoDao.addFavoriteVideo(favoriteVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.ChannelsService
|
||||
import aculix.channelify.app.api.PlaylistItemsService
|
||||
import aculix.channelify.app.api.SearchVideoService
|
||||
import aculix.channelify.app.model.ChannelUploadsPlaylistInfo
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import retrofit2.Response
|
||||
|
||||
class HomeRepository(private val searchVideoService: SearchVideoService,
|
||||
private val channelsService: ChannelsService,
|
||||
private val playlistItemsService: PlaylistItemsService) {
|
||||
|
||||
suspend fun getUploadsPlaylistId(channelId: String): Response<ChannelUploadsPlaylistInfo> =
|
||||
channelsService.getChannelUploadsPlaylistInfo(channelId)
|
||||
|
||||
suspend fun getLatestVideos(playlistId: String, pageToken: String?) =
|
||||
playlistItemsService.getPlaylistVideos(playlistId, pageToken)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.PlaylistItemsService
|
||||
|
||||
class PlaylistVideosRepository(private val playlistItemsService: PlaylistItemsService) {
|
||||
|
||||
suspend fun getPlaylistVideos(playlistId: String, pageToken: String?) =
|
||||
playlistItemsService.getPlaylistVideos(playlistId, pageToken)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.PlaylistsService
|
||||
|
||||
class PlaylistsRepository(private val playlistsService: PlaylistsService) {
|
||||
|
||||
suspend fun getPlaylists(channelId: String, pageToken: String?) =
|
||||
playlistsService.getPlaylists(channelId, pageToken)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.SearchVideoService
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SearchRepository(private val searchVideoService: SearchVideoService) {
|
||||
|
||||
suspend fun searchVideos(searchQuery: String, channelId: String, pageToken: String?) = withContext(Dispatchers.IO) {
|
||||
val defaultQueryMap = HashMap<String, String>()
|
||||
defaultQueryMap.apply {
|
||||
put("part", "id,snippet")
|
||||
put("fields", "nextPageToken, items(id(videoId), snippet(publishedAt, thumbnails, title))")
|
||||
put("order", "relevance")
|
||||
put("type", "video")
|
||||
}
|
||||
|
||||
searchVideoService.searchVideos(searchQuery, channelId, pageToken, defaultQueryMap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.VideosService
|
||||
import aculix.channelify.app.db.FavoriteVideoDao
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import aculix.channelify.app.model.Video
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.Response
|
||||
|
||||
class VideoDetailsRepository(
|
||||
private val videosService: VideosService,
|
||||
private val favoriteVideoDao: FavoriteVideoDao
|
||||
) {
|
||||
|
||||
suspend fun getVideoInfo(videoId: String): Response<Video> =
|
||||
videosService.getVideoInfo(videoId)
|
||||
|
||||
suspend fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
|
||||
withContext(Dispatchers.IO) {
|
||||
favoriteVideoDao.addFavoriteVideo(favoriteVideo)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
|
||||
withContext(Dispatchers.IO) {
|
||||
favoriteVideoDao.removeFavoriteVideo(favoriteVideo)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isVideoAddedToFavorites(videoId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
favoriteVideoDao.getFavoriteVideoId(videoId) != null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package aculix.channelify.app.repository
|
||||
|
||||
import aculix.channelify.app.api.VideosService
|
||||
import aculix.channelify.app.model.Video
|
||||
import retrofit2.Response
|
||||
|
||||
class VideoPlayerRepository() {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package aculix.channelify.app.sharedpref
|
||||
|
||||
import com.chibatching.kotpref.KotprefModel
|
||||
|
||||
object AppPref : KotprefModel() {
|
||||
var isLightThemeEnabled by booleanPref(true)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package aculix.channelify.app.utils
|
||||
|
||||
import android.util.DisplayMetrics
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.gms.ads.AdSize
|
||||
|
||||
/**
|
||||
* Returns the size of the Adaptive Banner Ad based on the screen width
|
||||
*/
|
||||
fun AppCompatActivity.getAdaptiveBannerAdSize(adViewContainer: FrameLayout): AdSize {
|
||||
val display = windowManager.defaultDisplay
|
||||
val outMetrics = DisplayMetrics()
|
||||
display.getMetrics(outMetrics)
|
||||
|
||||
val density = outMetrics.density
|
||||
|
||||
var adWidthPixels = adViewContainer.width.toFloat()
|
||||
if (adWidthPixels == 0f) {
|
||||
adWidthPixels = outMetrics.widthPixels.toFloat()
|
||||
}
|
||||
|
||||
val adWidth = (adWidthPixels / density).toInt()
|
||||
return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(this, adWidth)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package aculix.channelify.app.utils
|
||||
|
||||
object Constants {
|
||||
const val INITIAL_PAGE_LOAD_SIZE = 10
|
||||
const val PAGE_SIZE = 10
|
||||
|
||||
const val YT_API_MAX_RESULTS = 10
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package aculix.channelify.app.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.github.marlonlom.utilities.timeago.TimeAgo
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object DateTimeUtils {
|
||||
|
||||
/**
|
||||
* Returns time in ago format
|
||||
* Eg. 14 hours ago
|
||||
* Eg. 2 days ago
|
||||
*/
|
||||
fun getTimeAgo(timeInIso8601: String): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss")
|
||||
sdf.timeZone = TimeZone.getTimeZone("GMT")
|
||||
val timeInMillis = sdf.parse(timeInIso8601).time
|
||||
return TimeAgo.using(timeInMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns data in format MMM dd, yyyy
|
||||
* Eg. Dec 02, 2019
|
||||
*/
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
fun getPublishedDate(timeInIso8601: String): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss")
|
||||
sdf.timeZone = TimeZone.getTimeZone("GMT")
|
||||
val date = sdf.parse(timeInIso8601)
|
||||
|
||||
val publishedDateSdf = SimpleDateFormat("MMM dd, yyyy ")
|
||||
return publishedDateSdf.format(date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package aculix.channelify.app.utils
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
|
||||
class DividerItemDecorator(private val mDivider: Drawable) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val dividerLeft = parent.paddingLeft
|
||||
val dividerRight = parent.width - parent.paddingRight
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0..childCount - 2) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params =
|
||||
child.layoutParams as RecyclerView.LayoutParams
|
||||
val dividerTop = child.bottom + params.bottomMargin
|
||||
val dividerBottom = dividerTop + mDivider.intrinsicHeight
|
||||
mDivider.setBounds(dividerLeft, dividerTop, dividerRight, dividerBottom)
|
||||
mDivider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package aculix.channelify.app.utils
|
||||
|
||||
import android.util.DisplayMetrics
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.google.android.gms.ads.AdSize
|
||||
|
||||
/**
|
||||
* Returns the size of the Adaptive Banner Ad based on the screen width
|
||||
*/
|
||||
fun FragmentActivity.getAdaptiveBannerAdSize(adViewContainer: FrameLayout): AdSize {
|
||||
val display = windowManager.defaultDisplay
|
||||
val outMetrics = DisplayMetrics()
|
||||
display.getMetrics(outMetrics)
|
||||
|
||||
val density = outMetrics.density
|
||||
|
||||
var adWidthPixels = adViewContainer.width.toFloat()
|
||||
if (adWidthPixels == 0f) {
|
||||
adWidthPixels = outMetrics.widthPixels.toFloat()
|
||||
}
|
||||
|
||||
val adWidth = (adWidthPixels / density).toInt()
|
||||
return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(this, adWidth)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package aculix.channelify.app.utils
|
||||
|
||||
import android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
import android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
import android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
import android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
import android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
import android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
|
||||
|
||||
/**
|
||||
* Class responsible for changing the view from full screen to non-full screen and vice versa.
|
||||
*/
|
||||
class FullScreenHelper(private val context: Activity, vararg views: View) {
|
||||
|
||||
var views: Array<View> = arrayOf(*views)
|
||||
|
||||
/**
|
||||
* call this method to enter full screen
|
||||
*/
|
||||
fun enterFullScreen() {
|
||||
val decorView = context.window.decorView
|
||||
|
||||
hideSystemUi(decorView)
|
||||
|
||||
for (view in views) {
|
||||
view.visibility = View.GONE
|
||||
view.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* call this method to exit full screen
|
||||
*/
|
||||
fun exitFullScreen() {
|
||||
val decorView = context.window.decorView
|
||||
|
||||
showSystemUi(decorView)
|
||||
|
||||
for (view in views) {
|
||||
view.visibility = View.VISIBLE
|
||||
view.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideSystemUi(mDecorView: View) {
|
||||
mDecorView.systemUiVisibility = (SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
|
||||
}
|
||||
|
||||
private fun showSystemUi(mDecorView: View) {
|
||||
mDecorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.repository.AboutRepository
|
||||
import aculix.core.helper.ResultWrapper
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AboutViewModel(
|
||||
private val aboutRepository: AboutRepository,
|
||||
private val channelId: String,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _channelInfoLiveData = MutableLiveData<ResultWrapper>()
|
||||
val channelInfoLiveData: LiveData<ResultWrapper>
|
||||
get() = _channelInfoLiveData
|
||||
|
||||
fun getChannelInfo() {
|
||||
viewModelScope.launch {
|
||||
_channelInfoLiveData.value = ResultWrapper.Loading
|
||||
|
||||
val response = aboutRepository.getChannelInfo(channelId)
|
||||
if (response.isSuccessful) {
|
||||
_channelInfoLiveData.value = ResultWrapper.Success(response.body())
|
||||
} else {
|
||||
_channelInfoLiveData.value =
|
||||
ResultWrapper.Error(context.getString(R.string.error_channel_info))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.model.CommentReply
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.paging.datasourcefactory.CommentRepliesDataSourceFactory
|
||||
import aculix.channelify.app.repository.CommentRepliesRepository
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CommentRepliesViewModel(private val commentRepliesRepository: CommentRepliesRepository) :
|
||||
ViewModel() {
|
||||
|
||||
lateinit var commentRepliesDataSourceFactory: CommentRepliesDataSourceFactory
|
||||
var commentRepliesLiveData: LiveData<PagedList<CommentReply.Item>>? = null
|
||||
var networkStateLiveData: LiveData<NetworkState>? = null
|
||||
|
||||
fun getCommentReplies(commentId: String) {
|
||||
if (commentRepliesLiveData == null) {
|
||||
viewModelScope.launch {
|
||||
commentRepliesDataSourceFactory =
|
||||
CommentRepliesDataSourceFactory(
|
||||
commentRepliesRepository,
|
||||
viewModelScope,
|
||||
commentId
|
||||
)
|
||||
|
||||
commentRepliesLiveData =
|
||||
LivePagedListBuilder(commentRepliesDataSourceFactory, pagedListConfig()).build()
|
||||
networkStateLiveData =
|
||||
Transformations.switchMap(commentRepliesDataSourceFactory.commentRepliesDataSourceLiveData) { it.getNetworkState() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry possible last paged request (ie: network issue)
|
||||
*/
|
||||
fun refreshFailedRequest() =
|
||||
commentRepliesDataSourceFactory.getSource()?.retryFailedQuery()
|
||||
|
||||
private fun pagedListConfig() = PagedList.Config.Builder()
|
||||
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(Constants.PAGE_SIZE)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.model.Comment
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.paging.datasourcefactory.CommentsDataSourceFactory
|
||||
import aculix.channelify.app.repository.CommentsRepository
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.*
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class CommentsViewModel(
|
||||
private val commentsRepository: CommentsRepository,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
lateinit var commentsDataSourceFactory: CommentsDataSourceFactory
|
||||
var commentsLiveData: LiveData<PagedList<Comment.Item>>? = null
|
||||
var networkStateLiveData: LiveData<NetworkState>? = null
|
||||
private var _emptyStateLiveData = MutableLiveData<Boolean>()
|
||||
val emptyStateLiveData: LiveData<Boolean>
|
||||
get() = _emptyStateLiveData
|
||||
|
||||
|
||||
fun getVideoComments(videoId: String, sortOrder: String) {
|
||||
if (commentsLiveData == null) {
|
||||
viewModelScope.launch {
|
||||
commentsDataSourceFactory =
|
||||
CommentsDataSourceFactory(
|
||||
commentsRepository,
|
||||
viewModelScope,
|
||||
videoId,
|
||||
sortOrder
|
||||
)
|
||||
|
||||
commentsLiveData = LivePagedListBuilder(commentsDataSourceFactory, pagedListConfig())
|
||||
.setBoundaryCallback(object :
|
||||
PagedList.BoundaryCallback<Comment.Item>() {
|
||||
override fun onZeroItemsLoaded() {
|
||||
super.onZeroItemsLoaded()
|
||||
_emptyStateLiveData.value = true
|
||||
}
|
||||
|
||||
override fun onItemAtFrontLoaded(itemAtFront: Comment.Item) {
|
||||
super.onItemAtFrontLoaded(itemAtFront)
|
||||
_emptyStateLiveData.value = false
|
||||
}
|
||||
|
||||
override fun onItemAtEndLoaded(itemAtEnd: Comment.Item) {
|
||||
super.onItemAtEndLoaded(itemAtEnd)
|
||||
_emptyStateLiveData.value = false
|
||||
}
|
||||
})
|
||||
.build()
|
||||
networkStateLiveData = Transformations.switchMap(commentsDataSourceFactory.commentsDataSourceLiveData) { it.getNetworkState() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun sortComments(updatedSortOrder: String) {
|
||||
commentsDataSourceFactory.setSortOrder(updatedSortOrder)
|
||||
commentsDataSourceFactory.commentsDataSourceLiveData.value?.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry possible last paged request (ie: network issue)
|
||||
*/
|
||||
fun refreshFailedRequest() =
|
||||
commentsDataSourceFactory.getSource()?.retryFailedQuery()
|
||||
|
||||
private fun pagedListConfig() = PagedList.Config.Builder()
|
||||
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(Constants.PAGE_SIZE)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import aculix.channelify.app.repository.FavoritesRepository
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FavoritesViewModel(private val favoritesRepository: FavoritesRepository) : ViewModel() {
|
||||
|
||||
var favoriteVideosLiveData: LiveData<List<FavoriteVideo>> = liveData(Dispatchers.IO) {
|
||||
emit(favoritesRepository.getFavoriteVideosFromDb())
|
||||
}
|
||||
|
||||
fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
|
||||
viewModelScope.launch {
|
||||
favoritesRepository.removeVideoFromFavorites(favoriteVideo)
|
||||
}
|
||||
}
|
||||
|
||||
fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
|
||||
viewModelScope.launch {
|
||||
favoritesRepository.addVideoToFavorites(favoriteVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.datasourcefactory.HomeDataSourceFactory
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.repository.HomeRepository
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import aculix.core.helper.ResultWrapper
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.*
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class HomeViewModel(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val channelId: String,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uploadsPlaylistIdLiveData = MutableLiveData<ResultWrapper>()
|
||||
val uploadsPlaylistIdLiveData: LiveData<ResultWrapper>
|
||||
get() = _uploadsPlaylistIdLiveData
|
||||
|
||||
lateinit var homeDataSourceFactory: HomeDataSourceFactory
|
||||
var latestVideoLiveData: LiveData<PagedList<PlaylistItemInfo.Item>>? = null
|
||||
var networkStateLiveData: LiveData<NetworkState>? = null
|
||||
|
||||
fun getLatestVideos() {
|
||||
if (latestVideoLiveData == null) {
|
||||
viewModelScope.launch {
|
||||
_uploadsPlaylistIdLiveData.value = ResultWrapper.Loading
|
||||
|
||||
val uploadsPlaylistIdRequest = async(Dispatchers.IO) { homeRepository.getUploadsPlaylistId(channelId) }
|
||||
val response = uploadsPlaylistIdRequest.await()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val playlistId = response.body()!!.items[0].contentDetails.relatedPlaylists.uploads
|
||||
homeDataSourceFactory =
|
||||
HomeDataSourceFactory(
|
||||
homeRepository,
|
||||
viewModelScope,
|
||||
playlistId
|
||||
)
|
||||
|
||||
latestVideoLiveData = LivePagedListBuilder(homeDataSourceFactory, pagedListConfig()).build()
|
||||
networkStateLiveData = Transformations.switchMap(homeDataSourceFactory.homeDataSourceLiveData) { it.getNetworkState() }
|
||||
_uploadsPlaylistIdLiveData.value = ResultWrapper.Success("")
|
||||
} else {
|
||||
Timber.e("Error: ${response.raw()}")
|
||||
_uploadsPlaylistIdLiveData.value = ResultWrapper.Error(context.getString(R.string.error_fetch_videos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry possible last paged request (ie: network issue)
|
||||
*/
|
||||
fun refreshFailedRequest() =
|
||||
homeDataSourceFactory.getSource()?.retryFailedQuery()
|
||||
|
||||
private fun pagedListConfig() = PagedList.Config.Builder()
|
||||
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(Constants.PAGE_SIZE)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.model.PlaylistItemInfo
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.paging.datasourcefactory.PlaylistVideosDataSourceFactory
|
||||
import aculix.channelify.app.repository.PlaylistVideosRepository
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlaylistVideosViewModel(
|
||||
private val playlistVideosRepository: PlaylistVideosRepository
|
||||
) : ViewModel() {
|
||||
|
||||
lateinit var playlistVideosDataSourceFactory: PlaylistVideosDataSourceFactory
|
||||
var playlistVideosLiveData: LiveData<PagedList<PlaylistItemInfo.Item>>? = null
|
||||
var networkStateLiveData: LiveData<NetworkState>? = null
|
||||
|
||||
fun getPlaylistVideos(playlistId: String) {
|
||||
if (playlistVideosLiveData == null) {
|
||||
viewModelScope.launch {
|
||||
playlistVideosDataSourceFactory =
|
||||
PlaylistVideosDataSourceFactory(
|
||||
playlistVideosRepository,
|
||||
viewModelScope,
|
||||
playlistId
|
||||
)
|
||||
|
||||
playlistVideosLiveData =
|
||||
LivePagedListBuilder(playlistVideosDataSourceFactory, pagedListConfig()).build()
|
||||
networkStateLiveData =
|
||||
Transformations.switchMap(playlistVideosDataSourceFactory.playlistVideosDataSourceLiveData) { it.getNetworkState() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry possible last paged request (ie: network issue)
|
||||
*/
|
||||
fun refreshFailedRequest() =
|
||||
playlistVideosDataSourceFactory.getSource()?.retryFailedQuery()
|
||||
|
||||
private fun pagedListConfig() = PagedList.Config.Builder()
|
||||
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(Constants.PAGE_SIZE)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.model.Playlist
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.paging.datasourcefactory.PlaylistsDataSourceFactory
|
||||
import aculix.channelify.app.repository.PlaylistsRepository
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.*
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlaylistsViewModel(
|
||||
private val playlistsRepository: PlaylistsRepository,
|
||||
private val channelId: String
|
||||
) : ViewModel() {
|
||||
|
||||
lateinit var playlistsDataSourceFactory: PlaylistsDataSourceFactory
|
||||
var playlistsLiveData: LiveData<PagedList<Playlist.Item>>? = null
|
||||
var networkStateLiveData: LiveData<NetworkState>? = null
|
||||
private var _emptyStateLiveData = MutableLiveData<Boolean>()
|
||||
val emptyStateLiveData: LiveData<Boolean>
|
||||
get() = _emptyStateLiveData
|
||||
|
||||
fun getPlaylists() {
|
||||
if (playlistsLiveData == null) {
|
||||
viewModelScope.launch {
|
||||
playlistsDataSourceFactory =
|
||||
PlaylistsDataSourceFactory(
|
||||
playlistsRepository,
|
||||
viewModelScope,
|
||||
channelId
|
||||
)
|
||||
|
||||
playlistsLiveData = LivePagedListBuilder(playlistsDataSourceFactory, pagedListConfig())
|
||||
.setBoundaryCallback(object :
|
||||
PagedList.BoundaryCallback<Playlist.Item>() {
|
||||
override fun onZeroItemsLoaded() {
|
||||
super.onZeroItemsLoaded()
|
||||
_emptyStateLiveData.value = true
|
||||
}
|
||||
|
||||
override fun onItemAtFrontLoaded(itemAtFront: Playlist.Item) {
|
||||
super.onItemAtFrontLoaded(itemAtFront)
|
||||
_emptyStateLiveData.value = false
|
||||
}
|
||||
|
||||
override fun onItemAtEndLoaded(itemAtEnd: Playlist.Item) {
|
||||
super.onItemAtEndLoaded(itemAtEnd)
|
||||
_emptyStateLiveData.value = false
|
||||
}
|
||||
})
|
||||
.build()
|
||||
networkStateLiveData = Transformations.switchMap(playlistsDataSourceFactory.playlistsDataSourceLiveData) { it.getNetworkState() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry possible last paged request (ie: network issue)
|
||||
*/
|
||||
fun refreshFailedRequest() =
|
||||
playlistsDataSourceFactory.getSource()?.retryFailedQuery()
|
||||
|
||||
private fun pagedListConfig() = PagedList.Config.Builder()
|
||||
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(Constants.PAGE_SIZE)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.model.SearchedVideo
|
||||
import aculix.channelify.app.paging.NetworkState
|
||||
import aculix.channelify.app.paging.datasourcefactory.SearchDataSourceFactory
|
||||
import aculix.channelify.app.repository.SearchRepository
|
||||
import aculix.channelify.app.utils.Constants
|
||||
import androidx.lifecycle.*
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class SearchViewModel(
|
||||
private val searchRepository: SearchRepository,
|
||||
private val channelId: String,
|
||||
private val emptySearchResultText: String
|
||||
) : ViewModel() {
|
||||
|
||||
lateinit var searchDataSourceFactory: SearchDataSourceFactory
|
||||
var searchResultLiveData: LiveData<PagedList<SearchedVideo.Item>>? = null
|
||||
var networkStateLiveData: LiveData<NetworkState>? = null
|
||||
private var _emptyStateLiveData = MutableLiveData<Boolean>()
|
||||
val emptyStateLiveData: LiveData<Boolean>
|
||||
get() = _emptyStateLiveData
|
||||
|
||||
fun searchVideos(searchQuery: String) {
|
||||
if (searchResultLiveData == null) {
|
||||
viewModelScope.launch {
|
||||
searchDataSourceFactory =
|
||||
SearchDataSourceFactory(
|
||||
searchRepository,
|
||||
viewModelScope,
|
||||
channelId,
|
||||
searchQuery,
|
||||
emptySearchResultText
|
||||
)
|
||||
|
||||
searchResultLiveData =
|
||||
LivePagedListBuilder(searchDataSourceFactory, pagedListConfig())
|
||||
.setBoundaryCallback(object :
|
||||
PagedList.BoundaryCallback<SearchedVideo.Item>() {
|
||||
override fun onZeroItemsLoaded() {
|
||||
super.onZeroItemsLoaded()
|
||||
_emptyStateLiveData.value = true
|
||||
}
|
||||
|
||||
override fun onItemAtFrontLoaded(itemAtFront: SearchedVideo.Item) {
|
||||
super.onItemAtFrontLoaded(itemAtFront)
|
||||
_emptyStateLiveData.value = false
|
||||
}
|
||||
|
||||
override fun onItemAtEndLoaded(itemAtEnd: SearchedVideo.Item) {
|
||||
super.onItemAtEndLoaded(itemAtEnd)
|
||||
_emptyStateLiveData.value = false
|
||||
}
|
||||
}).build()
|
||||
networkStateLiveData =
|
||||
Transformations.switchMap(searchDataSourceFactory.searchDataSourceLiveData) { it.getNetworkState() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(updatedSearchQuery: String) {
|
||||
searchDataSourceFactory.setSearchQuery(updatedSearchQuery)
|
||||
searchDataSourceFactory.searchDataSourceLiveData.value?.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry possible last paged request (ie: network issue)
|
||||
*/
|
||||
fun refreshFailedRequest() =
|
||||
searchDataSourceFactory.getSource()?.retryFailedQuery()
|
||||
|
||||
private fun pagedListConfig() = PagedList.Config.Builder()
|
||||
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(Constants.PAGE_SIZE)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import aculix.channelify.app.R
|
||||
import aculix.channelify.app.model.FavoriteVideo
|
||||
import aculix.channelify.app.repository.VideoDetailsRepository
|
||||
import aculix.core.helper.ResultWrapper
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class VideoDetailsViewModel(
|
||||
private val videoDetailsRepository: VideoDetailsRepository,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _videoInfoLiveData = MutableLiveData<ResultWrapper>()
|
||||
val videoInfoLiveData: LiveData<ResultWrapper>
|
||||
get() = _videoInfoLiveData
|
||||
|
||||
private val _favoriteVideoLiveData = MutableLiveData<Boolean>()
|
||||
val favoriteVideoLiveData: LiveData<Boolean>
|
||||
get() = _favoriteVideoLiveData
|
||||
|
||||
fun getVideoInfo(videoId: String) {
|
||||
viewModelScope.launch {
|
||||
_videoInfoLiveData.value = ResultWrapper.Loading
|
||||
|
||||
val response = videoDetailsRepository.getVideoInfo(videoId)
|
||||
if (response.isSuccessful) {
|
||||
_videoInfoLiveData.value = ResultWrapper.Success(response.body())
|
||||
} else {
|
||||
_videoInfoLiveData.value =
|
||||
ResultWrapper.Error(context.getString(R.string.error_video_details))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
|
||||
viewModelScope.launch {
|
||||
videoDetailsRepository.addVideoToFavorites(favoriteVideo)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
|
||||
viewModelScope.launch {
|
||||
videoDetailsRepository.removeVideoFromFavorites(favoriteVideo)
|
||||
}
|
||||
}
|
||||
|
||||
fun getVideoFavoriteStatus(videoId: String) {
|
||||
viewModelScope.launch {
|
||||
_favoriteVideoLiveData.value = videoDetailsRepository.isVideoAddedToFavorites(videoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package aculix.channelify.app.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class VideoPlayerViewModel() : ViewModel() {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:duration="@android:integer/config_mediumAnimTime"
|
||||
android:fromYDelta="100%"
|
||||
android:toYDelta="0%" >
|
||||
</translate>
|
||||
</set>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:duration="@android:integer/config_mediumAnimTime"
|
||||
android:fromYDelta="0%"
|
||||
android:toYDelta="100%" >
|
||||
</translate>
|
||||
</set>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 769 B |
Binary file not shown.
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,127 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="880dp"
|
||||
android:height="708.57dp"
|
||||
android:viewportWidth="880"
|
||||
android:viewportHeight="708.57">
|
||||
<path
|
||||
android:pathData="M18.52,0L861.48,0A18.52,18.52 0,0 1,880 18.52L880,108.34A18.52,18.52 0,0 1,861.48 126.86L18.52,126.86A18.52,18.52 0,0 1,0 108.34L0,18.52A18.52,18.52 0,0 1,18.52 0z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startY="126.86"
|
||||
android:startX="440"
|
||||
android:endY="0"
|
||||
android:endX="440"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#3F808080"/>
|
||||
<item android:offset="0.54" android:color="#1E808080"/>
|
||||
<item android:offset="1" android:color="#19808080"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M49.67,20.06L124.93,20.06A8.35,8.35 0,0 1,133.28 28.41L133.28,101.03A8.35,8.35 0,0 1,124.93 109.38L49.67,109.38A8.35,8.35 0,0 1,41.32 101.03L41.32,28.41A8.35,8.35 0,0 1,49.67 20.06z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startY="109.38"
|
||||
android:startX="87.3"
|
||||
android:endY="20.06"
|
||||
android:endX="87.3"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#3FB3B3B3"/>
|
||||
<item android:offset="0.54" android:color="#19B3B3B3"/>
|
||||
<item android:offset="1" android:color="#0CB3B3B3"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,30.42h176.93v11.65h-176.93z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,57.6h250.26v11.65h-250.26z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M18.52,194.29L861.48,194.29A18.52,18.52 0,0 1,880 212.81L880,302.63A18.52,18.52 0,0 1,861.48 321.15L18.52,321.15A18.52,18.52 0,0 1,0 302.63L0,212.81A18.52,18.52 0,0 1,18.52 194.29z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M49.67,214.35L124.93,214.35A8.35,8.35 0,0 1,133.28 222.7L133.28,295.32A8.35,8.35 0,0 1,124.93 303.67L49.67,303.67A8.35,8.35 0,0 1,41.32 295.32L41.32,222.7A8.35,8.35 0,0 1,49.67 214.35z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,224.71h176.93v11.65h-176.93z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,251.89h250.26v11.65h-250.26z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M18.52,387.43L861.48,387.43A18.52,18.52 0,0 1,880 405.95L880,495.77A18.52,18.52 0,0 1,861.48 514.29L18.52,514.29A18.52,18.52 0,0 1,0 495.77L0,405.95A18.52,18.52 0,0 1,18.52 387.43z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M49.67,407.49L124.93,407.49A8.35,8.35 0,0 1,133.28 415.84L133.28,488.46A8.35,8.35 0,0 1,124.93 496.81L49.67,496.81A8.35,8.35 0,0 1,41.32 488.46L41.32,415.84A8.35,8.35 0,0 1,49.67 407.49z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,417.85h176.93v11.65h-176.93z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,445.03h250.26v11.65h-250.26z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M18.52,581.71L861.48,581.71A18.52,18.52 0,0 1,880 600.23L880,690.05A18.52,18.52 0,0 1,861.48 708.57L18.52,708.57A18.52,18.52 0,0 1,0 690.05L0,600.23A18.52,18.52 0,0 1,18.52 581.71z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M49.67,601.78L124.93,601.78A8.35,8.35 0,0 1,133.28 610.13L133.28,682.75A8.35,8.35 0,0 1,124.93 691.1L49.67,691.1A8.35,8.35 0,0 1,41.32 682.75L41.32,610.13A8.35,8.35 0,0 1,49.67 601.78z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,612.13h176.93v11.65h-176.93z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M167.04,639.32h250.26v11.65h-250.26z">
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M26.52,4.57L853.48,4.57A18.52,18.52 0,0 1,872 23.09L872,98.05A18.52,18.52 0,0 1,853.48 116.57L26.52,116.57A18.52,18.52 0,0 1,8 98.05L8,23.09A18.52,18.52 0,0 1,26.52 4.57z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M56.92,22.29L130.51,22.29A8.35,8.35 0,0 1,138.86 30.64L138.86,92.8A8.35,8.35 0,0 1,130.51 101.15L56.92,101.15A8.35,8.35 0,0 1,48.57 92.8L48.57,30.64A8.35,8.35 0,0 1,56.92 22.29z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M172,31.43h173.71v10.29h-173.71z"
|
||||
android:fillColor="#bdbdbd"/>
|
||||
<path
|
||||
android:pathData="M172,55.43h245.71v10.29h-245.71z"
|
||||
android:fillColor="#e0e0e0"/>
|
||||
<path
|
||||
android:pathData="M26.52,198.86L853.48,198.86A18.52,18.52 0,0 1,872 217.38L872,292.34A18.52,18.52 0,0 1,853.48 310.86L26.52,310.86A18.52,18.52 0,0 1,8 292.34L8,217.38A18.52,18.52 0,0 1,26.52 198.86z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M56.92,216.57L130.51,216.57A8.35,8.35 0,0 1,138.86 224.92L138.86,287.08A8.35,8.35 0,0 1,130.51 295.43L56.92,295.43A8.35,8.35 0,0 1,48.57 287.08L48.57,224.92A8.35,8.35 0,0 1,56.92 216.57z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M172,225.71h173.71v10.29h-173.71z"
|
||||
android:fillColor="#bdbdbd"/>
|
||||
<path
|
||||
android:pathData="M172,249.71h245.71v10.29h-245.71z"
|
||||
android:fillColor="#64ffda"/>
|
||||
<path
|
||||
android:pathData="M26.52,393.14L853.48,393.14A18.52,18.52 0,0 1,872 411.66L872,486.62A18.52,18.52 0,0 1,853.48 505.14L26.52,505.14A18.52,18.52 0,0 1,8 486.62L8,411.66A18.52,18.52 0,0 1,26.52 393.14z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M56.92,410.86L130.51,410.86A8.35,8.35 0,0 1,138.86 419.21L138.86,481.37A8.35,8.35 0,0 1,130.51 489.72L56.92,489.72A8.35,8.35 0,0 1,48.57 481.37L48.57,419.21A8.35,8.35 0,0 1,56.92 410.86z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M172,420h173.71v10.29h-173.71z"
|
||||
android:fillColor="#bdbdbd"/>
|
||||
<path
|
||||
android:pathData="M172,444h245.71v10.29h-245.71z"
|
||||
android:fillColor="#e0e0e0"/>
|
||||
<path
|
||||
android:pathData="M26.52,587.43L853.48,587.43A18.52,18.52 0,0 1,872 605.95L872,680.91A18.52,18.52 0,0 1,853.48 699.43L26.52,699.43A18.52,18.52 0,0 1,8 680.91L8,605.95A18.52,18.52 0,0 1,26.52 587.43z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M56.92,605.14L130.51,605.14A8.35,8.35 0,0 1,138.86 613.49L138.86,675.65A8.35,8.35 0,0 1,130.51 684L56.92,684A8.35,8.35 0,0 1,48.57 675.65L48.57,613.49A8.35,8.35 0,0 1,56.92 605.14z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M172,614.29h173.71v10.29h-173.71z"
|
||||
android:fillColor="#bdbdbd"/>
|
||||
<path
|
||||
android:pathData="M172,638.29h245.71v10.29h-245.71z"
|
||||
android:fillColor="#64ffda"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,116 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="1108.9467dp"
|
||||
android:height="696dp"
|
||||
android:viewportWidth="1108.9467"
|
||||
android:viewportHeight="696">
|
||||
<path
|
||||
android:pathData="M900.34,26c-14.65,-24.76 -43.64,-25.91 -43.64,-25.91s-28.25,-3.61 -46.37,34.09c-16.89,35.14 -40.2,69.08 -3.75,77.3l6.58,-20.49 4.08,22.01a142.6,142.6 0,0 0,15.59 0.27c39.03,-1.26 76.2,0.37 75,-13.64C906.25,81.02 914.44,49.82 900.34,26Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M601.56,650a224,13 0,1 0,448 0a224,13 0,1 0,-448 0z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M6.56,653a284,13 0,1 0,568 0a284,13 0,1 0,-568 0z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M380.56,683a253,13 0,1 0,506 0a253,13 0,1 0,-506 0z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M738.82,597.13c0.92,31.56 -17.52,43.12 -40.66,43.8q-0.81,0.02 -1.61,0.03 -1.61,0.01 -3.19,-0.05c-20.93,-0.87 -37.49,-11.98 -38.35,-41.33 -0.89,-30.37 36.81,-69.84 39.64,-72.76l0,-0c0.11,-0.11 0.16,-0.17 0.16,-0.17S737.9,565.57 738.82,597.13Z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M696.49,636.15l14.7,-21.86 -14.67,24.21 0.03,2.46q-1.61,0.01 -3.19,-0.05l0.73,-31.62 -0.02,-0.24 0.03,-0.05 0.07,-2.99L678.07,582.63l16.08,21.14 0.06,0.63 0.55,-23.89L680.86,556.28l13.94,20.04 -0.15,-49.49 0,-0.16 0,0.16 0.92,39.01 12.67,-15.84L695.63,569.19l0.28,21.37 11.66,-20.86 -11.62,24 0.15,11.88L713.06,576.53l-16.91,33.2Z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M83.82,608.13c0.92,31.56 -17.52,43.12 -40.66,43.8q-0.81,0.02 -1.61,0.03 -1.61,0.01 -3.19,-0.05c-20.93,-0.87 -37.49,-11.98 -38.35,-41.33 -0.89,-30.37 36.81,-69.84 39.64,-72.76l0,-0c0.11,-0.11 0.16,-0.17 0.16,-0.17S82.9,576.57 83.82,608.13Z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M41.49,647.15l14.7,-21.86L41.52,649.5l0.03,2.46q-1.61,0.01 -3.19,-0.05l0.73,-31.62 -0.02,-0.24 0.03,-0.05 0.07,-2.99L23.07,593.63l16.08,21.14 0.06,0.63 0.55,-23.89L25.86,567.28l13.94,20.04 -0.15,-49.49 0,-0.16 0,0.16 0.92,39.01 12.67,-15.84L40.63,580.19l0.28,21.37 11.66,-20.86L40.94,604.7l0.15,11.88L58.06,587.53 41.15,620.72Z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M1025.49,626.9l74.07,-110.15 -73.92,122 0.15,12.39q-8.11,0.06 -16.06,-0.27l3.67,-159.32 -0.1,-1.23 0.14,-0.24 0.35,-15.05L932.68,357.27l81.02,106.5 0.29,3.18 2.77,-120.37L946.7,224.48l70.26,100.95 -0.73,-249.38 0,-0.83 0.02,0.82 4.65,196.55 63.85,-79.84 -63.64,96.77 1.39,107.67 58.76,-105.08 -58.54,120.91 0.78,59.87 85.45,-146.38 -85.19,167.26Z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M813.53,260.47s-17.16,46 -6.18,87.19 39.82,139.36 39.82,139.36l38.44,76.2s21.97,15.1 35.01,-15.79l-35.01,-65.9L919.88,362.45a153.62,153.62 0,0 0,4.05 -68.6c-1.95,-11.31 -5.28,-22.62 -10.86,-31.32C913.07,262.53 823.82,248.8 813.53,260.47Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M849.22,478.78l-20.59,107.09s21.28,15.1 33.64,10.3l20.59,-78.95Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M831.38,583.81l-21.97,44.62 -8.92,17.85s-48.74,46.68 -10.3,41.88 41.19,-11.67 41.19,-11.67l7.55,-10.98 2.06,4.12s10.98,-8.92 10.98,-11.67 -4.12,-30.89 -4.12,-30.89l5.49,-32.95Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M893.85,562.53l11.67,31.58 -6.86,27.46s-26.77,36.38 0.69,34.33 32.27,-21.97 32.27,-21.97l4.12,-13.04 3.43,1.37 10.3,-18.54 -19.22,-30.89 -13.04,-22.65Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M966.62,298.91l4.12,21.28s17.16,38.44 0,39.82 -17.16,-37.76 -17.16,-37.76l1.37,-15.79Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M670.73,188.39l-24.03,-4.12s-47.37,-24.71 -46.68,-6.86 44.62,26.09 44.62,26.09l13.04,4.81Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M854.72,56.58m-31.58,0a31.58,31.58 0,1 1,63.16 0a31.58,31.58 0,1 1,-63.16 0"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M840.99,77.86s6.86,26.09 -5.49,31.58 -13.73,9.61 -13.73,9.61 82.38,26.09 88.56,-4.81l-28.15,-15.79s-2.75,-21.97 -5.49,-23.34S840.99,77.86 840.99,77.86Z"
|
||||
android:fillColor="#ffb8b8"/>
|
||||
<path
|
||||
android:pathData="M939.23,132.56a8.24,8.24 0,0 0,-3.56 -5.03c-7.17,-4.62 -25.83,-16.34 -28.37,-14.98 -3.16,1.69 -27.39,13.29 -75.35,-1.43 0,0 -36.28,9.99 -46.58,16.85S753.56,157 753.56,157s-97.69,15.76 -97,24 -7,28 -7,28 64,13 129,-14c0,0 20.55,47.62 18.49,56.54s-2.06,15.1 14.42,13.73 41.88,-5.49 54.92,-3.43 61.78,5.49 61.78,2.06L928.17,242.62s15.1,74.14 24.71,74.14 24.49,-5.79 30.67,-27.76l-30,-96Z"
|
||||
android:fillColor="#d0cde1"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M846.59,482.33l16.95,-147.01l2.98,0.34l-16.95,147.01z"
|
||||
android:strokeAlpha="0.2"
|
||||
android:fillAlpha="0.2"/>
|
||||
<path
|
||||
android:pathData="M893.87,24.75l-28.64,-15l-39.55,6.14l-8.18,36.14l20.37,-0.78l5.69,-13.28l0,13.06l9.4,-0.36l5.45,-21.14l3.41,22.5l33.41,-0.68l-1.36,-26.59z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M588.98,124.51a12.08,12.08 0,1 0,-17.08 17.08l17.08,17.08 17.08,-17.08a12.08,12.08 0,0 0,-17.08 -17.08Z"
|
||||
android:fillColor="#ff6584"/>
|
||||
<path
|
||||
android:pathData="M633.98,64.51a12.08,12.08 0,1 0,-17.08 17.08l17.08,17.08 17.08,-17.08a12.08,12.08 0,0 0,-17.08 -17.08Z"
|
||||
android:fillColor="#ff6584"/>
|
||||
<path
|
||||
android:pathData="M567.98,8.51a17.73,17.73 0,1 0,-25.08 25.08l25.08,25.08 25.08,-25.08a17.73,17.73 0,1 0,-25.08 -25.08Z"
|
||||
android:fillColor="#ff6584"/>
|
||||
<path
|
||||
android:pathData="M68.56,251h452v400h-452z"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M105.56,291h379v238h-379z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M326.98,530.42c0.45,-0.14 0.89,-0.28 1.34,-0.42a86.43,86.43 0,0 0,59.89 -85.57,85.27 85.27,0 0,0 -5.26,-26.47 86.32,86.32 0,0 0,-167.26 27.77,86.24 86.24,0 0,0 58.61,83.82c0.45,0.16 0.91,0.31 1.36,0.45h24.75Z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M277.01,512.11l-2.71,17.44l-0.07,0.45l-26.53,0l2.09,-13.44l1.42,-9.12l25.8,4.67z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M326.98,530.42l0.07,-0.42l1.55,-8.55l-25.8,-4.67l-2.39,13.22l26.57,0.42z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M308.01,426.7m-29.49,0a29.49,29.49 0,1 1,58.98 0a29.49,29.49 0,1 1,-58.98 0"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M320.13,416.13m-9.83,0a9.83,9.83 0,1 1,19.66 0a9.83,9.83 0,1 1,-19.66 0"
|
||||
android:fillColor="#3f3d56"/>
|
||||
<path
|
||||
android:pathData="M233.89,353.93c-1.3,-31.95 26.24,-59.01 61.5,-60.44s64.9,23.3 66.2,55.25 -23.21,39.21 -58.48,40.64S235.19,385.88 233.89,353.93Z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M339.59,441.73a13.54,43.15 77.09,1 0,84.11 -19.28a13.54,43.15 77.09,1 0,-84.11 19.28z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M170.2,469.9a13.54,43.15 77.09,1 0,84.11 -19.28a13.54,43.15 77.09,1 0,-84.11 19.28z"
|
||||
android:fillColor="#2f2e41"/>
|
||||
<path
|
||||
android:pathData="M278.27,481.81a19.66,19.66 0,1 0,38.69 7c1.93,-10.69 -6.33,-14.47 -17.01,-16.4S280.2,471.13 278.27,481.81Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M105.56,565h232v14h-232z"
|
||||
android:fillColor="#e57373"/>
|
||||
<path
|
||||
android:pathData="M105.56,595h379v14h-379z"
|
||||
android:fillColor="#e57373"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user