feat: statistics (wip)

This commit is contained in:
rebelonion 2024-03-02 04:54:02 -06:00
parent 533148069f
commit 500de4e45e
12 changed files with 690 additions and 356 deletions

View file

@ -17,6 +17,7 @@ import android.content.res.Configuration
import android.content.res.Resources.getSystem
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.media.MediaScannerConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities.*
@ -59,8 +60,15 @@ import ani.dantotsu.settings.saving.internal.PreferenceKeystore
import ani.dantotsu.settings.saving.internal.PreferenceKeystore.Companion.generateSalt
import ani.dantotsu.subcriptions.NotificationClickReceiver
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -68,6 +76,17 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.internal.ViewUtils
import com.google.android.material.snackbar.Snackbar
import eu.kanade.tachiyomi.data.notification.Notifications
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.SoftBreakAddsNewLinePlugin
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.ext.tasklist.TaskListPlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.html.TagHandlerNoOp
import io.noties.markwon.image.AsyncDrawable
import io.noties.markwon.image.glide.GlideImagesPlugin
import kotlinx.coroutines.*
import nl.joery.animatedbottombar.AnimatedBottomBar
import uy.kohesive.injekt.Injekt
@ -1079,4 +1098,70 @@ fun logToFile(context: Context, message: String) {
val file = File(externalFilesDir, "notifications.log")
file.appendText(message)
file.appendText("\n")
}
/**
* Builds the markwon instance with all the plugins
* @return the markwon instance
*/
fun buildMarkwon(activity: Activity, userInputContent: Boolean = true): Markwon {
val markwon = Markwon.builder(activity)
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder.linkResolver { view, link ->
copyToClipboard(link, true)
}
}
})
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(activity))
.usePlugin(TaskListPlugin.create(activity))
.usePlugin(HtmlPlugin.create { plugin ->
if (!userInputContent) {
plugin.addHandler(
TagHandlerNoOp.create("h1", "h2", "h3", "h4", "h5", "h6", "hr", "pre", "a")
)
}
})
.usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
private val requestManager: RequestManager =
Glide.with(activity).apply {
addDefaultRequestListener(object : RequestListener<Any> {
override fun onResourceReady(
resource: Any,
model: Any,
target: Target<Any>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
if (resource is GifDrawable) {
resource.start()
}
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Any>,
isFirstResource: Boolean
): Boolean {
return false
}
})
}
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
return requestManager.load(drawable.destination)
}
override fun cancel(target: Target<*>) {
requestManager.clear(target)
}
}))
.build()
return markwon
}

View file

