Compare commits
16 Commits
v6.7.4
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1a2fcf77 | ||
|
|
876675445d | ||
|
|
f7a70680bd | ||
|
|
8e82db441c | ||
|
|
f2626c668d | ||
|
|
4694215ccc | ||
|
|
096f5b15dc | ||
|
|
101d357eff | ||
|
|
11cd5609bb | ||
|
|
fda59996aa | ||
|
|
20461112d2 | ||
|
|
f98bb87d6e | ||
|
|
c451952a1e | ||
|
|
35a2ac4b04 | ||
|
|
f39ccb6223 | ||
|
|
6cb6c891dd |
@@ -239,6 +239,9 @@
|
||||
<data android:scheme="kotatsu+kitsu" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
|
||||
android:label="@string/reading_stats" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
page = json.getInt("page"),
|
||||
scroll = json.getDouble("scroll").toFloat(),
|
||||
percent = json.getFloatOrDefault("percent", -1f),
|
||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
put("page", e.page)
|
||||
put("scroll", e.scroll)
|
||||
put("percent", e.percent)
|
||||
put("chapters", e.chaptersCount)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
@@ -48,20 +49,22 @@ import org.koitharu.kotatsu.history.data.HistoryDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 18
|
||||
const val DATABASE_VERSION = 19
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
@@ -90,6 +93,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract fun getScrobblingDao(): ScrobblingDao
|
||||
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -110,6 +115,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration15To16(),
|
||||
Migration16To17(context),
|
||||
Migration17To18(),
|
||||
Migration18To19(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration18To19 : Migration(18, 19) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.parsers.util.isNumeric
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
@@ -191,11 +192,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
var appPassword: String?
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit {
|
||||
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
|
||||
KEY_APP_PASSWORD,
|
||||
)
|
||||
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD)
|
||||
}
|
||||
|
||||
var isAppPasswordNumeric: Boolean
|
||||
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
|
||||
|
||||
val isLoggingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
||||
|
||||
@@ -419,6 +422,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isPagesSavingAskEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
|
||||
|
||||
val isStatsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
|
||||
|
||||
fun isTipEnabled(tip: String): Boolean {
|
||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||
}
|
||||
@@ -528,6 +534,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_APP_PASSWORD = "app_password"
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -610,8 +617,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READING_TIME = "reading_time"
|
||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||
|
||||
// About
|
||||
const val KEY_STATS_ENABLED = "stats_on"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
|
||||
return this
|
||||
}
|
||||
|
||||
fun addListListener(listListener: ListListener<T>) {
|
||||
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
|
||||
differ.addListListener(listListener)
|
||||
return this
|
||||
}
|
||||
|
||||
fun removeListListener(listListener: ListListener<T>) {
|
||||
|
||||
@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
|
||||
loadingCounter.increment()
|
||||
block()
|
||||
} finally {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
@@ -12,12 +12,10 @@ import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.withClip
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.Colors
|
||||
import kotlin.math.absoluteValue
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
|
||||
class FaviconDrawable(
|
||||
context: Context,
|
||||
@@ -45,7 +43,7 @@ class FaviconDrawable(
|
||||
}
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
paint.isFakeBoldText = true
|
||||
colorForeground = MaterialColors.harmonize(Colors.random(name), colorBackground)
|
||||
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
|
||||
@@ -110,7 +110,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
chip.isChipIconVisible = false
|
||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||
chip.setEnsureMinTouchTargetSize(false)
|
||||
chip.setEnsureMinTouchTargetSize(false) // TODO remove
|
||||
chip.setOnClickListener(chipOnClickListener)
|
||||
addView(chip)
|
||||
return chip
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.CornerPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextDirectionHeuristic
|
||||
import android.text.TextDirectionHeuristics
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.draw
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveSp
|
||||
|
||||
class PieChart @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr), PieChartInterface {
|
||||
|
||||
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
|
||||
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
|
||||
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
|
||||
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
|
||||
private val marginText: Float = marginTextFirst + marginTextSecond
|
||||
private val circleRect = RectF()
|
||||
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
|
||||
private var circleRadius: Float = 0f
|
||||
private var circlePadding: Float = context.resources.resolveDp(8f)
|
||||
private var circlePaintRoundSize: Boolean = true
|
||||
private var circleSectionSpace: Float = 3f
|
||||
private var circleCenterX: Float = 0f
|
||||
private var circleCenterY: Float = 0f
|
||||
private var numberTextPaint: TextPaint = TextPaint()
|
||||
private var descriptionTextPain: TextPaint = TextPaint()
|
||||
private var amountTextPaint: TextPaint = TextPaint()
|
||||
private var textStartX: Float = 0f
|
||||
private var textStartY: Float = 0f
|
||||
private var textHeight: Int = 0
|
||||
private var textCircleRadius: Float = context.resources.resolveDp(4f)
|
||||
private var textAmountStr: String = ""
|
||||
private var textAmountY: Float = 0f
|
||||
private var textAmountXNumber: Float = 0f
|
||||
private var textAmountXDescription: Float = 0f
|
||||
private var textAmountYDescription: Float = 0f
|
||||
private var totalAmount: Int = 0
|
||||
private var pieChartColors: List<String> = listOf()
|
||||
private var percentageCircleList: List<PieChartModel> = listOf()
|
||||
private var textRowList: MutableList<StaticLayout> = mutableListOf()
|
||||
private var dataList: List<Pair<Int, String>> = listOf()
|
||||
private var animationSweepAngle: Int = 0
|
||||
|
||||
init {
|
||||
var textAmountSize: Float = context.resources.resolveSp(22f)
|
||||
var textNumberSize: Float = context.resources.resolveSp(20f)
|
||||
var textDescriptionSize: Float = context.resources.resolveSp(14f)
|
||||
var textAmountColor: Int = Color.WHITE
|
||||
var textNumberColor: Int = Color.WHITE
|
||||
var textDescriptionColor: Int = Color.GRAY
|
||||
|
||||
if (attrs != null) {
|
||||
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
|
||||
|
||||
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
|
||||
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
|
||||
|
||||
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
|
||||
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
|
||||
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
|
||||
marginSmallCircle =
|
||||
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
|
||||
|
||||
circleStrokeWidth =
|
||||
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
|
||||
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
|
||||
circlePaintRoundSize =
|
||||
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
|
||||
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
|
||||
|
||||
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
|
||||
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
|
||||
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
|
||||
textDescriptionSize =
|
||||
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
|
||||
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
|
||||
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
|
||||
textDescriptionColor =
|
||||
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
|
||||
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
|
||||
|
||||
typeArray.recycle()
|
||||
}
|
||||
|
||||
circlePadding += circleStrokeWidth
|
||||
|
||||
// Инициализация кистей View
|
||||
initPaints(amountTextPaint, textAmountSize, textAmountColor)
|
||||
initPaints(numberTextPaint, textNumberSize, textNumberColor)
|
||||
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
textRowList.clear()
|
||||
|
||||
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
|
||||
|
||||
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
|
||||
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
|
||||
|
||||
textStartX = initSizeWidth - textTextWidth.toFloat()
|
||||
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
|
||||
|
||||
calculateCircleRadius(initSizeWidth, initSizeHeight)
|
||||
|
||||
setMeasuredDimension(initSizeWidth, initSizeHeight)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
drawCircle(canvas)
|
||||
drawText(canvas)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
val pieChartState = state as? PieChartState
|
||||
super.onRestoreInstanceState(pieChartState?.superState ?: state)
|
||||
|
||||
dataList = pieChartState?.dataList ?: listOf()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val superState = super.onSaveInstanceState()
|
||||
return PieChartState(superState, dataList)
|
||||
}
|
||||
|
||||
override fun setDataChart(list: List<Pair<Int, String>>) {
|
||||
dataList = list
|
||||
calculatePercentageOfData()
|
||||
}
|
||||
|
||||
override fun startAnimation() {
|
||||
val animator = ValueAnimator.ofInt(0, 360).apply {
|
||||
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
|
||||
interpolator = FastOutSlowInInterpolator()
|
||||
addUpdateListener { valueAnimator ->
|
||||
animationSweepAngle = valueAnimator.animatedValue as Int
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
animator.start()
|
||||
}
|
||||
|
||||
private fun drawCircle(canvas: Canvas) {
|
||||
for (percent in percentageCircleList) {
|
||||
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
|
||||
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
|
||||
} else if (animationSweepAngle > percent.percentToStartAt) {
|
||||
canvas.drawArc(
|
||||
circleRect,
|
||||
percent.percentToStartAt,
|
||||
animationSweepAngle - percent.percentToStartAt,
|
||||
false,
|
||||
percent.paint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawText(canvas: Canvas) {
|
||||
var textBuffY = textStartY
|
||||
textRowList.forEachIndexed { index, staticLayout ->
|
||||
if (index % 2 == 0) {
|
||||
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
|
||||
canvas.drawCircle(
|
||||
textStartX + marginSmallCircle / 2,
|
||||
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
|
||||
textCircleRadius,
|
||||
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
|
||||
)
|
||||
textBuffY += staticLayout.height + marginTextFirst
|
||||
} else {
|
||||
staticLayout.draw(canvas, textStartX, textBuffY)
|
||||
textBuffY += staticLayout.height + marginTextSecond
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
|
||||
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
|
||||
}
|
||||
|
||||
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
|
||||
textPaint.color = textColor
|
||||
textPaint.textSize = textSize
|
||||
textPaint.isAntiAlias = true
|
||||
|
||||
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
|
||||
return when (MeasureSpec.getMode(spec)) {
|
||||
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
|
||||
else -> MeasureSpec.getSize(spec)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
|
||||
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
|
||||
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
|
||||
|
||||
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
|
||||
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
|
||||
}
|
||||
|
||||
private fun calculateCircleRadius(width: Int, height: Int) {
|
||||
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
|
||||
circleRadius = if (circleViewWidth > height) {
|
||||
(height.toFloat() - circlePadding) / 2
|
||||
} else {
|
||||
circleViewWidth.toFloat() / 2
|
||||
}
|
||||
|
||||
with(circleRect) {
|
||||
left = circlePadding
|
||||
top = height / 2 - circleRadius
|
||||
right = circleRadius * 2 + circlePadding
|
||||
bottom = height / 2 + circleRadius
|
||||
}
|
||||
|
||||
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
|
||||
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
|
||||
|
||||
textAmountY = circleCenterY
|
||||
|
||||
val sizeTextAmountNumber = getWidthOfAmountText(
|
||||
totalAmount.toString(),
|
||||
amountTextPaint,
|
||||
)
|
||||
|
||||
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
|
||||
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
|
||||
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun getTextViewHeight(maxWidth: Int): Int {
|
||||
var textHeight = 0
|
||||
dataList.forEach {
|
||||
val textLayoutNumber = getMultilineText(
|
||||
text = it.first.toString(),
|
||||
textPaint = numberTextPaint,
|
||||
width = maxWidth,
|
||||
)
|
||||
val textLayoutDescription = getMultilineText(
|
||||
text = it.second,
|
||||
textPaint = descriptionTextPain,
|
||||
width = maxWidth,
|
||||
)
|
||||
textRowList.apply {
|
||||
add(textLayoutNumber)
|
||||
add(textLayoutDescription)
|
||||
}
|
||||
textHeight += textLayoutNumber.height + textLayoutDescription.height
|
||||
}
|
||||
|
||||
return textHeight
|
||||
}
|
||||
|
||||
private fun calculatePercentageOfData() {
|
||||
totalAmount = dataList.fold(0) { res, value -> res + value.first }
|
||||
|
||||
var startAt = circleSectionSpace
|
||||
percentageCircleList = dataList.mapIndexed { index, pair ->
|
||||
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
|
||||
percent = if (percent < 0f) 0f else percent
|
||||
|
||||
val resultModel = PieChartModel(
|
||||
percentOfCircle = percent,
|
||||
percentToStartAt = startAt,
|
||||
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
|
||||
stroke = circleStrokeWidth,
|
||||
paintRound = circlePaintRoundSize,
|
||||
)
|
||||
if (percent != 0f) startAt += percent + circleSectionSpace
|
||||
resultModel
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
|
||||
val bounds = Rect()
|
||||
textPaint.getTextBounds(text, 0, text.length, bounds)
|
||||
return bounds
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun getMultilineText(
|
||||
text: CharSequence,
|
||||
textPaint: TextPaint,
|
||||
width: Int,
|
||||
start: Int = 0,
|
||||
end: Int = text.length,
|
||||
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
|
||||
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
|
||||
spacingMult: Float = 1f,
|
||||
spacingAdd: Float = 0f
|
||||
): StaticLayout {
|
||||
|
||||
return StaticLayout.Builder
|
||||
.obtain(text, start, end, textPaint, width)
|
||||
.setAlignment(alignment)
|
||||
.setTextDirection(textDir)
|
||||
.setLineSpacing(spacingAdd, spacingMult)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_MARGIN_TEXT_1 = 2f
|
||||
private const val DEFAULT_MARGIN_TEXT_2 = 10f
|
||||
private const val DEFAULT_MARGIN_TEXT_3 = 2f
|
||||
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
|
||||
|
||||
private const val TEXT_WIDTH_PERCENT = 0.40
|
||||
private const val CIRCLE_WIDTH_PERCENT = 0.50
|
||||
|
||||
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
|
||||
const val DEFAULT_VIEW_SIZE_WIDTH = 250
|
||||
}
|
||||
}
|
||||
|
||||
interface PieChartInterface {
|
||||
|
||||
fun setDataChart(list: List<Pair<Int, String>>)
|
||||
|
||||
fun startAnimation()
|
||||
}
|
||||
|
||||
data class PieChartModel(
|
||||
var percentOfCircle: Float = 0f,
|
||||
var percentToStartAt: Float = 0f,
|
||||
var colorOfLine: Int = 0,
|
||||
var stroke: Float = 0f,
|
||||
var paint: Paint = Paint(),
|
||||
var paintRound: Boolean = true
|
||||
) {
|
||||
|
||||
init {
|
||||
if (percentOfCircle < 0 || percentOfCircle > 100) {
|
||||
percentOfCircle = 100f
|
||||
}
|
||||
|
||||
percentOfCircle = 360 * percentOfCircle / 100
|
||||
|
||||
if (percentToStartAt < 0 || percentToStartAt > 100) {
|
||||
percentToStartAt = 0f
|
||||
}
|
||||
|
||||
percentToStartAt = 360 * percentToStartAt / 100
|
||||
|
||||
if (colorOfLine == 0) {
|
||||
colorOfLine = Color.parseColor("#000000")
|
||||
}
|
||||
|
||||
paint = Paint()
|
||||
paint.color = colorOfLine
|
||||
paint.isAntiAlias = true
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = stroke
|
||||
paint.isDither = true
|
||||
|
||||
if (paintRound) {
|
||||
paint.strokeJoin = Paint.Join.ROUND
|
||||
paint.strokeCap = Paint.Cap.ROUND
|
||||
paint.pathEffect = CornerPathEffect(8f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PieChartState(
|
||||
superSavedState: Parcelable?,
|
||||
val dataList: List<Pair<Int, String>>
|
||||
) : View.BaseSavedState(superSavedState), Parcelable
|
||||
@@ -7,9 +7,10 @@ import androidx.core.graphics.ColorUtils
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
object Colors {
|
||||
object KotatsuColors {
|
||||
|
||||
@ColorInt
|
||||
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
||||
@@ -20,11 +21,24 @@ object Colors {
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun random(seed: Any): Int {
|
||||
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
|
||||
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun ofManga(context: Context, manga: Manga?): Int {
|
||||
val color = if (manga != null) {
|
||||
val hue = (manga.id.absoluteValue % 360).toFloat()
|
||||
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
} else {
|
||||
context.getThemeColor(R.attr.colorSurface)
|
||||
}
|
||||
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
|
||||
private fun getHue(hex: String): Float {
|
||||
val r = (hex.substring(0, 2).toInt(16)).toFloat()
|
||||
val g = (hex.substring(2, 4).toInt(16)).toFloat()
|
||||
@@ -10,6 +10,7 @@ data class ReadingTime(
|
||||
) {
|
||||
|
||||
fun format(resources: Resources): String = when {
|
||||
hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute)
|
||||
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
|
||||
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
|
||||
else -> resources.getString(
|
||||
|
||||
@@ -5,25 +5,27 @@ import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ReadingTimeUseCase @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val statsRepository: StatsRepository,
|
||||
) {
|
||||
|
||||
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
||||
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
||||
if (!settings.isReadingTimeEstimationEnabled) {
|
||||
return null
|
||||
}
|
||||
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
|
||||
val chapters = manga?.chapters?.get(branch)
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
|
||||
// Impossible task, I guess. Good luck on this.
|
||||
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
|
||||
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
||||
if (isOnHistoryBranch) {
|
||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||
}
|
||||
@@ -36,4 +38,16 @@ class ReadingTimeUseCase @Inject constructor(
|
||||
isContinue = isOnHistoryBranch,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getSecondsPerPage(mangaId: Long): Int {
|
||||
var time = if (settings.isStatsEnabled) {
|
||||
TimeUnit.MILLISECONDS.toSeconds(statsRepository.getTimePerPage(mangaId)).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (time == 0) {
|
||||
time = 10 // default
|
||||
}
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,10 @@ class DetailsActivity :
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom))
|
||||
viewModel.onActionDone.observeEvent(
|
||||
this,
|
||||
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
|
||||
)
|
||||
viewModel.onShowTip.observeEvent(this) { showTip() }
|
||||
viewModel.historyInfo.observe(this, ::onHistoryChanged)
|
||||
viewModel.selectedBranch.observe(this) {
|
||||
@@ -150,6 +153,7 @@ class DetailsActivity :
|
||||
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
|
||||
val menuInvalidator = MenuInvalidator(this)
|
||||
viewModel.favouriteCategories.observe(this, menuInvalidator)
|
||||
viewModel.isStatsEnabled.observe(this, menuInvalidator)
|
||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||
viewModel.branches.observe(this) {
|
||||
viewBinding.buttonDropdown.isVisible = it.size > 1
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
|
||||
class DetailsMenuProvider(
|
||||
private val activity: FragmentActivity,
|
||||
@@ -43,6 +44,7 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
|
||||
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsEnabled.value
|
||||
menu.findItem(R.id.action_favourite).setIcon(
|
||||
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
)
|
||||
@@ -101,6 +103,12 @@ class DetailsMenuProvider(
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_stats -> {
|
||||
viewModel.manga.value?.let {
|
||||
MangaStatsSheet.show(activity.supportFragmentManager, it)
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_scrobbling -> {
|
||||
viewModel.manga.value?.let {
|
||||
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
|
||||
|
||||
@@ -100,6 +100,10 @@ class DetailsViewModel @Inject constructor(
|
||||
val favouriteCategories = interactor.observeIsFavourite(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val isStatsEnabled = settings.observeAsStateFlow(viewModelScope + Dispatchers.Default, AppSettings.KEY_STATS_ENABLED) {
|
||||
isStatsEnabled
|
||||
}
|
||||
|
||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
|
||||
val newChaptersCount = details.flatMapLatest { d ->
|
||||
|
||||
@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
),
|
||||
],
|
||||
)
|
||||
data class HistoryEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@@ -28,4 +28,5 @@ data class HistoryEntity(
|
||||
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||
@ColumnInfo(name = "percent") val percent: Float,
|
||||
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
||||
@ColumnInfo(name = "chapters") val chaptersCount: Int,
|
||||
)
|
||||
|
||||
@@ -94,6 +94,7 @@ class HistoryRepository @Inject constructor(
|
||||
if (!force && shouldSkip(manga)) {
|
||||
return
|
||||
}
|
||||
assert(manga.chapters != null)
|
||||
db.withTransaction {
|
||||
mangaRepository.storeManga(manga)
|
||||
db.getHistoryDao().upsert(
|
||||
@@ -105,6 +106,7 @@ class HistoryRepository @Inject constructor(
|
||||
page = page,
|
||||
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
|
||||
percent = percent,
|
||||
chaptersCount = manga.chapters?.size ?: -1,
|
||||
deletedAt = 0L,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.NetworkManageIntent
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||
@@ -27,6 +29,7 @@ class HistoryListFragment : MangaListFragment() {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
RecyclerScrollKeeper(binding.recyclerView).attach()
|
||||
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
|
||||
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.history.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
@@ -9,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.stats.ui.StatsActivity
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
@@ -24,6 +26,11 @@ class HistoryListMenuProvider(
|
||||
menuInflater.inflate(R.menu.opt_history, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_stats)?.isVisible = viewModel.isStatsEnabled.value
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_clear_history -> {
|
||||
@@ -31,6 +38,11 @@ class HistoryListMenuProvider(
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_stats -> {
|
||||
context.startActivity(Intent(context, StatsActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,12 @@ class HistoryListViewModel @Inject constructor(
|
||||
g && s.isGroupingSupported()
|
||||
}
|
||||
|
||||
val isStatsEnabled = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_STATS_ENABLED,
|
||||
valueProducer = { isStatsEnabled },
|
||||
)
|
||||
|
||||
override val content = combine(
|
||||
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
||||
isGroupingEnabled,
|
||||
|
||||
@@ -2,18 +2,11 @@ package org.koitharu.kotatsu.local.ui.info
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -21,16 +14,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
||||
import org.koitharu.kotatsu.core.util.Colors
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.settings.userdata.StorageUsage
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -67,7 +57,7 @@ class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
|
||||
val total = size + available
|
||||
val segment = SegmentedBarView.Segment(
|
||||
percent = (size.toDouble() / total.toDouble()).toFloat(),
|
||||
color = Colors.segmentColor(view.context, materialR.attr.colorPrimary),
|
||||
color = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary),
|
||||
)
|
||||
requireViewBinding().labelUsed.text = view.context.getString(
|
||||
R.string.memory_usage_pattern,
|
||||
|
||||
@@ -44,6 +44,12 @@ class ProtectActivity :
|
||||
viewBinding.buttonNext.setOnClickListener(this)
|
||||
viewBinding.buttonCancel.setOnClickListener(this)
|
||||
|
||||
viewBinding.editPassword.inputType = if (viewModel.isNumericPassword) {
|
||||
EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
} else {
|
||||
EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
|
||||
viewModel.onError.observeEvent(this, this::onError)
|
||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.onUnlockSuccess.observeEvent(this) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.parsers.util.isNumeric
|
||||
import org.koitharu.kotatsu.parsers.util.md5
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -26,6 +27,9 @@ class ProtectViewModel @Inject constructor(
|
||||
val isBiometricEnabled
|
||||
get() = settings.isBiometricProtectionEnabled
|
||||
|
||||
val isNumericPassword
|
||||
get() = settings.isAppPasswordNumeric
|
||||
|
||||
fun tryUnlock(password: String) {
|
||||
if (job?.isActive == true) {
|
||||
return
|
||||
|
||||
@@ -176,6 +176,11 @@ class ReaderActivity :
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.stats.domain.StatsCollector
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -78,11 +79,11 @@ class ReaderViewModel @Inject constructor(
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val historyUpdateUseCase: HistoryUpdateUseCase,
|
||||
private val detectReaderModeUseCase: DetectReaderModeUseCase,
|
||||
private val statsCollector: StatsCollector,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
private val preselectedBranch = savedStateHandle.get<String>(ReaderActivity.EXTRA_BRANCH)
|
||||
private val isIncognito = savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) ?: false
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
private var pageSaveJob: Job? = null
|
||||
@@ -98,7 +99,7 @@ class ReaderViewModel @Inject constructor(
|
||||
val onShowToast = MutableEventFlow<Int>()
|
||||
val uiState = MutableStateFlow<ReaderUiState?>(null)
|
||||
|
||||
val incognitoMode = if (isIncognito) {
|
||||
val incognitoMode = if (savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) == true) {
|
||||
MutableStateFlow(true)
|
||||
} else mangaFlow.map {
|
||||
it != null && historyRepository.shouldSkip(it)
|
||||
@@ -190,6 +191,12 @@ class ReaderViewModel @Inject constructor(
|
||||
loadImpl()
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
manga?.let {
|
||||
statsCollector.onPause(it.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchMode(newMode: ReaderMode) {
|
||||
launchJob {
|
||||
val manga = checkNotNull(mangaData.value?.toManga())
|
||||
@@ -208,7 +215,7 @@ class ReaderViewModel @Inject constructor(
|
||||
if (state != null) {
|
||||
currentState.value = state
|
||||
}
|
||||
if (isIncognito) {
|
||||
if (incognitoMode.value) {
|
||||
return
|
||||
}
|
||||
val readerState = state ?: currentState.value ?: return
|
||||
@@ -377,7 +384,7 @@ class ReaderViewModel @Inject constructor(
|
||||
|
||||
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
|
||||
// save state
|
||||
if (!isIncognito) {
|
||||
if (!incognitoMode.value) {
|
||||
currentState.value?.let {
|
||||
val percent = computePercent(it.chapterId, it.page)
|
||||
historyUpdateUseCase.invoke(manga, it, percent)
|
||||
@@ -426,6 +433,9 @@ class ReaderViewModel @Inject constructor(
|
||||
percent = computePercent(state.chapterId, state.page),
|
||||
)
|
||||
uiState.value = newState
|
||||
if (!incognitoMode.value) {
|
||||
statsCollector.onStateChanged(m.id, state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
|
||||
@@ -29,6 +29,8 @@ import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity
|
||||
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
import org.koitharu.kotatsu.stats.ui.StatsActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -52,11 +54,18 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_services)
|
||||
bindSuggestionsSummary()
|
||||
findPreference<SplitSwitchPreference>(AppSettings.KEY_STATS_ENABLED)?.let {
|
||||
it.onContainerClickListener = Preference.OnPreferenceClickListener {
|
||||
it.context.startActivity(Intent(it.context, StatsActivity::class.java))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindSuggestionsSummary()
|
||||
bindStatsSummary()
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
@@ -77,6 +86,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary()
|
||||
AppSettings.KEY_STATS_ENABLED -> bindStatsSummary()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,4 +205,10 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
|
||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindStatsSummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_STATS_ENABLED)?.setSummary(
|
||||
if (settings.isStatsEnabled) R.string.enabled else R.string.disabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.parsers.util.isNumeric
|
||||
import org.koitharu.kotatsu.parsers.util.md5
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -39,6 +40,7 @@ class ProtectSetupViewModel @Inject constructor(
|
||||
} else {
|
||||
if (firstPassword.value == password) {
|
||||
settings.appPassword = password.md5()
|
||||
settings.isAppPasswordNumeric = password.isNumeric()
|
||||
onPasswordSet.call(Unit)
|
||||
} else {
|
||||
onPasswordMismatch.call(Unit)
|
||||
|
||||
@@ -3,20 +3,15 @@ package org.koitharu.kotatsu.settings.userdata
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
||||
import org.koitharu.kotatsu.core.util.Colors
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -39,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor(
|
||||
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
|
||||
val storageSegment = SegmentedBarView.Segment(
|
||||
usage?.savedManga?.percent ?: 0f,
|
||||
Colors.segmentColor(context, materialR.attr.colorPrimary),
|
||||
KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
|
||||
)
|
||||
val pagesSegment = SegmentedBarView.Segment(
|
||||
usage?.pagesCache?.percent ?: 0f,
|
||||
Colors.segmentColor(context, materialR.attr.colorSecondary),
|
||||
KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
|
||||
)
|
||||
val otherSegment = SegmentedBarView.Segment(
|
||||
usage?.otherCache?.percent ?: 0f,
|
||||
Colors.segmentColor(context, materialR.attr.colorTertiary),
|
||||
KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
|
||||
)
|
||||
|
||||
with(binding) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class SplitSwitchPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = androidx.preference.R.attr.switchPreferenceCompatStyle,
|
||||
defStyleRes: Int = 0
|
||||
) : SwitchPreferenceCompat(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
init {
|
||||
layoutResource = R.layout.preference_split_switch
|
||||
}
|
||||
|
||||
var onContainerClickListener: OnPreferenceClickListener? = null
|
||||
|
||||
private val containerClickListener = View.OnClickListener { v ->
|
||||
onContainerClickListener?.onPreferenceClick(this)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
holder.findViewById(R.id.press_container)?.setOnClickListener(containerClickListener)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.koitharu.kotatsu.stats.data
|
||||
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.room.Dao
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryWithManga
|
||||
|
||||
@Dao
|
||||
abstract class StatsDao {
|
||||
|
||||
@Query("SELECT * FROM stats ORDER BY started_at")
|
||||
abstract suspend fun findAll(): List<StatsEntity>
|
||||
|
||||
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
|
||||
abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
|
||||
|
||||
@Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId")
|
||||
abstract suspend fun getReadPagesCount(mangaId: Long): Int
|
||||
|
||||
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId")
|
||||
abstract suspend fun getAverageTimePerPage(mangaId: Long): Long
|
||||
|
||||
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats")
|
||||
abstract suspend fun getAverageTimePerPage(): Long
|
||||
|
||||
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
|
||||
abstract suspend fun getReadingTime(mangaId: Long): Long
|
||||
|
||||
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
|
||||
abstract suspend fun getTotalReadingTime(): Long
|
||||
|
||||
@Query("DELETE FROM stats")
|
||||
abstract suspend fun clear()
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entity: StatsEntity)
|
||||
|
||||
suspend fun getDurationStats(fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set<Long>): Map<MangaEntity, Long> {
|
||||
val conditions = ArrayList<String>()
|
||||
conditions.add("stats.started_at >= $fromDate")
|
||||
if (favouriteCategories.isNotEmpty()) {
|
||||
val ids = favouriteCategories.joinToString(",")
|
||||
conditions.add("stats.manga_id IN (SELECT manga_id FROM favourites WHERE category_id IN ($ids))")
|
||||
}
|
||||
if (isNsfw != null) {
|
||||
val flag = if (isNsfw) 1 else 0
|
||||
conditions.add("manga.nsfw = $flag")
|
||||
}
|
||||
val where = conditions.joinToString(separator = " AND ")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT manga.*, SUM(duration) AS d FROM stats LEFT JOIN manga ON manga.manga_id = stats.manga_id WHERE $where GROUP BY manga.manga_id ORDER BY d DESC",
|
||||
)
|
||||
return getDurationStatsImpl(query)
|
||||
}
|
||||
|
||||
@RawQuery
|
||||
protected abstract fun getDurationStatsImpl(
|
||||
query: SupportSQLiteQuery
|
||||
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.stats.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
|
||||
@Entity(
|
||||
tableName = "stats",
|
||||
primaryKeys = ["manga_id", "started_at"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = HistoryEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class StatsEntity(
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "started_at") val startedAt: Long,
|
||||
@ColumnInfo(name = "duration") val duration: Long,
|
||||
@ColumnInfo(name = "pages") val pages: Int,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.koitharu.kotatsu.stats.data
|
||||
|
||||
import androidx.collection.LongIntMap
|
||||
import androidx.collection.MutableLongIntMap
|
||||
import androidx.room.withTransaction
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.stats.domain.StatsPeriod
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import java.util.NavigableMap
|
||||
import java.util.TreeMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class StatsRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
suspend fun getReadingStats(period: StatsPeriod, categories: Set<Long>): List<StatsRecord> {
|
||||
val fromDate = if (period == StatsPeriod.ALL) {
|
||||
0L
|
||||
} else {
|
||||
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong())
|
||||
}
|
||||
val stats = db.getStatsDao().getDurationStats(fromDate, null, categories)
|
||||
val result = ArrayList<StatsRecord>(stats.size)
|
||||
var other = StatsRecord(null, 0)
|
||||
val total = stats.values.sum()
|
||||
for ((mangaEntity, duration) in stats) {
|
||||
val manga = mangaEntity.toManga(emptySet())
|
||||
val percent = duration.toDouble() / total
|
||||
if (percent < 0.05) {
|
||||
other = other.copy(duration = other.duration + duration)
|
||||
} else {
|
||||
result += StatsRecord(
|
||||
manga = manga,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (other.duration != 0L) {
|
||||
result += other
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction {
|
||||
val dao = db.getStatsDao()
|
||||
val pages = dao.getReadPagesCount(mangaId)
|
||||
val time = if (pages >= 10) {
|
||||
dao.getAverageTimePerPage(mangaId)
|
||||
} else {
|
||||
dao.getAverageTimePerPage()
|
||||
}
|
||||
time
|
||||
}
|
||||
|
||||
suspend fun getTotalPagesRead(mangaId: Long): Int {
|
||||
return db.getStatsDao().getReadPagesCount(mangaId)
|
||||
}
|
||||
|
||||
suspend fun getMangaTimeline(mangaId: Long): NavigableMap<Long, Int> {
|
||||
val entities = db.getStatsDao().findAll(mangaId)
|
||||
val map = TreeMap<Long, Int>()
|
||||
for (e in entities) {
|
||||
map[e.startedAt] = e.pages
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
suspend fun clearStats() {
|
||||
db.getStatsDao().clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.koitharu.kotatsu.stats.domain
|
||||
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import dagger.hilt.android.ViewModelLifecycle
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
import javax.inject.Inject
|
||||
|
||||
@ViewModelScoped
|
||||
class StatsCollector @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
) {
|
||||
|
||||
private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle)
|
||||
private val stats = LongSparseArray<Entry>(1)
|
||||
|
||||
@Synchronized
|
||||
fun onStateChanged(mangaId: Long, state: ReaderState) {
|
||||
if (!settings.isStatsEnabled) {
|
||||
return
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val entry = stats[mangaId]
|
||||
if (entry == null) {
|
||||
stats[mangaId] = Entry(
|
||||
state = state,
|
||||
stats = StatsEntity(
|
||||
mangaId = mangaId,
|
||||
startedAt = now,
|
||||
duration = 0,
|
||||
pages = 0,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
val pagesDelta = if (entry.state.page != state.page || entry.state.chapterId != state.chapterId) 1 else 0
|
||||
val newEntry = entry.copy(
|
||||
stats = StatsEntity(
|
||||
mangaId = mangaId,
|
||||
startedAt = entry.stats.startedAt,
|
||||
duration = now - entry.stats.startedAt,
|
||||
pages = entry.stats.pages + pagesDelta,
|
||||
),
|
||||
)
|
||||
stats[mangaId] = newEntry
|
||||
commit(newEntry.stats)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onPause(mangaId: Long) {
|
||||
stats.remove(mangaId)
|
||||
}
|
||||
|
||||
private fun commit(entity: StatsEntity) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
db.getStatsDao().upsert(entity)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Entry(
|
||||
val state: ReaderState,
|
||||
val stats: StatsEntity,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.stats.domain
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
enum class StatsPeriod(
|
||||
@StringRes val titleResId: Int,
|
||||
val days: Int,
|
||||
) {
|
||||
|
||||
DAY(R.string.day, 1),
|
||||
WEEK(R.string.week, 7),
|
||||
MONTH(R.string.month, 30),
|
||||
MONTHS_3(R.string.three_months, 90),
|
||||
ALL(R.string.all_time, Int.MAX_VALUE),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.stats.domain
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
data class StatsRecord(
|
||||
val manga: Manga?,
|
||||
val duration: Long,
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is StatsRecord && other.manga == manga
|
||||
}
|
||||
|
||||
val time: ReadingTime
|
||||
|
||||
init {
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(duration).toInt()
|
||||
time = ReadingTime(
|
||||
minutes = minutes % 60,
|
||||
hours = minutes / 60,
|
||||
isContinue = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
28
app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt
Normal file
28
app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.stats.ui
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.databinding.ItemStatsBinding
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
|
||||
fun statsAD(
|
||||
listener: OnListItemClickListener<Manga>,
|
||||
) = adapterDelegateViewBinding<StatsRecord, StatsRecord, ItemStatsBinding>(
|
||||
{ layoutInflater, parent -> ItemStatsBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v ->
|
||||
listener.onItemClick(item.manga ?: return@setOnClickListener, v)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga)
|
||||
binding.textViewSummary.text = item.time.format(context.resources)
|
||||
binding.imageViewBadge.imageTintList = ColorStateList.valueOf(KotatsuColors.ofManga(context, item.manga))
|
||||
binding.root.isClickable = item.manga != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package org.koitharu.kotatsu.stats.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewStub
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.databinding.ActivityStatsBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.stats.domain.StatsPeriod
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
import org.koitharu.kotatsu.stats.ui.views.PieChartView
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class StatsActivity : BaseActivity<ActivityStatsBinding>(),
|
||||
OnListItemClickListener<Manga>,
|
||||
PieChartView.OnSegmentClickListener,
|
||||
AsyncListDiffer.ListListener<StatsRecord>,
|
||||
ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel: StatsViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityStatsBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val adapter = BaseListAdapter<StatsRecord>()
|
||||
.addDelegate(ListItemType.FEED, statsAD(this))
|
||||
.addListListener(this)
|
||||
viewBinding.recyclerView.adapter = adapter
|
||||
viewBinding.chart.onSegmentClickListener = this
|
||||
viewBinding.stubEmpty.setOnInflateListener(this)
|
||||
viewBinding.chipPeriod.setOnClickListener(this)
|
||||
|
||||
viewModel.isLoading.observe(this) {
|
||||
viewBinding.progressBar.showOrHide(it)
|
||||
}
|
||||
viewModel.period.observe(this) {
|
||||
viewBinding.chipPeriod.setText(it.titleResId)
|
||||
}
|
||||
viewModel.favoriteCategories.observe(this, ::createCategoriesChips)
|
||||
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
|
||||
viewModel.readingStats.observe(this) {
|
||||
val sum = it.sumOf { it.duration }
|
||||
viewBinding.chart.setData(
|
||||
it.map { v ->
|
||||
PieChartView.Segment(
|
||||
value = (v.duration / 1000).toInt(),
|
||||
label = v.manga?.title ?: getString(R.string.other_manga),
|
||||
percent = (v.duration.toDouble() / sum).toFloat(),
|
||||
color = KotatsuColors.ofManga(this, v.manga),
|
||||
tag = v.manga,
|
||||
)
|
||||
},
|
||||
)
|
||||
adapter.emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.chip_period -> showPeriodSelector()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||
val category = buttonView?.tag as? FavouriteCategory ?: return
|
||||
viewModel.setCategoryChecked(category, isChecked)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Manga, view: View) {
|
||||
MangaStatsSheet.show(supportFragmentManager, item)
|
||||
}
|
||||
|
||||
override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) {
|
||||
val manga = segment.tag as? Manga ?: return
|
||||
onItemClick(manga, view)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_stats, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_clear -> {
|
||||
showClearConfirmDialog()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCurrentListChanged(previousList: MutableList<StatsRecord>, currentList: MutableList<StatsRecord>) {
|
||||
val isEmpty = currentList.isEmpty()
|
||||
with(viewBinding) {
|
||||
chart.isGone = isEmpty
|
||||
recyclerView.isGone = isEmpty
|
||||
stubEmpty.isVisible = isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInflate(stub: ViewStub?, inflated: View) {
|
||||
val stubBinding = ItemEmptyStateBinding.bind(inflated)
|
||||
stubBinding.icon.newImageRequest(this, R.drawable.ic_empty_history)?.enqueueWith(coil)
|
||||
stubBinding.textPrimary.setText(R.string.text_empty_holder_primary)
|
||||
stubBinding.textSecondary.setTextAndVisible(R.string.empty_stats_text)
|
||||
stubBinding.buttonRetry.isVisible = false
|
||||
}
|
||||
|
||||
private fun createCategoriesChips(categories: List<FavouriteCategory>) {
|
||||
val container = viewBinding.layoutChips
|
||||
if (container.childCount > 1) {
|
||||
// avoid duplication
|
||||
return
|
||||
}
|
||||
val checkedIds = viewModel.selectedCategories.value
|
||||
for (category in categories) {
|
||||
val chip = Chip(this)
|
||||
val drawable = ChipDrawable.createFromAttributes(this, null, 0, R.style.Widget_Kotatsu_Chip_Filter)
|
||||
chip.setChipDrawable(drawable)
|
||||
chip.text = category.title
|
||||
chip.tag = category
|
||||
chip.isChecked = category.id in checkedIds
|
||||
chip.setOnCheckedChangeListener(this)
|
||||
container.addView(chip)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showClearConfirmDialog() {
|
||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||
.setMessage(R.string.clear_stats_confirm)
|
||||
.setTitle(R.string.clear_stats)
|
||||
.setIcon(R.drawable.ic_delete)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.clear) { _, _ ->
|
||||
viewModel.clear()
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun showPeriodSelector() {
|
||||
val menu = PopupMenu(this, viewBinding.chipPeriod)
|
||||
val selected = viewModel.period.value
|
||||
for ((i, branch) in StatsPeriod.entries.withIndex()) {
|
||||
val item = menu.menu.add(R.id.group_period, Menu.NONE, i, branch.titleResId)
|
||||
item.isCheckable = true
|
||||
item.isChecked = selected.ordinal == i
|
||||
}
|
||||
menu.menu.setGroupCheckable(R.id.group_period, true, true)
|
||||
|
||||
menu.setOnMenuItemClickListener {
|
||||
StatsPeriod.entries.getOrNull(it.order)?.also {
|
||||
viewModel.period.value = it
|
||||
} != null
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.koitharu.kotatsu.stats.ui
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import org.koitharu.kotatsu.stats.domain.StatsPeriod
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StatsViewModel @Inject constructor(
|
||||
private val repository: StatsRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val period = MutableStateFlow(StatsPeriod.WEEK)
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val selectedCategories = MutableStateFlow<Set<Long>>(emptySet())
|
||||
val favoriteCategories = favouritesRepository.observeCategories()
|
||||
.take(1)
|
||||
|
||||
val readingStats = MutableStateFlow<List<StatsRecord>>(emptyList())
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
combine<StatsPeriod, Set<Long>, Pair<StatsPeriod, Set<Long>>>(
|
||||
period,
|
||||
selectedCategories,
|
||||
::Pair,
|
||||
).collectLatest { p ->
|
||||
readingStats.value = withLoading {
|
||||
repository.getReadingStats(p.first, p.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCategoryChecked(category: FavouriteCategory, checked: Boolean) {
|
||||
val snapshot = selectedCategories.value.toMutableSet()
|
||||
if (checked) {
|
||||
snapshot.add(category.id)
|
||||
} else {
|
||||
snapshot.remove(category.id)
|
||||
}
|
||||
selectedCategories.value = snapshot
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
repository.clearStats()
|
||||
readingStats.value = emptyList()
|
||||
onActionDone.call(ReversibleAction(R.string.stats_cleared, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.koitharu.kotatsu.stats.ui.sheet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.collection.IntList
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.stats.ui.views.BarChartView
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>(), View.OnClickListener {
|
||||
|
||||
private val viewModel: MangaStatsViewModel by viewModels()
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetStatsMangaBinding {
|
||||
return SheetStatsMangaBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.textViewTitle.text = viewModel.manga.title
|
||||
binding.chartView.barColor = KotatsuColors.ofManga(binding.root.context, viewModel.manga)
|
||||
viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged)
|
||||
viewModel.startDate.observe(viewLifecycleOwner) {
|
||||
binding.textViewStart.textAndVisible = it?.format(resources)
|
||||
}
|
||||
viewModel.totalPagesRead.observe(viewLifecycleOwner) {
|
||||
binding.textViewPages.text = getString(R.string.pages_read_s, it.format())
|
||||
}
|
||||
binding.buttonOpen.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
startActivity(DetailsActivity.newIntent(v.context, viewModel.manga))
|
||||
}
|
||||
|
||||
private fun onStatsChanged(stats: IntList) {
|
||||
val chartView = viewBinding?.chartView ?: return
|
||||
if (stats.isEmpty()) {
|
||||
chartView.setData(emptyList())
|
||||
return
|
||||
}
|
||||
val bars = ArrayList<BarChartView.Bar>(stats.size)
|
||||
stats.forEach { pages ->
|
||||
bars.add(
|
||||
BarChartView.Bar(
|
||||
value = pages,
|
||||
label = pages.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
chartView.setData(bars)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_MANGA = "manga"
|
||||
|
||||
private const val TAG = "MangaStatsSheet"
|
||||
|
||||
fun show(fm: FragmentManager, manga: Manga) {
|
||||
MangaStatsSheet().withArgs(1) {
|
||||
putParcelable(ARG_MANGA, ParcelableManga(manga))
|
||||
}.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.koitharu.kotatsu.stats.ui.sheet
|
||||
|
||||
import androidx.collection.IntList
|
||||
import androidx.collection.LongIntMap
|
||||
import androidx.collection.MutableIntList
|
||||
import androidx.collection.emptyIntList
|
||||
import androidx.collection.emptyLongIntMap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaStatsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: StatsRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(MangaStatsSheet.ARG_MANGA).manga
|
||||
|
||||
val stats = MutableStateFlow<IntList>(emptyIntList())
|
||||
val startDate = MutableStateFlow<DateTimeAgo?>(null)
|
||||
val totalPagesRead = MutableStateFlow(0)
|
||||
|
||||
init {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val timeline = repository.getMangaTimeline(manga.id)
|
||||
if (timeline.isEmpty()) {
|
||||
startDate.value = null
|
||||
stats.value = emptyIntList()
|
||||
} else {
|
||||
val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey())
|
||||
val endDay = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis())
|
||||
val res = MutableIntList((endDay - startDay).toInt() + 1)
|
||||
for (day in startDay..endDay) {
|
||||
val from = TimeUnit.DAYS.toMillis(day)
|
||||
val to = TimeUnit.DAYS.toMillis(day + 1)
|
||||
res.add(timeline.subMap(from, true, to, false).values.sum())
|
||||
}
|
||||
stats.value = res
|
||||
startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey()))
|
||||
}
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
totalPagesRead.value = repository.getTotalPagesRead(manga.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package org.koitharu.kotatsu.stats.ui.views
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.DashPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PathEffect
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Xfermode
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.collection.MutableIntList
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.minus
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
import androidx.core.view.setPadding
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class BarChartView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rawData = ArrayList<Bar>()
|
||||
private val bars = ArrayList<Bar>()
|
||||
private var maxValue: Int = 0
|
||||
private val minBarSpacing = context.resources.resolveDp(12f)
|
||||
private val minSpace = context.resources.resolveDp(20f)
|
||||
private val barWidth = context.resources.resolveDp(12f)
|
||||
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
|
||||
private val dottedEffect = DashPathEffect(
|
||||
floatArrayOf(
|
||||
context.resources.resolveDp(6f),
|
||||
context.resources.resolveDp(6f),
|
||||
),
|
||||
0f,
|
||||
)
|
||||
private val chartBounds = RectF()
|
||||
|
||||
@ColorInt
|
||||
var barColor: Int = context.getThemeColor(materialR.attr.colorAccent)
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
init {
|
||||
paint.strokeWidth = context.resources.resolveDp(1f)
|
||||
if (isInEditMode) {
|
||||
setData(
|
||||
List(Random.nextInt(20, 60)) {
|
||||
Bar(
|
||||
value = Random.nextInt(-20, 400).coerceAtLeast(0),
|
||||
label = it.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (bars.isEmpty() || chartBounds.isEmpty) {
|
||||
return
|
||||
}
|
||||
val spacing = (chartBounds.width() - (barWidth * bars.size.toFloat())) / (bars.size + 1).toFloat()
|
||||
// dashed horizontal lines
|
||||
paint.color = outlineColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
|
||||
paint.pathEffect = dottedEffect
|
||||
for (i in (0..maxValue).step(computeValueStep())) {
|
||||
val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat())
|
||||
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint)
|
||||
}
|
||||
// bottom line
|
||||
paint.color = outlineColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
|
||||
// bars
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = barColor
|
||||
paint.pathEffect = null
|
||||
val corner = barWidth / 2f
|
||||
for ((i, bar) in bars.withIndex()) {
|
||||
if (bar.value == 0) {
|
||||
continue
|
||||
}
|
||||
val h = (chartBounds.height() * bar.value / maxValue.toFloat()).coerceAtLeast(barWidth)
|
||||
val x = spacing + i * (barWidth + spacing) + paddingLeft
|
||||
canvas.drawRoundRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, corner, corner, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
invalidateBounds()
|
||||
}
|
||||
|
||||
fun setData(value: List<Bar>) {
|
||||
rawData.replaceWith(value)
|
||||
compressBars()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun compressBars() {
|
||||
if (rawData.isEmpty() || width <= 0) {
|
||||
maxValue = 0
|
||||
bars.clear()
|
||||
return
|
||||
}
|
||||
var fullWidth = rawData.size * (barWidth + minBarSpacing) + minBarSpacing
|
||||
val windowSize = (fullWidth / width.toFloat()).toIntUp()
|
||||
bars.replaceWith(
|
||||
rawData.chunked(windowSize) { it.average() },
|
||||
)
|
||||
maxValue = bars.maxOf { it.value }
|
||||
}
|
||||
|
||||
private fun computeValueStep(): Int {
|
||||
val h = chartBounds.height()
|
||||
var step = 1
|
||||
while (h / (maxValue / step).toFloat() <= minSpace) {
|
||||
step++
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
private fun invalidateBounds() {
|
||||
val inset = paint.strokeWidth
|
||||
chartBounds.set(
|
||||
paddingLeft.toFloat() + inset,
|
||||
paddingTop.toFloat() + inset,
|
||||
(width - paddingLeft - paddingRight).toFloat() - inset,
|
||||
(height - paddingTop - paddingBottom).toFloat() - inset,
|
||||
)
|
||||
compressBars()
|
||||
}
|
||||
|
||||
private fun Collection<Bar>.average(): Bar {
|
||||
return when (size) {
|
||||
0 -> Bar(0, "")
|
||||
1 -> first()
|
||||
else -> Bar(
|
||||
value = (sumOf { it.value } / size.toFloat()).roundToInt(),
|
||||
label = "%s - %s".format(first().label, last().label),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Bar(
|
||||
val value: Int,
|
||||
val label: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package org.koitharu.kotatsu.stats.ui.views
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Xfermode
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.collection.MutableIntList
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.minus
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.sqrt
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class PieChartView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr), GestureDetector.OnGestureListener {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val segments = ArrayList<Segment>()
|
||||
private val chartBounds = RectF()
|
||||
private val clearColor = context.getThemeColor(android.R.attr.colorBackground)
|
||||
private val touchDetector = GestureDetectorCompat(context, this)
|
||||
private var hightlightedSegment = -1
|
||||
|
||||
var onSegmentClickListener: OnSegmentClickListener? = null
|
||||
|
||||
init {
|
||||
touchDetector.setIsLongpressEnabled(false)
|
||||
paint.strokeWidth = context.resources.resolveDp(2f)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
var angle = 0f
|
||||
for ((i, segment) in segments.withIndex()) {
|
||||
paint.color = segment.color
|
||||
if (i == hightlightedSegment) {
|
||||
paint.color = ColorUtils.setAlphaComponent(paint.color, 180)
|
||||
}
|
||||
paint.style = Paint.Style.FILL
|
||||
val sweepAngle = segment.percent * 360f
|
||||
canvas.drawArc(
|
||||
chartBounds,
|
||||
angle,
|
||||
sweepAngle,
|
||||
true,
|
||||
paint,
|
||||
)
|
||||
paint.color = clearColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawArc(
|
||||
chartBounds,
|
||||
angle,
|
||||
sweepAngle,
|
||||
true,
|
||||
paint,
|
||||
)
|
||||
angle += sweepAngle
|
||||
}
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = clearColor
|
||||
canvas.drawCircle(chartBounds.centerX(), chartBounds.centerY(), chartBounds.height() / 4f, paint)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
val size = minOf(w, h).toFloat()
|
||||
val inset = paint.strokeWidth
|
||||
chartBounds.set(inset, inset, size - inset, size - inset)
|
||||
chartBounds.offset(
|
||||
(w - size) / 2f,
|
||||
(h - size) / 2f,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
hightlightedSegment = -1
|
||||
invalidate()
|
||||
}
|
||||
return super.onTouchEvent(event) || touchDetector.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
if (onSegmentClickListener == null) {
|
||||
return false
|
||||
}
|
||||
val segment = findSegmentIndex(e.x, e.y)
|
||||
if (segment != hightlightedSegment) {
|
||||
hightlightedSegment = segment
|
||||
invalidate()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShowPress(e: MotionEvent) = Unit
|
||||
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
onSegmentClickListener?.run {
|
||||
val segment = segments.getOrNull(findSegmentIndex(e.x, e.y))
|
||||
if (segment != null) {
|
||||
onSegmentClick(this@PieChartView, segment)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean = false
|
||||
|
||||
override fun onLongPress(e: MotionEvent) = Unit
|
||||
|
||||
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false
|
||||
|
||||
fun setData(value: List<Segment>) {
|
||||
segments.replaceWith(value)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun findSegmentIndex(x: Float, y: Float): Int {
|
||||
val dy = (y - chartBounds.centerY()).toDouble()
|
||||
val dx = (x - chartBounds.centerX()).toDouble()
|
||||
val distance = sqrt(dx * dx + dy * dy).toFloat()
|
||||
if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) {
|
||||
return -1
|
||||
}
|
||||
var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat()
|
||||
if (touchAngle < 0) {
|
||||
touchAngle += 360
|
||||
}
|
||||
var angle = 0f
|
||||
for ((i, segment) in segments.withIndex()) {
|
||||
val sweepAngle = segment.percent * 360f
|
||||
if (touchAngle in angle..(angle + sweepAngle)) {
|
||||
return i
|
||||
}
|
||||
angle += sweepAngle
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
class Segment(
|
||||
val value: Int,
|
||||
val label: String,
|
||||
val percent: Float,
|
||||
val color: Int,
|
||||
val tag: Any?,
|
||||
)
|
||||
|
||||
interface OnSegmentClickListener {
|
||||
|
||||
fun onSegmentClick(view: PieChartView, segment: Segment)
|
||||
}
|
||||
}
|
||||
108
app/src/main/res/layout/activity_stats.xml
Normal file
108
app/src/main/res/layout/activity_stats.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_scrollFlags="noScroll">
|
||||
|
||||
</com.google.android.material.appbar.MaterialToolbar>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:hideAnimationBehavior="outward"
|
||||
app:layout_constraintBottom_toBottomOf="@id/appbar"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/appbar"
|
||||
app:showAnimationBehavior="inward"
|
||||
app:trackCornerRadius="0dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/scrollView_chips"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:scrollbars="none"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/appbar">
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/layout_chips"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_period"
|
||||
style="@style/Widget.Kotatsu.Chip.Dropdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/week"
|
||||
app:chipIcon="@drawable/ic_history" />
|
||||
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
</HorizontalScrollView>
|
||||
|
||||
<org.koitharu.kotatsu.stats.ui.views.PieChartView
|
||||
android:id="@+id/chart"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_margin="24dp"
|
||||
app:layout_constraintDimensionRatio="1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/scrollView_chips" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/chart"
|
||||
tools:itemCount="4"
|
||||
tools:listitem="@layout/item_stats" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/stub_empty"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout="@layout/item_empty_state"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/scrollView_chips"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
52
app/src/main/res/layout/item_stats.xml
Normal file
52
app/src/main/res/layout/item_stats.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/list_selector"
|
||||
android:clipChildren="false"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?listPreferredItemHeightSmall"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_badge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@null"
|
||||
app:srcCompat="@drawable/bg_rounded_square" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
74
app/src/main/res/layout/preference_split_switch.xml
Normal file
74
app/src/main/res/layout/preference_split_switch.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:baselineAligned="false"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||
tools:ignore="RtlSymmetry">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/press_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="?selectableItemBackground"
|
||||
android:baselineAligned="false"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?android:attr/listPreferredItemHeightSmall"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart">
|
||||
|
||||
<include layout="@layout/image_frame" />
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem" />
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/summary"
|
||||
style="@style/PreferenceSummaryTextStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@android:id/title"
|
||||
android:layout_alignStart="@android:id/title"
|
||||
android:layout_gravity="start"
|
||||
android:maxLines="10"
|
||||
android:textAlignment="viewStart"
|
||||
android:textColor="?android:attr/textColorSecondary" />
|
||||
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:background="?dividerVertical" />
|
||||
|
||||
<!-- Preference should place its actual preference widget here. -->
|
||||
<LinearLayout
|
||||
android:id="@android:id/widget_frame"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="end|center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="0dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="@dimen/margin_small"
|
||||
android:paddingBottom="@dimen/margin_normal">
|
||||
|
||||
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||
android:id="@+id/dragHandle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_alignParentEnd="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignWithParentIfMissing="true"
|
||||
android:layout_below="@id/dragHandle"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_toStartOf="@id/textView_label"
|
||||
android:paddingHorizontal="@dimen/margin_small"
|
||||
android:paddingBottom="@dimen/margin_small"
|
||||
android:singleLine="true"
|
||||
android:text="@string/grid_size"
|
||||
android:textAppearance="?textAppearanceTitleMedium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignBaseline="@id/textView_title"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:paddingHorizontal="@dimen/margin_small"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceLabelLarge"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button_small"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_alignTop="@id/slider_grid"
|
||||
android:layout_alignBottom="@id/slider_grid"
|
||||
android:layout_alignParentStart="true"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_size_small"
|
||||
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button_large"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_alignTop="@id/slider_grid"
|
||||
android:layout_alignBottom="@id/slider_grid"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_size_large"
|
||||
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider_grid"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/textView_title"
|
||||
android:layout_toStartOf="@id/button_large"
|
||||
android:layout_toEndOf="@id/button_small"
|
||||
android:stepSize="5"
|
||||
android:valueFrom="50"
|
||||
android:valueTo="150"
|
||||
app:labelBehavior="gone"
|
||||
app:tickVisible="false"
|
||||
tools:value="100" />
|
||||
|
||||
</RelativeLayout>
|
||||
82
app/src/main/res/layout/sheet_stats_manga.xml
Normal file
82
app/src/main/res/layout/sheet_stats_manga.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/screen_padding">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
|
||||
android:id="@+id/headerBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:title="@string/reading_stats" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollIndicators="top">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="@dimen/screen_padding">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?textAppearanceTitleMedium"
|
||||
tools:text="@tools:sample/lorem[4]" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_open"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:minWidth="?minTouchTargetSize"
|
||||
android:minHeight="?minTouchTargetSize"
|
||||
app:srcCompat="@drawable/ic_open_external" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<org.koitharu.kotatsu.stats.ui.views.BarChartView
|
||||
android:id="@+id/chartView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="240dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:paddingHorizontal="@dimen/screen_padding" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_start"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:paddingHorizontal="@dimen/screen_padding"
|
||||
android:textAppearance="?textAppearanceLabelSmall"
|
||||
tools:text="Week ago" />
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_pages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:paddingHorizontal="@dimen/screen_padding"
|
||||
android:textAppearance="?textAppearanceBodyMedium"
|
||||
tools:text="Total pages read: 250" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -37,6 +37,12 @@
|
||||
android:title="@string/tracking"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_stats"
|
||||
android:orderInCategory="50"
|
||||
android:title="@string/statistics"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_related"
|
||||
android:orderInCategory="50"
|
||||
|
||||
@@ -9,4 +9,10 @@
|
||||
android:title="@string/clear_history"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_stats"
|
||||
android:orderInCategory="40"
|
||||
android:title="@string/statistics"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
|
||||
13
app/src/main/res/menu/opt_stats.xml
Normal file
13
app/src/main/res/menu/opt_stats.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_clear"
|
||||
android:title="@string/clear_stats"
|
||||
android:titleCondensed="@string/clear"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
@@ -134,31 +134,6 @@
|
||||
<attr name="cornerSize" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="PieChart">
|
||||
<attr name="pieChartColors" format="reference" />
|
||||
|
||||
<attr name="pieChartMarginTextFirst" format="dimension" />
|
||||
<attr name="pieChartMarginTextSecond" format="dimension" />
|
||||
<attr name="pieChartMarginTextThird" format="dimension" />
|
||||
<attr name="pieChartMarginSmallCircle" format="dimension" />
|
||||
|
||||
<attr name="pieChartCircleStrokeWidth" format="dimension" />
|
||||
<attr name="pieChartCirclePadding" format="dimension" />
|
||||
<attr name="pieChartCirclePaintRoundSize" format="boolean" />
|
||||
<attr name="pieChartCircleSectionSpace" format="float" />
|
||||
|
||||
<attr name="pieChartTextCircleRadius" format="dimension" />
|
||||
<attr name="pieChartTextAmountSize" format="dimension" />
|
||||
<attr name="pieChartTextNumberSize" format="dimension" />
|
||||
<attr name="pieChartTextDescriptionSize" format="dimension" />
|
||||
|
||||
<attr name="pieChartTextAmountColor" format="color" />
|
||||
<attr name="pieChartTextNumberColor" format="color" />
|
||||
<attr name="pieChartTextDescriptionColor" format="color" />
|
||||
|
||||
<attr name="pieChartTextAmount" format="string" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="NestedRecyclerView">
|
||||
<attr name="maxHeight" />
|
||||
</declare-styleable>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<item name="fast_scroller" type="id" />
|
||||
<item name="group_branches" type="id" />
|
||||
<item name="layout_tip" type="id" />
|
||||
<item name="group_period" type="id" />
|
||||
<!-- Navigation -->
|
||||
<item name="nav_history" type="id" />
|
||||
<item name="nav_favorites" type="id" />
|
||||
|
||||
@@ -603,4 +603,19 @@
|
||||
<string name="automatic">Automatic</string>
|
||||
<string name="single_cbz_file">Single CBZ file</string>
|
||||
<string name="multiple_cbz_files">Multiple CBZ files</string>
|
||||
<string name="stats_enabled">Enable statistics</string>
|
||||
<string name="reading_stats">Reading statistics</string>
|
||||
<string name="other_manga">Other manga</string>
|
||||
<string name="less_than_minute">Less than a minute</string>
|
||||
<string name="statistics">Statistics</string>
|
||||
<string name="clear_stats">Clear statistics</string>
|
||||
<string name="stats_cleared">Statistics cleared</string>
|
||||
<string name="clear_stats_confirm">Do you really want to clear all reading statistics? This action cannot be undone.</string>
|
||||
<string name="week">Week</string>
|
||||
<string name="month">Month</string>
|
||||
<string name="all_time">All time</string>
|
||||
<string name="day">Day</string>
|
||||
<string name="three_months">Three months</string>
|
||||
<string name="empty_stats_text">There are no statistics for the selected period</string>
|
||||
<string name="pages_read_s">Pages read: %s</string>
|
||||
</resources>
|
||||
|
||||
@@ -124,6 +124,12 @@
|
||||
|
||||
<style name="Widget.Kotatsu.Chip.Assist" parent="Widget.Material3.Chip.Assist" />
|
||||
|
||||
<style name="Widget.Kotatsu.Chip.Dropdown" parent="Widget.Material3.Chip.Assist">
|
||||
<item name="closeIconVisible">true</item>
|
||||
<item name="closeIcon">@drawable/ic_expand_more</item>
|
||||
<item name="chipIconVisible">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Kotatsu.Button.More" parent="Widget.Material3.Button.TextButton">
|
||||
<item name="android:minWidth">48dp</item>
|
||||
</style>
|
||||
|
||||
@@ -48,12 +48,6 @@
|
||||
|
||||
<PreferenceCategory android:title="@string/details">
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
android:key="reading_time"
|
||||
android:summary="@string/reading_time_estimation_summary"
|
||||
android:title="@string/reading_time_estimation" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="0"
|
||||
android:entries="@array/details_tabs"
|
||||
|
||||
@@ -28,6 +28,18 @@
|
||||
android:summary="@string/related_manga_summary"
|
||||
android:title="@string/related_manga" />
|
||||
|
||||
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:key="stats_on"
|
||||
android:title="@string/reading_stats"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
android:key="reading_time"
|
||||
android:summary="@string/reading_time_estimation_summary"
|
||||
android:title="@string/reading_time_estimation" />
|
||||
|
||||
<PreferenceCategory android:title="@string/tracking">
|
||||
|
||||
<Preference
|
||||
|
||||
@@ -73,6 +73,7 @@ class JsonSerializerTest {
|
||||
scroll = 24.0f,
|
||||
percent = 0.6f,
|
||||
deletedAt = 0L,
|
||||
chaptersCount = 12,
|
||||
)
|
||||
val json = JsonSerializer(entity).toJson()
|
||||
val result = JsonDeserializer(json).toHistoryEntity()
|
||||
|
||||
Reference in New Issue
Block a user