Compare commits
8 Commits
feature/st
...
v6.7.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2bff78f7 | ||
|
|
4f2c38d4ee | ||
|
|
3c54fe4217 | ||
|
|
750bf11fdc | ||
|
|
b2c5ec5082 | ||
|
|
f97d4d452f | ||
|
|
640fe272c8 | ||
|
|
f730e80bb7 |
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 626
|
versionCode = 627
|
||||||
versionName = '6.7.4'
|
versionName = '6.7.5'
|
||||||
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:103f578c61') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:b7613606c0') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,9 +239,6 @@
|
|||||||
<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,6 +14,7 @@ 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
|
||||||
@@ -80,11 +81,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
val url = viewBinding.webView.url?.toUriOrNull()
|
||||||
intent.data = Uri.parse(viewBinding.webView.url)
|
if (url != null) {
|
||||||
try {
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
startActivity(Intent.createChooser(intent, item.title))
|
intent.data = url
|
||||||
} catch (_: ActivityNotFoundException) {
|
try {
|
||||||
|
startActivity(Intent.createChooser(intent, item.title))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ 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,7 +41,6 @@ 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,7 +30,6 @@ 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
|
||||||
@@ -49,22 +48,20 @@ 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 = 19
|
const val DATABASE_VERSION = 18
|
||||||
|
|
||||||
@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, StatsEntity::class,
|
ScrobblingEntity::class, MangaSourceEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -93,8 +90,6 @@ 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(
|
||||||
@@ -115,7 +110,6 @@ 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
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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,9 +422,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -617,7 +614,8 @@ 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,9 +29,8 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
|
fun addListListener(listListener: ListListener<T>) {
|
||||||
differ.addListListener(listListener)
|
differ.addListListener(listListener)
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeListListener(listListener: ListListener<T>) {
|
fun removeListListener(listListener: ListListener<T>) {
|
||||||
|
|||||||
@@ -68,13 +68,6 @@ 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,10 +12,12 @@ 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.KotatsuColors
|
import org.koitharu.kotatsu.core.util.Colors
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
class FaviconDrawable(
|
class FaviconDrawable(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -43,7 +45,7 @@ class FaviconDrawable(
|
|||||||
}
|
}
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
colorForeground = MaterialColors.harmonize(Colors.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) // TODO remove
|
chip.setEnsureMinTouchTargetSize(false)
|
||||||
chip.setOnClickListener(chipOnClickListener)
|
chip.setOnClickListener(chipOnClickListener)
|
||||||
addView(chip)
|
addView(chip)
|
||||||
return chip
|
return chip
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
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,10 +7,9 @@ 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 KotatsuColors {
|
object Colors {
|
||||||
|
|
||||||
@ColorInt
|
@ColorInt
|
||||||
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
||||||
@@ -21,24 +20,11 @@ object KotatsuColors {
|
|||||||
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,7 +10,6 @@ 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,27 +5,25 @@ 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,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
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 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
|
||||||
if (isOnHistoryBranch) {
|
if (isOnHistoryBranch) {
|
||||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||||
}
|
}
|
||||||
@@ -38,16 +36,4 @@ 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,10 +138,7 @@ class DetailsActivity :
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
viewModel.onActionDone.observeEvent(
|
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom))
|
||||||
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) {
|
||||||
@@ -153,7 +150,6 @@ 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,7 +23,6 @@ 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,
|
||||||
@@ -44,7 +43,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -103,12 +101,6 @@ 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,10 +100,6 @@ 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,5 +28,4 @@ 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,7 +94,6 @@ 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(
|
||||||
@@ -106,7 +105,6 @@ 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,9 +11,7 @@ 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
|
||||||
@@ -29,7 +27,6 @@ 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,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -10,7 +9,6 @@ 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
|
||||||
@@ -26,11 +24,6 @@ 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 -> {
|
||||||
@@ -38,11 +31,6 @@ class HistoryListMenuProvider(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_stats -> {
|
|
||||||
context.startActivity(Intent(context, StatsActivity::class.java))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,12 +71,6 @@ 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,11 +2,18 @@ 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
|
||||||
@@ -14,13 +21,16 @@ 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.KotatsuColors
|
import org.koitharu.kotatsu.core.util.Colors
|
||||||
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
|
||||||
@@ -57,7 +67,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 = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary),
|
color = Colors.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,6 +14,7 @@ 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)
|
||||||
@@ -25,6 +26,7 @@ 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)
|
||||||
@@ -33,17 +35,28 @@ class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addLast(id: Long, newPages: List<ReaderPage>) {
|
@Synchronized
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addFirst(id: Long, newPages: List<ReaderPage>) {
|
@Synchronized
|
||||||
|
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()
|
||||||
@@ -58,7 +71,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) = indices.contains(chapterId)
|
operator fun contains(chapterId: Long) = chapterId in indices
|
||||||
|
|
||||||
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,11 +176,6 @@ 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,7 +58,6 @@ 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
|
||||||
|
|
||||||
@@ -79,11 +78,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
|
||||||
@@ -99,7 +98,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 (savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) == true) {
|
val incognitoMode = if (isIncognito) {
|
||||||
MutableStateFlow(true)
|
MutableStateFlow(true)
|
||||||
} else mangaFlow.map {
|
} else mangaFlow.map {
|
||||||
it != null && historyRepository.shouldSkip(it)
|
it != null && historyRepository.shouldSkip(it)
|
||||||
@@ -191,12 +190,6 @@ 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())
|
||||||
@@ -215,7 +208,7 @@ class ReaderViewModel @Inject constructor(
|
|||||||
if (state != null) {
|
if (state != null) {
|
||||||
currentState.value = state
|
currentState.value = state
|
||||||
}
|
}
|
||||||
if (incognitoMode.value) {
|
if (isIncognito) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val readerState = state ?: currentState.value ?: return
|
val readerState = state ?: currentState.value ?: return
|
||||||
@@ -384,7 +377,7 @@ class ReaderViewModel @Inject constructor(
|
|||||||
|
|
||||||
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
|
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
|
||||||
// save state
|
// save state
|
||||||
if (!incognitoMode.value) {
|
if (!isIncognito) {
|
||||||
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)
|
||||||
@@ -433,9 +426,6 @@ 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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -16,7 +17,6 @@ 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,8 +29,6 @@ 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
|
||||||
@@ -54,18 +52,11 @@ 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)
|
||||||
findPreference<SplitSwitchPreference>(AppSettings.KEY_STATS_ENABLED)?.let {
|
bindSuggestionsSummary()
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +77,6 @@ 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +195,4 @@ 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,15 +3,20 @@ 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.KotatsuColors
|
import org.koitharu.kotatsu.core.util.Colors
|
||||||
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
|
||||||
|
|
||||||
@@ -34,15 +39,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,
|
||||||
KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
|
Colors.segmentColor(context, materialR.attr.colorPrimary),
|
||||||
)
|
)
|
||||||
val pagesSegment = SegmentedBarView.Segment(
|
val pagesSegment = SegmentedBarView.Segment(
|
||||||
usage?.pagesCache?.percent ?: 0f,
|
usage?.pagesCache?.percent ?: 0f,
|
||||||
KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
|
Colors.segmentColor(context, materialR.attr.colorSecondary),
|
||||||
)
|
)
|
||||||
val otherSegment = SegmentedBarView.Segment(
|
val otherSegment = SegmentedBarView.Segment(
|
||||||
usage?.otherCache?.percent ?: 0f,
|
usage?.otherCache?.percent ?: 0f,
|
||||||
KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
|
Colors.segmentColor(context, materialR.attr.colorTertiary),
|
||||||
)
|
)
|
||||||
|
|
||||||
with(binding) {
|
with(binding) {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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.2" android:color="?attr/colorPrimaryInverse" />
|
<item android:alpha="0.27" android:color="?attr/colorOnSurface" />
|
||||||
</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.2" android:color="@color/kotatsu_inversePrimary" />
|
<item android:alpha="0.27" android:color="@color/kotatsu_onSurface" />
|
||||||
</selector>
|
</selector>
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<?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" />
|
||||||
|
|
||||||
<TextView
|
<org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
|
||||||
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"
|
||||||
|
|||||||
83
app/src/main/res/layout/sheet_shelf_size.xml
Normal file
83
app/src/main/res/layout/sheet_shelf_size.xml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?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>
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?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,12 +37,6 @@
|
|||||||
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,10 +9,4 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?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,6 +134,31 @@
|
|||||||
<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,7 +6,6 @@
|
|||||||
<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,19 +603,4 @@
|
|||||||
<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,12 +124,6 @@
|
|||||||
|
|
||||||
<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,6 +48,12 @@
|
|||||||
|
|
||||||
<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,18 +28,6 @@
|
|||||||
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,7 +73,6 @@ 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.2.2'
|
classpath 'com.android.tools.build:gradle:8.3.0'
|
||||||
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.4-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Reference in New Issue
Block a user