@ -10,12 +10,8 @@ import ani.dantotsu.R
import ani.dantotsu.connections.comments.Comment
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.copyToClipboard
import ani.dantotsu.currActivity
import ani.dantotsu.currContext
import ani.dantotsu.databinding.ItemCommentsBinding
import ani.dantotsu.loadImage
import ani.dantotsu.media.user.ListActivity
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.profile.ProfileActivity
import ani.dantotsu.snackString
import com.xwray.groupie.GroupieAdapter
@ -56,7 +52,7 @@ class CommentItem(val comment: Comment,
@SuppressLint("SetTextI18n")
override fun bind(viewBinding: ItemCommentsBinding, position: Int) {
binding = viewBinding
viewBinding.commentRepliesList.layoutManager = LinearLayoutManager(currActivity())
viewBinding.commentRepliesList.layoutManager = LinearLayoutManager(commentsFragment.activity)
viewBinding.commentRepliesList.adapter = adapter
val isUserComment = CommentsAPI.userId == comment.userId
val node = markwon.parse(comment.content)
@ -101,7 +97,7 @@ class CommentItem(val comment: Comment,
viewBinding.commentUserName.setOnClickListener {
ContextCompat.startActivity(
currContext()!!, Intent(currContext()!!, ProfileActivity::class.java)
commentsFragment.activity, Intent(commentsFragment.activity, ProfileActivity::class.java)
.putExtra("userId", comment.userId.toInt())
.putExtra("username","[${levelColor.second}]"), null
)
@ -215,12 +211,12 @@ class CommentItem(val comment: Comment,
}
fun replying(isReplying: Boolean) {
binding?.commentReply?.text = if (isReplying) currActivity()!!.getString(R.string.cancel) else "Reply"
binding?.commentReply?.text = if (isReplying) commentsFragment.activity.getString(R.string.cancel) else "Reply"
this.isReplying = isReplying
}
fun editing(isEditing: Boolean) {
binding?.commentEdit?.text = if (isEditing) currActivity()!!.getString(R.string.cancel) else currActivity()!!.getString(R.string.edit)
binding?.commentEdit?.text = if (isEditing) commentsFragment.activity.getString(R.string.cancel) else commentsFragment.activity.getString(R.string.edit)
this.isEditing = isEditing
}

View file

@ -22,6 +22,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.R
import ani.dantotsu.buildMarkwon
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.comments.Comment
import ani.dantotsu.connections.comments.CommentResponse
@ -100,7 +101,7 @@ class CommentsFragment : Fragment() {
this.mediaId = mediaId
backgroundColor = (binding.root.background as? ColorDrawable)?.color ?: 0
val markwon = buildMarkwon()
val markwon = buildMarkwon(activity)
binding.commentUserAvatar.loadImage(Anilist.avatar)
val markwonEditor = MarkwonEditor.create(markwon)
@ -235,7 +236,7 @@ class CommentsFragment : Fragment() {
section.add(
CommentItem(
comment,
buildMarkwon(),
buildMarkwon(activity),
section,
this@CommentsFragment,
backgroundColor,
@ -386,7 +387,7 @@ class CommentsFragment : Fragment() {
section.add(
CommentItem(
it,
buildMarkwon(),
buildMarkwon(activity),
section,
this@CommentsFragment,
backgroundColor,
@ -416,7 +417,7 @@ class CommentsFragment : Fragment() {
section.add(
CommentItem(
comment,
buildMarkwon(),
buildMarkwon(activity),
section,
this@CommentsFragment,
backgroundColor,
@ -539,7 +540,7 @@ class CommentsFragment : Fragment() {
if (depth >= comment.MAX_DEPTH) comment.registerSubComment(it.commentId)
val newCommentItem = CommentItem(
it,
buildMarkwon(),
buildMarkwon(activity),
section,
this@CommentsFragment,
backgroundColor,
@ -664,7 +665,7 @@ class CommentsFragment : Fragment() {
if (commentWithInteraction!!.commentDepth + 1 > commentWithInteraction!!.MAX_DEPTH) 0 else section.itemCount,
CommentItem(
it,
buildMarkwon(),
buildMarkwon(activity),
section,
this@CommentsFragment,
backgroundColor,
@ -676,7 +677,7 @@ class CommentsFragment : Fragment() {
0,
CommentItem(
it,
buildMarkwon(),
buildMarkwon(activity),
section,
this@CommentsFragment,
backgroundColor,
@ -686,68 +687,4 @@ class CommentsFragment : Fragment() {
}
}
}
/**
* Builds the markwon instance with all the plugins
* @return the markwon instance
*/
private fun buildMarkwon(): Markwon {
val markwon = Markwon.builder(activity)
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder.linkResolver { view, link ->
copyToClipboard(link, true)
}
}
})
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(activity))
.usePlugin(TaskListPlugin.create(activity))
.usePlugin(HtmlPlugin.create { plugin ->
plugin.addHandler(
TagHandlerNoOp.create("h1", "h2", "h3", "h4", "h5", "h6", "hr", "pre", "a")
)
})
.usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
private val requestManager: RequestManager =
Glide.with(this@CommentsFragment).apply {
addDefaultRequestListener(object : RequestListener<Any> {
override fun onResourceReady(
resource: Any,
model: Any,
target: Target<Any>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
if (resource is GifDrawable) {
resource.start()
}
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Any>,
isFirstResource: Boolean
): Boolean {
return false
}
})
}
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
return requestManager.load(drawable.destination)
}
override fun cancel(target: Target<*>) {
requestManager.clear(target)
}
}))
.build()
return markwon
}
}

View file

@ -6,58 +6,123 @@ import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import ani.dantotsu.R
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.anilist.api.Query
import ani.dantotsu.databinding.ActivityProfileBinding
import ani.dantotsu.initActivity
import ani.dantotsu.loadImage
import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.media.user.ListActivity
import ani.dantotsu.others.ImageViewDialog
import ani.dantotsu.snackString
import ani.dantotsu.themes.ThemeManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nl.joery.animatedbottombar.AnimatedBottomBar
class ProfileActivity : AppCompatActivity(){
private lateinit var binding: ActivityProfileBinding
private var selected: Int = 0
private lateinit var tabLayout: AnimatedBottomBar
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ThemeManager(this).applyTheme()
initActivity(this)
binding = ActivityProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
tabLayout = binding.typeTab
val profileTab = tabLayout.createTab(R.drawable.ic_round_person_24, "Profile")
val statsTab = tabLayout.createTab(R.drawable.ic_stats_24, "Stats")
tabLayout.addTab(profileTab)
tabLayout.addTab(statsTab)
tabLayout.visibility = View.GONE
binding.mediaViewPager.isUserInputEnabled = false
lifecycleScope.launch(Dispatchers.IO) {
val userid = intent.getIntExtra("userId", 0)
val respond = Anilist.query.getUserProfile(userid)
val user = respond?.data?.user ?: return@launch
val userLevel = intent.getStringExtra("username")
val user = respond?.data?.user
if (user == null) {
snackString("User not found")
finish()
return@launch
}
withContext(Dispatchers.Main) {
binding.mediaViewPager.adapter = ViewPagerAdapter(supportFragmentManager, lifecycle, user, this@ProfileActivity)
tabLayout.visibility = View.VISIBLE
tabLayout.selectTabAt(selected)
tabLayout.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener {
override fun onTabSelected(
lastIndex: Int,
lastTab: AnimatedBottomBar.Tab?,
newIndex: Int,
newTab: AnimatedBottomBar.Tab
) {
selected = newIndex
binding.mediaViewPager.setCurrentItem(selected, true)
}
})
val userLevel = intent.getStringExtra("username")?: ""
binding.profileProgressBar.visibility = View.GONE
binding.profileBannerImage.loadImage(user.bannerImage)
binding.profileUserAvatar.loadImage(user.avatar?.medium)
binding.profileUserName.text = "${user.name} $userLevel"
binding.profileUserInfo.text = user.about
binding.profileAnimeList.setOnClickListener {
ContextCompat.startActivity(
this@ProfileActivity, Intent(this@ProfileActivity, ListActivity::class.java)
.putExtra("anime", true)
.putExtra("userId", user.id)
.putExtra("username", user.name), null
)
}
binding.profileMangaList.setOnClickListener {
ContextCompat.startActivity(
this@ProfileActivity, Intent(this@ProfileActivity, ListActivity::class.java)
.putExtra("anime", false)
.putExtra("userId", user.id)
.putExtra("username", user.name), null
)
}
binding.profileUserEpisodesWatched.text = user.statistics.anime.episodesWatched.toString()
binding.profileUserChaptersRead.text = user.statistics.manga.chaptersRead.toString()
binding.profileAnimeListImage.loadImage("https://bit.ly/31bsIHq")
binding.profileMangaListImage.loadImage("https://bit.ly/2ZGfcuG")
binding.profileBannerImage.loadImage(user.bannerImage)
binding.profileBannerImage.setOnLongClickListener {
ImageViewDialog.newInstance(
this@ProfileActivity,
user.name + " [Banner]",
user.bannerImage
)
}
binding.profileUserAvatar.loadImage(user.avatar?.medium)
binding.profileUserAvatar.setOnLongClickListener {
ImageViewDialog.newInstance(
this@ProfileActivity,
user.name + " [Avatar]",
user.avatar?.medium
)
}
}
}
}
override fun onResume() {
if (this::tabLayout.isInitialized) {
tabLayout.selectTabAt(selected)
}
super.onResume()
}
private class ViewPagerAdapter(
fragmentManager: FragmentManager,
lifecycle: Lifecycle,
private val user: Query.UserProfile,
private val activity: ProfileActivity
) :
FragmentStateAdapter(fragmentManager, lifecycle) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> ProfileFragment(user, activity)
1 -> StatsFragment(user, activity)
else -> ProfileFragment(user, activity)
}
}
}

View file

@ -0,0 +1,50 @@
package ani.dantotsu.profile
import android.content.Intent
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 ani.dantotsu.buildMarkwon
import ani.dantotsu.connections.anilist.api.Query
import ani.dantotsu.databinding.FragmentProfileBinding
import ani.dantotsu.loadImage
import ani.dantotsu.media.user.ListActivity
class ProfileFragment(private val user: Query.UserProfile, private val activity: ProfileActivity): Fragment() {
lateinit var binding: FragmentProfileBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentProfileBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val markwon = buildMarkwon(activity, false)
markwon.setMarkdown(binding.profileUserInfo, user.about?:"")
binding.profileAnimeList.setOnClickListener {
ContextCompat.startActivity(
activity, Intent(activity, ListActivity::class.java)
.putExtra("anime", true)
.putExtra("userId", user.id)
.putExtra("username", user.name), null
)
}
binding.profileMangaList.setOnClickListener {
ContextCompat.startActivity(
activity, Intent(activity, ListActivity::class.java)
.putExtra("anime", false)
.putExtra("userId", user.id)
.putExtra("username", user.name), null
)
}
binding.profileAnimeListImage.loadImage("https://bit.ly/31bsIHq")
binding.profileMangaListImage.loadImage("https://bit.ly/2ZGfcuG")
}
}

View file

@ -1,4 +0,0 @@
package ani.dantotsu.profile
class StatisticsActivity {
}

View file

@ -0,0 +1,157 @@
package ani.dantotsu.profile
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import ani.dantotsu.R
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.anilist.api.Query
import ani.dantotsu.databinding.FragmentStatisticsBinding
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartAlignType
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartLayoutType
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartModel
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartStackingType
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartType
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartVerticalAlignType
import com.github.aachartmodel.aainfographics.aachartcreator.AAChartZoomType
import com.github.aachartmodel.aainfographics.aachartcreator.AADataElement
import com.github.aachartmodel.aainfographics.aachartcreator.AAOptions
import com.github.aachartmodel.aainfographics.aachartcreator.AASeriesElement
import com.github.aachartmodel.aainfographics.aachartcreator.aa_toAAOptions
import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAChart
import com.github.aachartmodel.aainfographics.aaoptionsmodel.AALang
import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAPosition
import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAScrollablePlotArea
import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAStyle
import com.github.aachartmodel.aainfographics.aatools.AAColor
import com.github.aachartmodel.aainfographics.aatools.AAGradientColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nl.joery.animatedbottombar.AnimatedBottomBar
class StatsFragment(private val user: Query.UserProfile, private val activity: ProfileActivity) :
Fragment() {
private lateinit var binding: FragmentStatisticsBinding
private var selected: Int = 0
private lateinit var tabLayout: AnimatedBottomBar
private var stats: Query.StatisticsResponse? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentStatisticsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
tabLayout = binding.typeTab
val animeTab = tabLayout.createTab(R.drawable.ic_round_movie_filter_24, "Anime")
val mangaTab = tabLayout.createTab(R.drawable.ic_round_menu_book_24, "Manga")
tabLayout.addTab(animeTab)
tabLayout.addTab(mangaTab)
tabLayout.visibility = View.GONE
activity.lifecycleScope.launch {
stats = Anilist.query.getUserStatistics(user.id)
withContext(Dispatchers.Main) {
tabLayout.visibility = View.VISIBLE
tabLayout.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener {
override fun onTabSelected(
lastIndex: Int,
lastTab: AnimatedBottomBar.Tab?,
newIndex: Int,
newTab: AnimatedBottomBar.Tab
) {
selected = newIndex
when (newIndex) {
0 -> loadAnimeStats()
1 -> loadMangaStats()
}
}
})
tabLayout.selectTabAt(selected)
loadAnimeStats()
}
}
}
override fun onResume() {
if (this::tabLayout.isInitialized) {
tabLayout.selectTabAt(selected)
}
super.onResume()
}
private fun loadAnimeStats() {
val formatChartModel = getFormatChartModel(true)
if (formatChartModel != null) {
val aaOptions = buildOptions(formatChartModel)
binding.formatChartView.aa_drawChartWithChartOptions(aaOptions)
}
}
private fun loadMangaStats() {
val formatChartModel = getFormatChartModel(false)
if (formatChartModel != null) {
val aaOptions = buildOptions(formatChartModel)
binding.formatChartView.aa_drawChartWithChartOptions(aaOptions)
}
}
private fun buildOptions(aaChartModel: AAChartModel): AAOptions {
val aaOptions = aaChartModel.aa_toAAOptions()
aaOptions.tooltip?.apply {
backgroundColor(AAGradientColor.PurpleLake)
.style(AAStyle.style(AAColor.White))
}
aaOptions.chart?.zoomType = "xy"
aaOptions.chart?.pinchType = "xy"
aaOptions.legend?.apply {
enabled(true)
.verticalAlign(AAChartVerticalAlignType.Top)
.layout(AAChartLayoutType.Vertical)
.align(AAChartAlignType.Right)
.itemMarginTop(10f)
.labelFormat = "{name}: {y}"
}
aaOptions.plotOptions?.series?.connectNulls(true)
return aaOptions
}
private fun getFormatChartModel(anime: Boolean): AAChartModel? {
val fotmatTypes: List<String> = if (anime) {
stats?.data?.user?.statistics?.anime?.formats?.map { it.format } ?: emptyList()
} else {
stats?.data?.user?.statistics?.manga?.formats?.map { it.format } ?: emptyList()
}
val formatCount: List<Int> = if (anime) {
stats?.data?.user?.statistics?.anime?.formats?.map { it.count } ?: emptyList()
} else {
stats?.data?.user?.statistics?.manga?.formats?.map { it.count } ?: emptyList()
}
if (fotmatTypes.isEmpty() || formatCount.isEmpty())
return null
return AAChartModel()
.chartType(AAChartType.Pie)
.title("Format")
.zoomType(AAChartZoomType.XY)
.dataLabelsEnabled(true)
.series(getElements(fotmatTypes, formatCount))
}
private fun getElements(types: List<String>, counts: List<Int>): Array<Any> {
val elements = AASeriesElement()
val dataElements = mutableListOf<AADataElement>()
for (i in types.indices) {
dataElements.add(AADataElement().name(types[i]).y(counts[i]))
}
return arrayOf(elements.data(dataElements.toTypedArray()))
}
}