Compare commits
16 Commits
v6.7.5
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1a2fcf77 | ||
|
|
876675445d | ||
|
|
f7a70680bd | ||
|
|
8e82db441c | ||
|
|
f2626c668d | ||
|
|
4694215ccc | ||
|
|
096f5b15dc | ||
|
|
101d357eff | ||
|
|
11cd5609bb | ||
|
|
fda59996aa | ||
|
|
20461112d2 | ||
|
|
f98bb87d6e | ||
|
|
c451952a1e | ||
|
|
35a2ac4b04 | ||
|
|
f39ccb6223 | ||
|
|
6cb6c891dd |
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 627
|
versionCode = 626
|
||||||
versionName = '6.7.5'
|
versionName = '6.7.4'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:b7613606c0') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:103f578c61') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,9 @@
|
|||||||
<data android:scheme="kotatsu+kitsu" />
|
<data android:scheme="kotatsu+kitsu" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
|
||||||
|
android:label="@string/reading_stats" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.core.view.isVisible
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
@@ -81,14 +80,11 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
val url = viewBinding.webView.url?.toUriOrNull()
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
if (url != null) {
|
intent.data = Uri.parse(viewBinding.webView.url)
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
try {
|
||||||
intent.data = url
|
startActivity(Intent.createChooser(intent, item.title))
|
||||||
try {
|
} catch (_: ActivityNotFoundException) {
|
||||||
startActivity(Intent.createChooser(intent, item.title))
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
page = json.getInt("page"),
|
page = json.getInt("page"),
|
||||||
scroll = json.getDouble("scroll").toFloat(),
|
scroll = json.getDouble("scroll").toFloat(),
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
percent = json.getFloatOrDefault("percent", -1f),
|
||||||
|
chaptersCount = json.getIntOrDefault("chapters", -1),
|
||||||
deletedAt = 0L,
|
deletedAt = 0L,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
put("page", e.page)
|
put("page", e.page)
|
||||||
put("scroll", e.scroll)
|
put("scroll", e.scroll)
|
||||||
put("percent", e.percent)
|
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.Migration15To16
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
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.Migration1To2
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
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.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
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.SuggestionDao
|
||||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 18
|
const val DATABASE_VERSION = 19
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||||
ScrobblingEntity::class, MangaSourceEntity::class,
|
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -90,6 +93,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getScrobblingDao(): ScrobblingDao
|
abstract fun getScrobblingDao(): ScrobblingDao
|
||||||
|
|
||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract fun getSourcesDao(): MangaSourcesDao
|
||||||
|
|
||||||
|
abstract fun getStatsDao(): StatsDao
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -110,6 +115,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration15To16(),
|
Migration15To16(),
|
||||||
Migration16To17(context),
|
Migration16To17(context),
|
||||||
Migration17To18(),
|
Migration17To18(),
|
||||||
|
Migration18To19(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
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 )")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,6 +422,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isPagesSavingAskEnabled: Boolean
|
val isPagesSavingAskEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
|
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
|
||||||
|
|
||||||
|
val isStatsEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
|
||||||
|
|
||||||
fun isTipEnabled(tip: String): Boolean {
|
fun isTipEnabled(tip: String): Boolean {
|
||||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||||
}
|
}
|
||||||
@@ -614,8 +617,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_READING_TIME = "reading_time"
|
const val KEY_READING_TIME = "reading_time"
|
||||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||||
|
const val KEY_STATS_ENABLED = "stats_on"
|
||||||
// About
|
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
if (isAmoledTheme) {
|
if (isAmoledTheme) {
|
||||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||||
}
|
}
|
||||||
putDataToExtras(intent)
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
insetsDelegate.handleImeInsets = true
|
insetsDelegate.handleImeInsets = true
|
||||||
insetsDelegate.addInsetsListener(this)
|
insetsDelegate.addInsetsListener(this)
|
||||||
|
putDataToExtras(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addListListener(listListener: ListListener<T>) {
|
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
|
||||||
differ.addListListener(listListener)
|
differ.addListListener(listListener)
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeListListener(listListener: ListListener<T>) {
|
fun removeListListener(listListener: ListListener<T>) {
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
errorEvent.call(error)
|
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>.increment() = update { it + 1 }
|
||||||
|
|
||||||
protected fun MutableStateFlow<Int>.decrement() = 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 android.graphics.drawable.Drawable
|
||||||
import androidx.annotation.StyleRes
|
import androidx.annotation.StyleRes
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.graphics.ColorUtils
|
|
||||||
import androidx.core.graphics.withClip
|
import androidx.core.graphics.withClip
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.Colors
|
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
|
||||||
class FaviconDrawable(
|
class FaviconDrawable(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -45,7 +43,7 @@ class FaviconDrawable(
|
|||||||
}
|
}
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
colorForeground = MaterialColors.harmonize(Colors.random(name), colorBackground)
|
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas) {
|
override fun draw(canvas: Canvas) {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
chip.isChipIconVisible = false
|
chip.isChipIconVisible = false
|
||||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||||
chip.setEnsureMinTouchTargetSize(false)
|
chip.setEnsureMinTouchTargetSize(false) // TODO remove
|
||||||
chip.setOnClickListener(chipOnClickListener)
|
chip.setOnClickListener(chipOnClickListener)
|
||||||
addView(chip)
|
addView(chip)
|
||||||
return 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.R
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
object Colors {
|
object KotatsuColors {
|
||||||
|
|
||||||
@ColorInt
|
@ColorInt
|
||||||
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
||||||
@@ -20,11 +21,24 @@ object Colors {
|
|||||||
return MaterialColors.harmonize(color, backgroundColor)
|
return MaterialColors.harmonize(color, backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
fun random(seed: Any): Int {
|
fun random(seed: Any): Int {
|
||||||
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
|
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
|
||||||
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
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 {
|
private fun getHue(hex: String): Float {
|
||||||
val r = (hex.substring(0, 2).toInt(16)).toFloat()
|
val r = (hex.substring(0, 2).toInt(16)).toFloat()
|
||||||
val g = (hex.substring(2, 4).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 {
|
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)
|
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
|
||||||
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
|
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
|
||||||
else -> resources.getString(
|
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.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||||
|
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ReadingTimeUseCase @Inject constructor(
|
class ReadingTimeUseCase @Inject constructor(
|
||||||
private val settings: AppSettings,
|
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) {
|
if (!settings.isReadingTimeEstimationEnabled) {
|
||||||
return null
|
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)
|
val chapters = manga?.chapters?.get(branch)
|
||||||
if (chapters.isNullOrEmpty()) {
|
if (chapters.isNullOrEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
|
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
|
||||||
// Impossible task, I guess. Good luck on this.
|
// 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) {
|
if (isOnHistoryBranch) {
|
||||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||||
}
|
}
|
||||||
@@ -36,4 +38,16 @@ class ReadingTimeUseCase @Inject constructor(
|
|||||||
isContinue = isOnHistoryBranch,
|
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.onShowTip.observeEvent(this) { showTip() }
|
||||||
viewModel.historyInfo.observe(this, ::onHistoryChanged)
|
viewModel.historyInfo.observe(this, ::onHistoryChanged)
|
||||||
viewModel.selectedBranch.observe(this) {
|
viewModel.selectedBranch.observe(this) {
|
||||||
@@ -150,6 +153,7 @@ class DetailsActivity :
|
|||||||
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
|
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
|
||||||
val menuInvalidator = MenuInvalidator(this)
|
val menuInvalidator = MenuInvalidator(this)
|
||||||
viewModel.favouriteCategories.observe(this, menuInvalidator)
|
viewModel.favouriteCategories.observe(this, menuInvalidator)
|
||||||
|
viewModel.isStatsEnabled.observe(this, menuInvalidator)
|
||||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||||
viewModel.branches.observe(this) {
|
viewModel.branches.observe(this) {
|
||||||
viewBinding.buttonDropdown.isVisible = it.size > 1
|
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.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||||
|
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||||
|
|
||||||
class DetailsMenuProvider(
|
class DetailsMenuProvider(
|
||||||
private val activity: FragmentActivity,
|
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_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
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_online).isVisible = viewModel.remoteManga.value != null
|
||||||
|
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsEnabled.value
|
||||||
menu.findItem(R.id.action_favourite).setIcon(
|
menu.findItem(R.id.action_favourite).setIcon(
|
||||||
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
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 -> {
|
R.id.action_scrobbling -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
|
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ class DetailsViewModel @Inject constructor(
|
|||||||
val favouriteCategories = interactor.observeIsFavourite(mangaId)
|
val favouriteCategories = interactor.observeIsFavourite(mangaId)
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
.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 remoteManga = MutableStateFlow<Manga?>(null)
|
||||||
|
|
||||||
val newChaptersCount = details.flatMapLatest { d ->
|
val newChaptersCount = details.flatMapLatest { d ->
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
childColumns = ["manga_id"],
|
childColumns = ["manga_id"],
|
||||||
onDelete = ForeignKey.CASCADE,
|
onDelete = ForeignKey.CASCADE,
|
||||||
)
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
data class HistoryEntity(
|
data class HistoryEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@@ -28,4 +28,5 @@ data class HistoryEntity(
|
|||||||
@ColumnInfo(name = "scroll") val scroll: Float,
|
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||||
@ColumnInfo(name = "percent") val percent: Float,
|
@ColumnInfo(name = "percent") val percent: Float,
|
||||||
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
@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)) {
|
if (!force && shouldSkip(manga)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assert(manga.chapters != null)
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
mangaRepository.storeManga(manga)
|
mangaRepository.storeManga(manga)
|
||||||
db.getHistoryDao().upsert(
|
db.getHistoryDao().upsert(
|
||||||
@@ -105,6 +106,7 @@ class HistoryRepository @Inject constructor(
|
|||||||
page = page,
|
page = page,
|
||||||
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
|
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
|
||||||
percent = percent,
|
percent = percent,
|
||||||
|
chaptersCount = manga.chapters?.size ?: -1,
|
||||||
deletedAt = 0L,
|
deletedAt = 0L,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.os.NetworkManageIntent
|
import org.koitharu.kotatsu.core.os.NetworkManageIntent
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
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.addMenuProvider
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||||
@@ -27,6 +29,7 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
RecyclerScrollKeeper(binding.recyclerView).attach()
|
RecyclerScrollKeeper(binding.recyclerView).attach()
|
||||||
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
|
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
|
||||||
|
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = Unit
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.history.ui
|
package org.koitharu.kotatsu.history.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -9,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
|
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||||
|
import org.koitharu.kotatsu.stats.ui.StatsActivity
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -24,6 +26,11 @@ class HistoryListMenuProvider(
|
|||||||
menuInflater.inflate(R.menu.opt_history, menu)
|
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 {
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
return when (menuItem.itemId) {
|
return when (menuItem.itemId) {
|
||||||
R.id.action_clear_history -> {
|
R.id.action_clear_history -> {
|
||||||
@@ -31,6 +38,11 @@ class HistoryListMenuProvider(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_stats -> {
|
||||||
|
context.startActivity(Intent(context, StatsActivity::class.java))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ class HistoryListViewModel @Inject constructor(
|
|||||||
g && s.isGroupingSupported()
|
g && s.isGroupingSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isStatsEnabled = settings.observeAsStateFlow(
|
||||||
|
scope = viewModelScope + Dispatchers.Default,
|
||||||
|
key = AppSettings.KEY_STATS_ENABLED,
|
||||||
|
valueProducer = { isStatsEnabled },
|
||||||
|
)
|
||||||
|
|
||||||
override val content = combine(
|
override val content = combine(
|
||||||
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
||||||
isGroupingEnabled,
|
isGroupingEnabled,
|
||||||
|
|||||||
@@ -2,18 +2,11 @@ package org.koitharu.kotatsu.local.ui.info
|
|||||||
|
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
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.core.widget.TextViewCompat
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import com.google.android.material.color.MaterialColors
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.combine
|
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.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
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.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.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
|
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.settings.userdata.StorageUsage
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -67,7 +57,7 @@ class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
|
|||||||
val total = size + available
|
val total = size + available
|
||||||
val segment = SegmentedBarView.Segment(
|
val segment = SegmentedBarView.Segment(
|
||||||
percent = (size.toDouble() / total.toDouble()).toFloat(),
|
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(
|
requireViewBinding().labelUsed.text = view.context.getString(
|
||||||
R.string.memory_usage_pattern,
|
R.string.memory_usage_pattern,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
|||||||
val chaptersSize: Int
|
val chaptersSize: Int
|
||||||
get() = indices.size()
|
get() = indices.size()
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeFirst() {
|
fun removeFirst() {
|
||||||
val chapterId = pages.first().chapterId
|
val chapterId = pages.first().chapterId
|
||||||
indices.remove(chapterId)
|
indices.remove(chapterId)
|
||||||
@@ -26,7 +25,6 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
|||||||
shiftIndices(delta)
|
shiftIndices(delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeLast() {
|
fun removeLast() {
|
||||||
val chapterId = pages.last().chapterId
|
val chapterId = pages.last().chapterId
|
||||||
indices.remove(chapterId)
|
indices.remove(chapterId)
|
||||||
@@ -35,28 +33,17 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun addLast(id: Long, newPages: List<ReaderPage>) {
|
||||||
fun addLast(id: Long, newPages: List<ReaderPage>): Boolean {
|
|
||||||
if (id in indices) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
indices.put(id, pages.size until (pages.size + newPages.size))
|
indices.put(id, pages.size until (pages.size + newPages.size))
|
||||||
pages.addAll(newPages)
|
pages.addAll(newPages)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun addFirst(id: Long, newPages: List<ReaderPage>) {
|
||||||
fun addFirst(id: Long, newPages: List<ReaderPage>): Boolean {
|
|
||||||
if (id in indices) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
shiftIndices(newPages.size)
|
shiftIndices(newPages.size)
|
||||||
indices.put(id, newPages.indices)
|
indices.put(id, newPages.indices)
|
||||||
pages.addAll(0, newPages)
|
pages.addAll(0, newPages)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
indices.clear()
|
indices.clear()
|
||||||
pages.clear()
|
pages.clear()
|
||||||
@@ -71,7 +58,7 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
|||||||
return pages.subList(range.first, range.last + 1)
|
return pages.subList(range.first, range.last + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun contains(chapterId: Long) = chapterId in indices
|
operator fun contains(chapterId: Long) = indices.contains(chapterId)
|
||||||
|
|
||||||
private fun shiftIndices(delta: Int) {
|
private fun shiftIndices(delta: Int) {
|
||||||
for (i in 0 until indices.size()) {
|
for (i in 0 until indices.size()) {
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ class ReaderActivity :
|
|||||||
idlingDetector.onUserInteraction()
|
idlingDetector.onUserInteraction()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
viewModel.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onIdle() {
|
override fun onIdle() {
|
||||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
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.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||||
|
import org.koitharu.kotatsu.stats.domain.StatsCollector
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -78,11 +79,11 @@ class ReaderViewModel @Inject constructor(
|
|||||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||||
private val historyUpdateUseCase: HistoryUpdateUseCase,
|
private val historyUpdateUseCase: HistoryUpdateUseCase,
|
||||||
private val detectReaderModeUseCase: DetectReaderModeUseCase,
|
private val detectReaderModeUseCase: DetectReaderModeUseCase,
|
||||||
|
private val statsCollector: StatsCollector,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private val intent = MangaIntent(savedStateHandle)
|
private val intent = MangaIntent(savedStateHandle)
|
||||||
private val preselectedBranch = savedStateHandle.get<String>(ReaderActivity.EXTRA_BRANCH)
|
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 loadingJob: Job? = null
|
||||||
private var pageSaveJob: Job? = null
|
private var pageSaveJob: Job? = null
|
||||||
@@ -98,7 +99,7 @@ class ReaderViewModel @Inject constructor(
|
|||||||
val onShowToast = MutableEventFlow<Int>()
|
val onShowToast = MutableEventFlow<Int>()
|
||||||
val uiState = MutableStateFlow<ReaderUiState?>(null)
|
val uiState = MutableStateFlow<ReaderUiState?>(null)
|
||||||
|
|
||||||
val incognitoMode = if (isIncognito) {
|
val incognitoMode = if (savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) == true) {
|
||||||
MutableStateFlow(true)
|
MutableStateFlow(true)
|
||||||
} else mangaFlow.map {
|
} else mangaFlow.map {
|
||||||
it != null && historyRepository.shouldSkip(it)
|
it != null && historyRepository.shouldSkip(it)
|
||||||
@@ -190,6 +191,12 @@ class ReaderViewModel @Inject constructor(
|
|||||||
loadImpl()
|
loadImpl()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onPause() {
|
||||||
|
manga?.let {
|
||||||
|
statsCollector.onPause(it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun switchMode(newMode: ReaderMode) {
|
fun switchMode(newMode: ReaderMode) {
|
||||||
launchJob {
|
launchJob {
|
||||||
val manga = checkNotNull(mangaData.value?.toManga())
|
val manga = checkNotNull(mangaData.value?.toManga())
|
||||||
@@ -208,7 +215,7 @@ class ReaderViewModel @Inject constructor(
|
|||||||
if (state != null) {
|
if (state != null) {
|
||||||
currentState.value = state
|
currentState.value = state
|
||||||
}
|
}
|
||||||
if (isIncognito) {
|
if (incognitoMode.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val readerState = state ?: currentState.value ?: return
|
val readerState = state ?: currentState.value ?: return
|
||||||
@@ -377,7 +384,7 @@ class ReaderViewModel @Inject constructor(
|
|||||||
|
|
||||||
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
|
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
|
||||||
// save state
|
// save state
|
||||||
if (!isIncognito) {
|
if (!incognitoMode.value) {
|
||||||
currentState.value?.let {
|
currentState.value?.let {
|
||||||
val percent = computePercent(it.chapterId, it.page)
|
val percent = computePercent(it.chapterId, it.page)
|
||||||
historyUpdateUseCase.invoke(manga, it, percent)
|
historyUpdateUseCase.invoke(manga, it, percent)
|
||||||
@@ -426,6 +433,9 @@ class ReaderViewModel @Inject constructor(
|
|||||||
percent = computePercent(state.chapterId, state.page),
|
percent = computePercent(state.chapterId, state.page),
|
||||||
)
|
)
|
||||||
uiState.value = newState
|
uiState.value = newState
|
||||||
|
if (!incognitoMode.value) {
|
||||||
|
statsCollector.onStateChanged(m.id, state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
|
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
import android.accounts.AccountManager
|
import android.accounts.AccountManager
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
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.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
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.core.util.ext.viewLifecycleScope
|
||||||
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
|
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
|
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.sync.ui.SyncSettingsIntent
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -52,11 +54,18 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
|
|||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
addPreferencesFromResource(R.xml.pref_services)
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
bindSuggestionsSummary()
|
||||||
|
bindStatsSummary()
|
||||||
settings.subscribe(this)
|
settings.subscribe(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +86,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
|
|||||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||||
when (key) {
|
when (key) {
|
||||||
AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary()
|
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,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,15 @@ package org.koitharu.kotatsu.settings.userdata
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import androidx.annotation.AttrRes
|
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.graphics.ColorUtils
|
|
||||||
import androidx.core.widget.TextViewCompat
|
import androidx.core.widget.TextViewCompat
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceViewHolder
|
import androidx.preference.PreferenceViewHolder
|
||||||
import com.google.android.material.color.MaterialColors
|
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
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.FileSize
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
|
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -39,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor(
|
|||||||
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
|
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
|
||||||
val storageSegment = SegmentedBarView.Segment(
|
val storageSegment = SegmentedBarView.Segment(
|
||||||
usage?.savedManga?.percent ?: 0f,
|
usage?.savedManga?.percent ?: 0f,
|
||||||
Colors.segmentColor(context, materialR.attr.colorPrimary),
|
KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
|
||||||
)
|
)
|
||||||
val pagesSegment = SegmentedBarView.Segment(
|
val pagesSegment = SegmentedBarView.Segment(
|
||||||
usage?.pagesCache?.percent ?: 0f,
|
usage?.pagesCache?.percent ?: 0f,
|
||||||
Colors.segmentColor(context, materialR.attr.colorSecondary),
|
KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
|
||||||
)
|
)
|
||||||
val otherSegment = SegmentedBarView.Segment(
|
val otherSegment = SegmentedBarView.Segment(
|
||||||
usage?.otherCache?.percent ?: 0f,
|
usage?.otherCache?.percent ?: 0f,
|
||||||
Colors.segmentColor(context, materialR.attr.colorTertiary),
|
KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
|
||||||
)
|
)
|
||||||
|
|
||||||
with(binding) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:alpha="0.27" android:color="?attr/colorOnSurface" />
|
<item android:alpha="0.2" android:color="?attr/colorPrimaryInverse" />
|
||||||
</selector>
|
</selector>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
|
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
|
||||||
<item android:alpha="0.27" android:color="@color/kotatsu_onSurface" />
|
<item android:alpha="0.2" android:color="@color/kotatsu_inversePrimary" />
|
||||||
</selector>
|
</selector>
|
||||||
|
|||||||
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>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
app:barrierMargin="8dp"
|
app:barrierMargin="8dp"
|
||||||
app:constraint_referenced_ids="imageView_cover,spinner_status" />
|
app:constraint_referenced_ids="imageView_cover,spinner_status" />
|
||||||
|
|
||||||
<org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
|
<TextView
|
||||||
android:id="@+id/textView_description"
|
android:id="@+id/textView_description"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -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"
|
android:title="@string/tracking"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_stats"
|
||||||
|
android:orderInCategory="50"
|
||||||
|
android:title="@string/statistics"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_related"
|
android:id="@+id/action_related"
|
||||||
android:orderInCategory="50"
|
android:orderInCategory="50"
|
||||||
|
|||||||
@@ -9,4 +9,10 @@
|
|||||||
android:title="@string/clear_history"
|
android:title="@string/clear_history"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_stats"
|
||||||
|
android:orderInCategory="40"
|
||||||
|
android:title="@string/statistics"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
</menu>
|
</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" />
|
<attr name="cornerSize" />
|
||||||
</declare-styleable>
|
</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">
|
<declare-styleable name="NestedRecyclerView">
|
||||||
<attr name="maxHeight" />
|
<attr name="maxHeight" />
|
||||||
</declare-styleable>
|
</declare-styleable>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<item name="fast_scroller" type="id" />
|
<item name="fast_scroller" type="id" />
|
||||||
<item name="group_branches" type="id" />
|
<item name="group_branches" type="id" />
|
||||||
<item name="layout_tip" type="id" />
|
<item name="layout_tip" type="id" />
|
||||||
|
<item name="group_period" type="id" />
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<item name="nav_history" type="id" />
|
<item name="nav_history" type="id" />
|
||||||
<item name="nav_favorites" type="id" />
|
<item name="nav_favorites" type="id" />
|
||||||
|
|||||||
@@ -603,4 +603,19 @@
|
|||||||
<string name="automatic">Automatic</string>
|
<string name="automatic">Automatic</string>
|
||||||
<string name="single_cbz_file">Single CBZ file</string>
|
<string name="single_cbz_file">Single CBZ file</string>
|
||||||
<string name="multiple_cbz_files">Multiple CBZ files</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>
|
</resources>
|
||||||
|
|||||||
@@ -124,6 +124,12 @@
|
|||||||
|
|
||||||
<style name="Widget.Kotatsu.Chip.Assist" parent="Widget.Material3.Chip.Assist" />
|
<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">
|
<style name="Widget.Kotatsu.Button.More" parent="Widget.Material3.Button.TextButton">
|
||||||
<item name="android:minWidth">48dp</item>
|
<item name="android:minWidth">48dp</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,12 +48,6 @@
|
|||||||
|
|
||||||
<PreferenceCategory android:title="@string/details">
|
<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
|
<ListPreference
|
||||||
android:defaultValue="0"
|
android:defaultValue="0"
|
||||||
android:entries="@array/details_tabs"
|
android:entries="@array/details_tabs"
|
||||||
|
|||||||
@@ -28,6 +28,18 @@
|
|||||||
android:summary="@string/related_manga_summary"
|
android:summary="@string/related_manga_summary"
|
||||||
android:title="@string/related_manga" />
|
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">
|
<PreferenceCategory android:title="@string/tracking">
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class JsonSerializerTest {
|
|||||||
scroll = 24.0f,
|
scroll = 24.0f,
|
||||||
percent = 0.6f,
|
percent = 0.6f,
|
||||||
deletedAt = 0L,
|
deletedAt = 0L,
|
||||||
|
chaptersCount = 12,
|
||||||
)
|
)
|
||||||
val json = JsonSerializer(entity).toJson()
|
val json = JsonSerializer(entity).toJson()
|
||||||
val result = JsonDeserializer(json).toHistoryEntity()
|
val result = JsonDeserializer(json).toHistoryEntity()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.3.0'
|
classpath 'com.android.tools.build:gradle:8.2.2'
|
||||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'
|
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'
|
||||||
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.51'
|
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.51'
|
||||||
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.22-1.0.17'
|
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.22-1.0.17'
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -2,6 +2,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae
|
distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Reference in New Issue
Block a user