diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt index a74d09a3b7..62b3c43555 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt @@ -3,23 +3,6 @@ package io.github.landwarderer.futon.backups.data import androidx.collection.ArrayMap import androidx.room.withTransaction import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerializationStrategy -import kotlinx.serialization.json.DecodeSequenceMode -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeToSequence -import kotlinx.serialization.json.encodeToStream -import kotlinx.serialization.serializer -import org.json.JSONArray -import org.json.JSONObject import io.github.landwarderer.futon.backups.data.model.BackupIndex import io.github.landwarderer.futon.backups.data.model.BookmarkBackup import io.github.landwarderer.futon.backups.data.model.CategoryBackup @@ -37,8 +20,25 @@ import io.github.landwarderer.futon.core.util.progress.Progress import io.github.landwarderer.futon.explore.data.MangaSourcesRepository import io.github.landwarderer.futon.filter.data.PersistableFilter import io.github.landwarderer.futon.filter.data.SavedFiltersRepository -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.reader.data.TapGridSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.collectIndexed +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.DecodeSequenceMode +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeToSequence +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.serializer +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.InputStream import java.io.OutputStream import java.util.zip.ZipEntry @@ -151,6 +151,7 @@ class BackupRepository @Inject constructor( input: ZipInputStream, sections: Set, progress: FlowCollector?, + isMerge: Boolean = false, ): CompositeResult { progress?.emit(Progress.INDETERMINATE) var commonProgress = Progress(0, sections.size) @@ -176,12 +177,12 @@ class BackupRepository @Inject constructor( } BackupSection.SETTINGS -> input.readMap().let { - settings.upsertAll(it) + settings.upsertAll(it, isMerge) CompositeResult.success() } BackupSection.SETTINGS_READER_GRID -> input.readMap().let { - tapGridSettings.upsertAll(it) + tapGridSettings.upsertAll(it, isMerge) CompositeResult.success() } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreDialogFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreDialogFragment.kt index 496a42b325..f8966b48c5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreDialogFragment.kt @@ -10,7 +10,6 @@ import androidx.core.view.isVisible import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.combine import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.ui.AlertDialogFragment @@ -20,6 +19,7 @@ import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.core.util.ext.observeEvent import io.github.landwarderer.futon.core.util.ext.textAndVisible import io.github.landwarderer.futon.databinding.DialogRestoreBinding +import kotlinx.coroutines.flow.combine import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -47,8 +47,12 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis viewModel.isLoading, viewModel.availableEntries, viewModel.backupDate, - ::Triple, + viewModel.isMergeEnabled, + ::Quadruple, ).observe(viewLifecycleOwner, this::onLoadingChanged) + binding.checkboxMerge.setOnCheckedChangeListener { _, isChecked -> + viewModel.onMergeToggle(isChecked) + } } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -76,12 +80,14 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis viewModel.onItemClick(item) } - private fun onLoadingChanged(value: Triple, Date?>) { - val (isLoading, entries, backupDate) = value + private fun onLoadingChanged(value: Quadruple, Date?, Boolean>) { + val (isLoading, entries, backupDate, isMergeEnabled) = value val hasEntries = entries.isNotEmpty() with(requireViewBinding()) { progressBar.isVisible = isLoading recyclerView.isGone = isLoading + checkboxMerge.isVisible = !isLoading && hasEntries + checkboxMerge.isChecked = isMergeEnabled textViewSubtitle.textAndVisible = when { !isLoading -> backupDate?.formatBackupDate() @@ -97,6 +103,7 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis context ?: return false, viewModel.uri ?: return false, viewModel.getCheckedSections(), + viewModel.isMergeEnabled.value, ) } @@ -115,4 +122,11 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis .show() dismiss() } + + data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D, + ) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreService.kt b/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreService.kt index ab2dd70986..39ded260b4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreService.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreService.kt @@ -50,6 +50,7 @@ class RestoreService : BaseBackupRestoreService() { val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException() val sections = requireNotNull(intent.getSerializableExtraCompat>(AppRouter.KEY_ENTRIES)?.toSet()) + val isMerge = intent.getBooleanExtra(KEY_MERGE, false) powerManager.withPartialWakeLock(TAG) { val progress = MutableStateFlow(Progress.INDETERMINATE) val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) { @@ -62,7 +63,7 @@ class RestoreService : BaseBackupRestoreService() { null } val result = ZipInputStream(contentResolver.openInputStream(source)).use { input -> - repository.restoreBackup(input, sections, progress) + repository.restoreBackup(input, sections, progress, isMerge) } progressUpdateJob?.cancelAndJoin() showResultNotification(source, result) @@ -102,12 +103,14 @@ class RestoreService : BaseBackupRestoreService() { private const val TAG = "RESTORE" private const val FOREGROUND_NOTIFICATION_ID = 39 + private const val KEY_MERGE = "merge" @CheckResult - fun start(context: Context, uri: Uri, sections: Set): Boolean = try { + fun start(context: Context, uri: Uri, sections: Set, isMerge: Boolean): Boolean = try { val intent = Intent(context, RestoreService::class.java) intent.putExtra(AppRouter.KEY_DATA, uri.toString()) intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray()) + intent.putExtra(KEY_MERGE, isMerge) ContextCompat.startForegroundService(context, intent) true } catch (e: Exception) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreViewModel.kt index 952dd8c5b0..1310539286 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/backups/ui/restore/RestoreViewModel.kt @@ -34,6 +34,7 @@ class RestoreViewModel @Inject constructor( val availableEntries = MutableStateFlow>(emptyList()) val backupDate = MutableStateFlow(null) + val isMergeEnabled = MutableStateFlow(false) init { launchLoadingJob(Dispatchers.IO) { @@ -80,6 +81,10 @@ class RestoreViewModel @Inject constructor( availableEntries.value = map.values.sortedBy { it.section.ordinal } } + fun onMergeToggle(isChecked: Boolean) { + isMergeEnabled.value = isChecked + } + fun getCheckedSections(): Set = availableEntries.value .mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) { if (it.isChecked) it.section else null diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt index af8f7926a6..777b0a6fc2 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt @@ -651,8 +651,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { fun getAllValues(): Map = prefs.all - fun upsertAll(m: Map) = prefs.edit { - clear() + fun upsertAll(m: Map, isMerge: Boolean = false) = prefs.edit { + if (!isMerge) { + clear() + } putAll(m) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/data/TapGridSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/data/TapGridSettings.kt index 82e4e40ac4..06908574f3 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/data/TapGridSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/data/TapGridSettings.kt @@ -5,14 +5,14 @@ import android.content.SharedPreferences import androidx.core.content.edit import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn import io.github.landwarderer.futon.core.util.ext.getEnumValue import io.github.landwarderer.futon.core.util.ext.observeChanges import io.github.landwarderer.futon.core.util.ext.putAll import io.github.landwarderer.futon.core.util.ext.putEnumValue import io.github.landwarderer.futon.reader.domain.TapGridArea import io.github.landwarderer.futon.reader.ui.tapgrid.TapAction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn import javax.inject.Inject @Reusable @@ -48,8 +48,10 @@ class TapGridSettings @Inject constructor(@ApplicationContext context: Context) fun getAllValues(): Map = prefs.all - fun upsertAll(m: Map) = prefs.edit { - clear() + fun upsertAll(m: Map, isMerge: Boolean = false) = prefs.edit { + if (!isMerge) { + clear() + } putAll(m) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsDao.kt index aec12c53fa..ce5afed87c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsDao.kt @@ -7,12 +7,11 @@ import androidx.room.RawQuery import androidx.room.Upsert import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery +import io.github.landwarderer.futon.core.db.entity.MangaEntity import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive -import io.github.landwarderer.futon.core.db.entity.MangaEntity -import kotlin.collections.forEach @Dao abstract class StatsDao { @@ -66,6 +65,40 @@ abstract class StatsDao { query: SupportSQLiteQuery ): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long> + suspend fun getTagDurationStats( + fromDate: Long, + isNsfw: Boolean?, + favouriteCategories: Set + ): Map { + val conditions = ArrayList() + conditions.add("(SELECT deleted_at FROM history WHERE history.manga_id = stats.manga_id) = 0") + 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 tags.title AS tag_name, SUM(stats.duration) AS d " + + "FROM stats " + + "LEFT JOIN manga ON manga.manga_id = stats.manga_id " + + "LEFT JOIN manga_tags ON manga_tags.manga_id = manga.manga_id " + + "LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id " + + "WHERE $where AND tags.title IS NOT NULL " + + "GROUP BY tags.tag_id ORDER BY d DESC", + ) + return getTagDurationStatsImpl(query) + } + + @RawQuery + protected abstract suspend fun getTagDurationStatsImpl( + query: SupportSQLiteQuery + ): Map<@MapColumn("tag_name") String, @MapColumn("d") Long> + @Query("SELECT * FROM stats ORDER BY started_at LIMIT :limit OFFSET :offset") protected abstract suspend fun findAll(offset: Int, limit: Int): List fun dumpEnabled(): Flow = flow { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsRepository.kt index cf4e870d0f..f841190069 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/stats/data/StatsRepository.kt @@ -1,17 +1,17 @@ package io.github.landwarderer.futon.stats.data import androidx.room.withTransaction -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import io.github.landwarderer.futon.core.db.MangaDatabase import io.github.landwarderer.futon.core.db.entity.toManga import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.observeAsFlow import io.github.landwarderer.futon.stats.domain.StatsPeriod import io.github.landwarderer.futon.stats.domain.StatsRecord +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import java.util.NavigableMap import java.util.TreeMap import java.util.concurrent.TimeUnit @@ -22,15 +22,41 @@ class StatsRepository @Inject constructor( private val db: MangaDatabase, ) { - suspend fun getReadingStats(period: StatsPeriod, categories: Set): List { + suspend fun getReadingStats( + period: StatsPeriod, + categories: Set, + byGenre: Boolean = false, + ): List { val fromDate = if (period == StatsPeriod.ALL) { 0L } else { System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong()) } + if (byGenre) { + val stats = db.getStatsDao().getTagDurationStats(fromDate, null, categories) + val total = stats.values.sum() + val result = ArrayList(stats.size) + var other = StatsRecord(manga = null, tagName = null, duration = 0) + for ((tagName, duration) in stats) { + val percent = duration.toDouble() / total + if (percent < 0.05) { + other = other.copy(duration = other.duration + duration) + } else { + result += StatsRecord( + manga = null, + tagName = tagName, + duration = duration, + ) + } + } + if (other.duration != 0L) { + result += other + } + return result + } val stats = db.getStatsDao().getDurationStats(fromDate, null, categories) val result = ArrayList(stats.size) - var other = StatsRecord(null, 0) + var other = StatsRecord(manga = null, tagName = null, duration = 0) val total = stats.values.sum() for ((mangaEntity, duration) in stats) { val manga = mangaEntity.toManga(emptySet(), null) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/stats/domain/StatsRecord.kt b/app/src/main/kotlin/io/github/landwarderer/futon/stats/domain/StatsRecord.kt index 24909f6cec..0ab06ef9c0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/stats/domain/StatsRecord.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/stats/domain/StatsRecord.kt @@ -7,11 +7,12 @@ import java.util.concurrent.TimeUnit data class StatsRecord( val manga: Manga?, + val tagName: String? = null, val duration: Long, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { - return other is StatsRecord && other.manga == manga + return other is StatsRecord && other.manga == manga && other.tagName == tagName } val time: ReadingTime diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsAD.kt index fae4a98a71..691f19a274 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsAD.kt @@ -6,8 +6,8 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.ui.list.OnListItemClickListener import io.github.landwarderer.futon.core.util.FutonColors import io.github.landwarderer.futon.databinding.ItemStatsBinding -import org.koitharu.kotatsu.parsers.model.Manga import io.github.landwarderer.futon.stats.domain.StatsRecord +import org.koitharu.kotatsu.parsers.model.Manga fun statsAD( listener: OnListItemClickListener, @@ -16,11 +16,11 @@ fun statsAD( ) { binding.root.setOnClickListener { v -> - listener.onItemClick(item.manga ?: return@setOnClickListener, v) + item.manga?.let { listener.onItemClick(it, v) } } bind { - binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga) + binding.textViewTitle.text = item.manga?.title ?: item.tagName ?: getString(R.string.other_manga) binding.textViewSummary.text = item.time.format(context.resources) binding.imageViewBadge.imageTintList = ColorStateList.valueOf(FutonColors.ofManga(context, item.manga)) binding.root.isClickable = item.manga != null diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsActivity.kt index 47f7b8d3b9..c82d6c79d5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsActivity.kt @@ -37,189 +37,214 @@ import io.github.landwarderer.futon.core.util.ext.start import io.github.landwarderer.futon.databinding.ActivityStatsBinding import io.github.landwarderer.futon.databinding.ItemEmptyStateBinding import io.github.landwarderer.futon.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.parsers.model.Manga import io.github.landwarderer.futon.stats.domain.StatsPeriod import io.github.landwarderer.futon.stats.domain.StatsRecord import io.github.landwarderer.futon.stats.ui.views.PieChartView +import org.koitharu.kotatsu.parsers.model.Manga @AndroidEntryPoint class StatsActivity : BaseActivity(), - OnListItemClickListener, - PieChartView.OnSegmentClickListener, - AsyncListDiffer.ListListener, - ViewStub.OnInflateListener, - View.OnClickListener, - CompoundButton.OnCheckedChangeListener { - - private val viewModel: StatsViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityStatsBinding.inflate(layoutInflater)) - setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) - val adapter = BaseListAdapter() - .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 = FutonColors.ofManga(this, v.manga), - tag = v.manga, - ) - }, - ) - adapter.emit(it) - } - } - - override fun onApplyWindowInsets( - v: View, - insets: WindowInsetsCompat - ): WindowInsetsCompat { - val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - val isTablet = viewBinding.guidelineCenter != null - viewBinding.appbar.updatePaddingRelative( - start = bars.start(v), - top = bars.top, - end = if (isTablet) 0 else bars.end(v), - ) - val badgePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_large) - viewBinding.scrollViewChips.updatePaddingRelative( - start = badgePadding + if (isTablet) 0 else bars.start(v), - end = badgePadding + bars.end(v), - top = if (isTablet) bars.top else 0, - ) - viewBinding.recyclerView.updatePaddingRelative( - start = if (isTablet) 0 else bars.start(v), - end = bars.end(v), - bottom = bars.bottom, - ) - viewBinding.chart.updateLayoutParams { - val baseMargin = topMargin - bottomMargin = if (isTablet) baseMargin + bars.bottom else baseMargin - marginStart = baseMargin + bars.start(v) - marginEnd = if (isTablet) baseMargin else baseMargin + bars.end(v) - } - return WindowInsetsCompat.Builder(insets) - .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) - .build() - } - - 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) { - router.showStatisticSheet(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, currentList: MutableList) { - 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.setImageAsync(R.drawable.ic_empty_history) - 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) { - 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_Futon_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() { - buildAlertDialog(this, isCentered = true) { - setMessage(R.string.clear_stats_confirm) - setTitle(R.string.clear_stats) - setIcon(R.drawable.ic_delete_all) - setNegativeButton(android.R.string.cancel, null) - setPositiveButton(R.string.clear) { _, _ -> viewModel.clearStats() } - }.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() - } + OnListItemClickListener, + PieChartView.OnSegmentClickListener, + AsyncListDiffer.ListListener, + ViewStub.OnInflateListener, + View.OnClickListener, + CompoundButton.OnCheckedChangeListener { + + private val viewModel: StatsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityStatsBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + val adapter = BaseListAdapter() + .addDelegate(ListItemType.FEED, statsAD(this)) + .addListListener(this) + viewBinding.recyclerView.adapter = adapter + viewBinding.chart.onSegmentClickListener = this + viewBinding.stubEmpty.setOnInflateListener(this) + viewBinding.chipPeriod.setOnClickListener(this) + viewBinding.chipType.setOnClickListener(this) + + viewModel.isLoading.observe(this) { + viewBinding.progressBar.showOrHide(it) + } + viewModel.period.observe(this) { + viewBinding.chipPeriod.setText(it.titleResId) + } + viewModel.byGenre.observe(this) { + viewBinding.chipType.setText(if (it) R.string.genres else R.string.manga) + } + viewModel.favoriteCategories.observe(this, ::createCategoriesChips) + viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) + viewModel.readingStats.observe(this) { records -> + val sum = records.sumOf { it.duration } + viewBinding.chart.setData( + records.map { v -> + PieChartView.Segment( + value = (v.duration / 1000).toInt(), + label = v.manga?.title ?: v.tagName ?: getString(R.string.other_manga), + percent = (v.duration.toDouble() / sum).toFloat(), + color = FutonColors.ofManga(this, v.manga), + tag = v.manga, + ) + }, + ) + adapter.emit(records) + } + } + + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val isTablet = viewBinding.guidelineCenter != null + viewBinding.appbar.updatePaddingRelative( + start = bars.start(v), + top = bars.top, + end = if (isTablet) 0 else bars.end(v), + ) + val badgePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_large) + viewBinding.scrollViewChips.updatePaddingRelative( + start = badgePadding + if (isTablet) 0 else bars.start(v), + end = badgePadding + bars.end(v), + top = if (isTablet) bars.top else 0, + ) + viewBinding.recyclerView.updatePaddingRelative( + start = if (isTablet) 0 else bars.start(v), + end = bars.end(v), + bottom = bars.bottom, + ) + viewBinding.chart.updateLayoutParams { + val baseMargin = topMargin + bottomMargin = if (isTablet) baseMargin + bars.bottom else baseMargin + marginStart = baseMargin + bars.start(v) + marginEnd = if (isTablet) baseMargin else baseMargin + bars.end(v) + } + return WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) + .build() + } + + override fun onClick(v: View) { + when (v.id) { + R.id.chip_period -> showPeriodSelector() + R.id.chip_type -> showTypeSelector() + } + } + + 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) { + router.showStatisticSheet(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, currentList: MutableList) { + 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.setImageAsync(R.drawable.ic_empty_history) + 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) { + 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_Futon_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() { + buildAlertDialog(this, isCentered = true) { + setMessage(R.string.clear_stats_confirm) + setTitle(R.string.clear_stats) + setIcon(R.drawable.ic_delete_all) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.clear) { _, _ -> viewModel.clearStats() } + }.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 { item -> + StatsPeriod.entries.getOrNull(item.order)?.also { + viewModel.period.value = it + } != null + } + menu.show() + } + + private fun showTypeSelector() { + val anchor = viewBinding.chipType + val menu = PopupMenu(this, anchor) + val byGenre = viewModel.byGenre.value + val mangaItem = menu.menu.add(R.id.group_period + 1, Menu.NONE, 0, R.string.manga) + mangaItem.isCheckable = true + mangaItem.isChecked = !byGenre + val genreItem = menu.menu.add(R.id.group_period + 1, Menu.NONE, 1, R.string.genres) + genreItem.isCheckable = true + genreItem.isChecked = byGenre + + menu.menu.setGroupCheckable(R.id.group_period + 1, true, true) + + menu.setOnMenuItemClickListener { + viewModel.byGenre.value = it.order == 1 + true + } + menu.show() + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsViewModel.kt index 6f6a7f2872..a4e9ab0658 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/stats/ui/StatsViewModel.kt @@ -1,11 +1,6 @@ package io.github.landwarderer.futon.stats.ui import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.take import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.FavouriteCategory import io.github.landwarderer.futon.core.ui.BaseViewModel @@ -16,6 +11,11 @@ import io.github.landwarderer.futon.favourites.domain.FavouritesRepository import io.github.landwarderer.futon.stats.data.StatsRepository import io.github.landwarderer.futon.stats.domain.StatsPeriod import io.github.landwarderer.futon.stats.domain.StatsRecord +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.take import javax.inject.Inject @HiltViewModel @@ -25,6 +25,7 @@ class StatsViewModel @Inject constructor( ) : BaseViewModel() { val period = MutableStateFlow(StatsPeriod.WEEK) + val byGenre = MutableStateFlow(false) val onActionDone = MutableEventFlow() val selectedCategories = MutableStateFlow>(emptySet()) val favoriteCategories = favouritesRepository.observeCategories() @@ -34,13 +35,14 @@ class StatsViewModel @Inject constructor( init { launchJob(Dispatchers.IO) { - combine, Pair>>( + combine( period, selectedCategories, - ::Pair, - ).collectLatest { p -> + byGenre, + ::Triple, + ).collectLatest { (p, categories, genre) -> readingStats.value = withLoading { - repository.getReadingStats(p.first, p.second) + repository.getReadingStats(p, categories, genre) } } } diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000000..eb8b07cc16 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout-w600dp-land/activity_stats.xml b/app/src/main/res/layout-w600dp-land/activity_stats.xml index 6f0f6eedb9..eab67d02f1 100644 --- a/app/src/main/res/layout-w600dp-land/activity_stats.xml +++ b/app/src/main/res/layout-w600dp-land/activity_stats.xml @@ -52,6 +52,14 @@ android:text="@string/week" app:chipIcon="@drawable/ic_history" /> + + diff --git a/app/src/main/res/layout/activity_stats.xml b/app/src/main/res/layout/activity_stats.xml index 39b6b82401..4a318d7781 100644 --- a/app/src/main/res/layout/activity_stats.xml +++ b/app/src/main/res/layout/activity_stats.xml @@ -65,6 +65,14 @@ android:text="@string/week" app:chipIcon="@drawable/ic_history" /> + + diff --git a/app/src/main/res/layout/dialog_restore.xml b/app/src/main/res/layout/dialog_restore.xml index 35acea149a..9c4d4d70d2 100644 --- a/app/src/main/res/layout/dialog_restore.xml +++ b/app/src/main/res/layout/dialog_restore.xml @@ -32,7 +32,17 @@ tools:listitem="@layout/item_checkable_multiple" tools:visibility="visible" /> - + + Filter Saved filters Theme + Merge with existing data + Manga Light Dark