Migrate History screen to Compose (#6922)

* Migrate History screen to Compose

- Migrate screen
- Strip logic from presenter into use cases and repository
- Setup for other screen being able to migrate to Compose with Theme

* Changes from review comments
This commit is contained in:
Andreas 2022-04-17 16:36:22 +02:00 committed by GitHub
parent 7d50d7ff52
commit c475acd1ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 986 additions and 668 deletions

View File

@ -109,6 +109,7 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
compose = true
// Disable some unused things // Disable some unused things
aidl = false aidl = false
@ -122,6 +123,10 @@ android {
checkReleaseBuilds = false checkReleaseBuilds = false
} }
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compose.get()
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
@ -133,6 +138,16 @@ android {
} }
dependencies { dependencies {
implementation(compose.foundation)
implementation(compose.material3.core)
implementation(compose.material3.adapter)
implementation(compose.animation)
implementation(compose.ui.tooling)
implementation(androidx.paging.runtime)
implementation(androidx.paging.compose)
implementation(kotlinx.reflect) implementation(kotlinx.reflect)
implementation(kotlinx.bundles.coroutines) implementation(kotlinx.bundles.coroutines)
@ -262,6 +277,9 @@ tasks {
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi", "-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi", "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xopt-in=coil.annotation.ExperimentalCoilApi", "-Xopt-in=coil.annotation.ExperimentalCoilApi",
"-Xopt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-Xopt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi"
) )
} }

View File

@ -0,0 +1,43 @@
package eu.kanade.data.history.local
import androidx.paging.PagingSource
import androidx.paging.PagingState
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import logcat.logcat
class HistoryPagingSource(
private val repository: HistoryRepository,
private val query: String
) : PagingSource<Int, MangaChapterHistory>() {
override fun getRefreshKey(state: PagingState<Int, MangaChapterHistory>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult.Page<Int, MangaChapterHistory> {
val nextPageNumber = params.key ?: 0
logcat { "Loading page $nextPageNumber" }
val response = repository.getHistory(PAGE_SIZE, nextPageNumber, query)
val nextKey = if (response.size == 25) {
nextPageNumber + 1
} else {
null
}
return LoadResult.Page(
data = response,
prevKey = null,
nextKey = nextKey
)
}
companion object {
const val PAGE_SIZE = 25
}
}

View File

@ -0,0 +1,137 @@
package eu.kanade.data.history.repository
import eu.kanade.data.history.local.HistoryPagingSource
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.tables.HistoryTable
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import rx.Subscription
import rx.schedulers.Schedulers
import java.util.*
class HistoryRepositoryImpl(
private val db: DatabaseHelper
) : HistoryRepository {
/**
* Used to observe changes in the History table
* as RxJava isn't supported in Paging 3
*/
private var subscription: Subscription? = null
/**
* Paging Source for history table
*/
override fun getHistory(query: String): HistoryPagingSource {
subscription?.unsubscribe()
val pagingSource = HistoryPagingSource(this, query)
subscription = db.db
.observeChangesInTable(HistoryTable.TABLE)
.observeOn(Schedulers.io())
.subscribe {
pagingSource.invalidate()
}
return pagingSource
}
override suspend fun getHistory(limit: Int, page: Int, query: String) = coroutineScope {
withContext(Dispatchers.IO) {
// Set date limit for recent manga
val calendar = Calendar.getInstance().apply {
time = Date()
add(Calendar.YEAR, -50)
}
db.getRecentManga(calendar.time, limit, page * limit, query)
.executeAsBlocking()
}
}
override suspend fun getNextChapterForManga(manga: Manga, chapter: Chapter): Chapter? = coroutineScope {
withContext(Dispatchers.IO) {
if (!chapter.read) {
return@withContext chapter
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
else -> throw NotImplementedError("Unknown sorting method")
}
val chapters = db.getChapters(manga)
.executeAsBlocking()
.sortedWith { c1, c2 -> sortFunction(c1, c2) }
val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
return@withContext when (manga.sorting) {
Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
Manga.CHAPTER_SORTING_NUMBER -> {
val chapterNumber = chapter.chapter_number
((currChapterIndex + 1) until chapters.size)
.map { chapters[it] }
.firstOrNull {
it.chapter_number > chapterNumber &&
it.chapter_number <= chapterNumber + 1
}
}
Manga.CHAPTER_SORTING_UPLOAD_DATE -> {
chapters.drop(currChapterIndex + 1)
.firstOrNull { it.date_upload >= chapter.date_upload }
}
else -> throw NotImplementedError("Unknown sorting method")
}
}
}
override suspend fun resetHistory(history: History): Boolean = coroutineScope {
withContext(Dispatchers.IO) {
try {
history.last_read = 0
db.upsertHistoryLastRead(history)
.executeAsBlocking()
true
} catch (e: Throwable) {
logcat(throwable = e)
false
}
}
}
override suspend fun resetHistoryByMangaId(mangaId: Long): Boolean = coroutineScope {
withContext(Dispatchers.IO) {
try {
val history = db.getHistoryByMangaId(mangaId)
.executeAsBlocking()
history.forEach { it.last_read = 0 }
db.upsertHistoryLastRead(history)
.executeAsBlocking()
true
} catch (e: Throwable) {
logcat(throwable = e)
false
}
}
}
override suspend fun deleteAllHistory(): Boolean = coroutineScope {
withContext(Dispatchers.IO) {
try {
db.dropHistoryTable()
.executeAsBlocking()
true
} catch (e: Throwable) {
logcat(throwable = e)
false
}
}
}
}

View File

@ -0,0 +1,26 @@
package eu.kanade.domain
import eu.kanade.data.history.repository.HistoryRepositoryImpl
import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory
import eu.kanade.domain.history.interactor.GetNextChapterForManga
import eu.kanade.domain.history.interactor.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.domain.history.repository.HistoryRepository
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { DeleteHistoryTable(get()) }
addFactory { GetHistory(get()) }
addFactory { GetNextChapterForManga(get()) }
addFactory { RemoveHistoryById(get()) }
addFactory { RemoveHistoryByMangaId(get()) }
}
}

View File

@ -0,0 +1,12 @@
package eu.kanade.domain.history.interactor
import eu.kanade.domain.history.repository.HistoryRepository
class DeleteHistoryTable(
private val repository: HistoryRepository
) {
suspend fun await(): Boolean {
return repository.deleteAllHistory()
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.domain.history.interactor
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import eu.kanade.data.history.local.HistoryPagingSource
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import kotlinx.coroutines.flow.Flow
class GetHistory(
private val repository: HistoryRepository
) {
fun subscribe(query: String): Flow<PagingData<MangaChapterHistory>> {
return Pager(
PagingConfig(pageSize = HistoryPagingSource.PAGE_SIZE)
) {
repository.getHistory(query)
}.flow
}
}

View File

@ -0,0 +1,14 @@
package eu.kanade.domain.history.interactor
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
class GetNextChapterForManga(
private val repository: HistoryRepository
) {
suspend fun await(manga: Manga, chapter: Chapter): Chapter? {
return repository.getNextChapterForManga(manga, chapter)
}
}

View File

@ -0,0 +1,21 @@
package eu.kanade.domain.history.interactor
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.HistoryImpl
class RemoveHistoryById(
private val repository: HistoryRepository
) {
suspend fun await(history: History): Boolean {
// Workaround for list not freaking out when changing reference varaible
val history = HistoryImpl().apply {
id = history.id
chapter_id = history.chapter_id
last_read = history.last_read
time_read = history.time_read
}
return repository.resetHistory(history)
}
}

View File

@ -0,0 +1,12 @@
package eu.kanade.domain.history.interactor
import eu.kanade.domain.history.repository.HistoryRepository
class RemoveHistoryByMangaId(
private val repository: HistoryRepository
) {
suspend fun await(mangaId: Long): Boolean {
return repository.resetHistoryByMangaId(mangaId)
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.domain.history.repository
import eu.kanade.data.history.local.HistoryPagingSource
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
interface HistoryRepository {
fun getHistory(query: String): HistoryPagingSource
suspend fun getHistory(limit: Int, page: Int, query: String): List<MangaChapterHistory>
suspend fun getNextChapterForManga(manga: Manga, chapter: Chapter): Chapter?
suspend fun resetHistory(history: History): Boolean
suspend fun resetHistoryByMangaId(mangaId: Long): Boolean
suspend fun deleteAllHistory(): Boolean
}

View File

@ -0,0 +1,49 @@
package eu.kanade.presentation.components
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import eu.kanade.tachiyomi.widget.EmptyView
@Composable
fun EmptyScreen(
@StringRes textResource: Int,
actions: List<EmptyView.Action>? = null,
) {
EmptyScreen(
message = stringResource(id = textResource),
actions = actions,
)
}
@Composable
fun EmptyScreen(
message: String,
actions: List<EmptyView.Action>? = null,
) {
Box(
modifier = Modifier
.fillMaxSize()
) {
AndroidView(
factory = { context ->
EmptyView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
}
},
modifier = Modifier
.align(Alignment.Center),
) { view ->
view.show(message, actions)
}
}
}

View File

@ -0,0 +1,35 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import eu.kanade.tachiyomi.data.database.models.Manga
enum class MangaCoverAspect(val ratio: Float) {
SQUARE(1f / 1f),
COVER(2f / 3f)
}
@Composable
fun MangaCover(
modifier: Modifier = Modifier,
manga: Manga,
aspect: MangaCoverAspect,
contentDescription: String = "",
shape: Shape = RoundedCornerShape(4.dp)
) {
AsyncImage(
model = manga,
contentDescription = contentDescription,
modifier = modifier
.aspectRatio(aspect.ratio)
.clip(shape),
contentScale = ContentScale.Crop
)
}

View File

@ -0,0 +1,298 @@
package eu.kanade.presentation.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.text.buildSpannedString
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.components.MangaCoverAspect
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter
import eu.kanade.tachiyomi.ui.recent.history.UiModel
import eu.kanade.tachiyomi.util.lang.toRelativeString
import eu.kanade.tachiyomi.util.lang.toTimestampString
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
val chapterFormatter = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
@Composable
fun HistoryScreen(
composeView: ComposeView,
presenter: HistoryPresenter,
onClickItem: (MangaChapterHistory) -> Unit,
onClickResume: (MangaChapterHistory) -> Unit,
onClickDelete: (MangaChapterHistory, Boolean) -> Unit,
) {
val nestedSrollInterop = rememberNestedScrollInteropConnection(composeView)
TachiyomiTheme {
val state by presenter.state.collectAsState()
val history = state.list?.collectAsLazyPagingItems()
when {
history == null -> {
CircularProgressIndicator()
}
history.itemCount == 0 -> {
EmptyScreen(
textResource = R.string.information_no_recent_manga
)
}
else -> {
HistoryContent(
nestedScroll = nestedSrollInterop,
history = history,
onClickItem = onClickItem,
onClickResume = onClickResume,
onClickDelete = onClickDelete,
)
}
}
}
}
@Composable
fun HistoryContent(
history: LazyPagingItems<UiModel>,
onClickItem: (MangaChapterHistory) -> Unit,
onClickResume: (MangaChapterHistory) -> Unit,
onClickDelete: (MangaChapterHistory, Boolean) -> Unit,
preferences: PreferencesHelper = Injekt.get(),
nestedScroll: NestedScrollConnection
) {
val relativeTime: Int = remember { preferences.relativeTime().get() }
val dateFormat: DateFormat = remember { preferences.dateFormat() }
val (removeState, setRemoveState) = remember { mutableStateOf<MangaChapterHistory?>(null) }
val scrollState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScroll),
state = scrollState,
) {
items(history) { item ->
when (item) {
is UiModel.Header -> {
HistoryHeader(
modifier = Modifier
.animateItemPlacement(),
date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat
)
}
is UiModel.History -> {
val value = item.item
HistoryItem(
modifier = Modifier.animateItemPlacement(),
history = value,
onClickItem = { onClickItem(value) },
onClickResume = { onClickResume(value) },
onClickDelete = { setRemoveState(value) },
)
}
null -> {}
}
}
item {
Spacer(
modifier = Modifier
.navigationBarsPadding()
)
}
}
if (removeState != null) {
RemoveHistoryDialog(
onPositive = { all ->
onClickDelete(removeState, all)
setRemoveState(null)
},
onNegative = { setRemoveState(null) }
)
}
}
@Composable
fun HistoryHeader(
modifier: Modifier = Modifier,
date: Date,
relativeTime: Int,
dateFormat: DateFormat,
) {
Text(
modifier = modifier
.padding(horizontal = horizontalPadding, vertical = 8.dp),
text = date.toRelativeString(
LocalContext.current,
relativeTime,
dateFormat
),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
)
}
@Composable
fun HistoryItem(
modifier: Modifier = Modifier,
history: MangaChapterHistory,
onClickItem: () -> Unit,
onClickResume: () -> Unit,
onClickDelete: () -> Unit,
) {
Row(
modifier = modifier
.clickable(onClick = onClickItem)
.height(96.dp)
.padding(horizontal = horizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover(
modifier = Modifier.fillMaxHeight(),
manga = history.manga,
aspect = MangaCoverAspect.COVER
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = horizontalPadding, end = 8.dp),
) {
val textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = history.manga.title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = textStyle.copy(fontWeight = FontWeight.SemiBold)
)
Row {
Text(
text = buildSpannedString {
if (history.chapter.chapter_number > -1) {
append(
stringResource(
R.string.history_prefix,
chapterFormatter.format(history.chapter.chapter_number)
)
)
}
append(Date(history.history.last_read).toTimestampString())
}.toString(),
modifier = Modifier.padding(top = 2.dp),
style = textStyle
)
}
}
IconButton(onClick = onClickDelete) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(id = R.string.action_delete),
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = onClickResume) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(id = R.string.action_resume),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
fun RemoveHistoryDialog(
onPositive: (Boolean) -> Unit,
onNegative: () -> Unit
) {
val (removeEverything, removeEverythingState) = remember { mutableStateOf(false) }
AlertDialog(
title = {
Text(text = stringResource(id = R.string.action_remove))
},
text = {
Column {
Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description))
Row(
modifier = Modifier.toggleable(value = removeEverything, onValueChange = removeEverythingState),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = removeEverything,
onCheckedChange = removeEverythingState,
)
Text(
text = stringResource(id = R.string.dialog_with_checkbox_reset)
)
}
}
},
onDismissRequest = onNegative,
confirmButton = {
TextButton(onClick = { onPositive(removeEverything) }) {
Text(text = stringResource(id = R.string.action_remove))
}
},
dismissButton = {
TextButton(onClick = onNegative) {
Text(text = stringResource(id = R.string.action_cancel))
}
},
)
}

View File

@ -0,0 +1,20 @@
package eu.kanade.presentation.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.google.android.material.composethemeadapter3.createMdc3Theme
@Composable
fun TachiyomiTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
var (colorScheme, typography) = createMdc3Theme(
context = context
)
MaterialTheme(
colorScheme = colorScheme!!,
typography = typography!!,
content = content
)
}

View File

@ -0,0 +1,5 @@
package eu.kanade.presentation.util
import androidx.compose.ui.unit.dp
val horizontalPadding = 16.dp

View File

@ -0,0 +1,5 @@
package eu.kanade.presentation.util
import androidx.compose.foundation.lazy.LazyListState
fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1

View File

@ -24,6 +24,7 @@ import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.util.DebugLogger import coil.util.DebugLogger
import eu.kanade.domain.DomainModule
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
@ -74,6 +75,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
} }
Injekt.importModule(AppModule(this)) Injekt.importModule(AppModule(this))
Injekt.importModule(DomainModule())
setupAcra() setupAcra()
setupNotificationChannels() setupNotificationChannels()

View File

@ -294,7 +294,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
} }
} }
} }
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking()
} }
/** /**

View File

@ -168,7 +168,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
} }
} }
} }
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking() databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking()
} }
/** /**

View File

@ -5,7 +5,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver import eu.kanade.tachiyomi.data.database.resolvers.HistoryUpsertResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver
import eu.kanade.tachiyomi.data.database.tables.HistoryTable import eu.kanade.tachiyomi.data.database.tables.HistoryTable
import java.util.Date import java.util.Date
@ -64,9 +64,9 @@ interface HistoryQueries : DbProvider {
* Inserts history object if not yet in database * Inserts history object if not yet in database
* @param history history object * @param history history object
*/ */
fun updateHistoryLastRead(history: History) = db.put() fun upsertHistoryLastRead(history: History) = db.put()
.`object`(history) .`object`(history)
.withPutResolver(HistoryLastReadPutResolver()) .withPutResolver(HistoryUpsertResolver())
.prepare() .prepare()
/** /**
@ -74,12 +74,40 @@ interface HistoryQueries : DbProvider {
* Inserts history object if not yet in database * Inserts history object if not yet in database
* @param historyList history object list * @param historyList history object list
*/ */
fun updateHistoryLastRead(historyList: List<History>) = db.put() fun upsertHistoryLastRead(historyList: List<History>) = db.put()
.objects(historyList) .objects(historyList)
.withPutResolver(HistoryLastReadPutResolver()) .withPutResolver(HistoryUpsertResolver())
.prepare() .prepare()
fun deleteHistory() = db.delete() fun resetHistoryLastRead(historyId: Long) = db.executeSQL()
.withQuery(
RawQuery.builder()
.query(
"""
UPDATE ${HistoryTable.TABLE}
SET history_last_read = 0
WHERE ${HistoryTable.COL_ID} = $historyId
""".trimIndent()
)
.build()
)
.prepare()
fun resetHistoryLastRead(historyIds: List<Long>) = db.executeSQL()
.withQuery(
RawQuery.builder()
.query(
"""
UPDATE ${HistoryTable.TABLE}
SET history_last_read = 0
WHERE ${HistoryTable.COL_ID} in ${historyIds.joinToString(",", "(", ")")}
""".trimIndent()
)
.build()
)
.prepare()
fun dropHistoryTable() = db.delete()
.byQuery( .byQuery(
DeleteQuery.builder() DeleteQuery.builder()
.table(HistoryTable.TABLE) .table(HistoryTable.TABLE)

View File

@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.database.mappers.HistoryPutResolver
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.tables.HistoryTable import eu.kanade.tachiyomi.data.database.tables.HistoryTable
class HistoryLastReadPutResolver : HistoryPutResolver() { class HistoryUpsertResolver : HistoryPutResolver() {
/** /**
* Updates last_read time of chapter * Updates last_read time of chapter

View File

@ -97,14 +97,19 @@ import kotlin.math.max
class ReaderActivity : BaseRxActivity<ReaderPresenter>() { class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
companion object { companion object {
fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
fun newIntent(context: Context, mangaId: Long?, chapterId: Long?): Intent {
return Intent(context, ReaderActivity::class.java).apply { return Intent(context, ReaderActivity::class.java).apply {
putExtra("manga", manga.id) putExtra("manga", mangaId)
putExtra("chapter", chapter.id) putExtra("chapter", chapterId)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
} }
} }
fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
return newIntent(context, manga.id, chapter.id)
}
private const val ENABLED_BUTTON_IMAGE_ALPHA = 255 private const val ENABLED_BUTTON_IMAGE_ALPHA = 255
private const val DISABLED_BUTTON_IMAGE_ALPHA = 64 private const val DISABLED_BUTTON_IMAGE_ALPHA = 64

View File

@ -449,7 +449,7 @@ class ReaderPresenter(
private fun saveChapterHistory(chapter: ReaderChapter) { private fun saveChapterHistory(chapter: ReaderChapter) {
if (!incognitoMode) { if (!incognitoMode) {
val history = History.create(chapter.chapter).apply { last_read = Date().time } val history = History.create(chapter.chapter).apply { last_read = Date().time }
db.updateHistoryLastRead(history).asRxCompletable() db.upsertHistoryLastRead(history).asRxCompletable()
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()

View File

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.ui.recent.history
import android.app.Dialog
import android.os.Bundle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ClearHistoryDialogController : DialogController() {
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.clear_history_confirmation)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? HistoryController)
?.presenter
?.deleteAllHistory()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
}

View File

@ -1,51 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.history
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
/**
* Adapter of HistoryHolder.
* Connection between Fragment and Holder
* Holder updates should be called from here.
*
* @param controller a HistoryController object
* @constructor creates an instance of the adapter.
*/
class HistoryAdapter(controller: HistoryController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
val sourceManager: SourceManager by injectLazy()
val resumeClickListener: OnResumeClickListener = controller
val removeClickListener: OnRemoveClickListener = controller
val itemClickListener: OnItemClickListener = controller
/**
* DecimalFormat used to display correct chapter number
*/
val decimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
init {
setDisplayHeadersAtStartUp(true)
}
interface OnResumeClickListener {
fun onResumeClick(position: Int)
}
interface OnRemoveClickListener {
fun onRemoveClick(position: Int)
}
interface OnItemClickListener {
fun onItemClick(position: Int)
}
}

View File

@ -1,186 +1,56 @@
package eu.kanade.tachiyomi.ui.recent.history package eu.kanade.tachiyomi.ui.recent.history
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.presentation.history.HistoryScreen
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.HistoryControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import logcat.LogPriority
import reactivecircus.flowbinding.appcompat.queryTextChanges import reactivecircus.flowbinding.appcompat.queryTextChanges
import uy.kohesive.injekt.injectLazy
/** /**
* Fragment that shows recently read manga. * Fragment that shows recently read manga.
*/ */
class HistoryController : class HistoryController :
NucleusController<HistoryControllerBinding, HistoryPresenter>(), NucleusController<ComposeControllerBinding, HistoryPresenter>(),
RootController, RootController {
FlexibleAdapter.OnUpdateListener,
FlexibleAdapter.EndlessScrollListener,
HistoryAdapter.OnRemoveClickListener,
HistoryAdapter.OnResumeClickListener,
HistoryAdapter.OnItemClickListener,
RemoveHistoryDialog.Listener {
private val db: DatabaseHelper by injectLazy()
/**
* Adapter containing the recent manga.
*/
var adapter: HistoryAdapter? = null
private set
/**
* Endless loading item.
*/
private var progressItem: ProgressItem? = null
/**
* Search query.
*/
private var query = "" private var query = ""
override fun getTitle(): String? { override fun getTitle(): String? = resources?.getString(R.string.label_recent_manga)
return resources?.getString(R.string.label_recent_manga)
}
override fun createPresenter(): HistoryPresenter { override fun createPresenter(): HistoryPresenter = HistoryPresenter()
return HistoryPresenter()
}
override fun createBinding(inflater: LayoutInflater) = HistoryControllerBinding.inflate(inflater) override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) { override fun onViewCreated(view: View) {
super.onViewCreated(view) super.onViewCreated(view)
binding.recycler.applyInsetter { binding.root.setContent {
type(navigationBars = true) { HistoryScreen(
padding() composeView = binding.root,
} presenter = presenter,
} onClickItem = { (manga, _, _) ->
// Initialize adapter
binding.recycler.layoutManager = LinearLayoutManager(view.context)
adapter = HistoryAdapter(this@HistoryController)
binding.recycler.setHasFixedSize(true)
binding.recycler.adapter = adapter
adapter?.fastScroller = binding.fastScroller
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
/**
* Populate adapter with chapters
*
* @param mangaHistory list of manga history
*/
fun onNextManga(mangaHistory: List<HistoryItem>, cleanBatch: Boolean = false) {
if (adapter?.itemCount ?: 0 == 0) {
resetProgressItem()
}
if (cleanBatch) {
adapter?.updateDataSet(mangaHistory)
} else {
adapter?.onLoadMoreComplete(mangaHistory)
}
binding.recycler.onAnimationsFinished {
(activity as? MainActivity)?.ready = true
}
}
/**
* Safely error if next page load fails
*/
fun onAddPageError(error: Throwable) {
adapter?.onLoadMoreComplete(null)
adapter?.endlessTargetCount = 1
logcat(LogPriority.ERROR, error)
}
override fun onUpdateEmptyView(size: Int) {
if (size > 0) {
binding.emptyView.hide()
} else {
binding.emptyView.show(R.string.information_no_recent_manga)
}
}
/**
* Sets a new progress item and reenables the scroll listener.
*/
private fun resetProgressItem() {
progressItem = ProgressItem()
adapter?.endlessTargetCount = 0
adapter?.setEndlessScrollListener(this, progressItem!!)
}
override fun onLoadMore(lastPosition: Int, currentPage: Int) {
val view = view ?: return
if (BackupRestoreService.isRunning(view.context.applicationContext)) {
onAddPageError(Throwable())
return
}
val adapter = adapter ?: return
presenter.requestNext(adapter.itemCount - adapter.headerItems.size, query)
}
override fun noMoreLoad(newItemsSize: Int) {}
override fun onResumeClick(position: Int) {
val activity = activity ?: return
val (manga, chapter, _) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return
val nextChapter = presenter.getNextChapter(chapter, manga)
if (nextChapter != null) {
val intent = ReaderActivity.newIntent(activity, manga, nextChapter)
startActivity(intent)
} else {
activity.toast(R.string.no_next_chapter)
}
}
override fun onRemoveClick(position: Int) {
val (manga, _, history) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return
RemoveHistoryDialog(this, manga, history).showDialog(router)
}
override fun onItemClick(position: Int) {
val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return
router.pushController(MangaController(manga).withFadeTransaction()) router.pushController(MangaController(manga).withFadeTransaction())
} },
onClickResume = { (manga, chapter, _) ->
override fun removeHistory(manga: Manga, history: History, all: Boolean) { presenter.getNextChapterForManga(manga, chapter)
},
onClickDelete = { (manga, _, history), all ->
if (all) { if (all) {
// Reset last read of chapter to 0L // Reset last read of chapter to 0L
presenter.removeAllFromHistory(manga.id!!) presenter.removeAllFromHistory(manga.id!!)
@ -188,6 +58,9 @@ class HistoryController :
// Remove all chapters belonging to manga from library // Remove all chapters belonging to manga from library
presenter.removeFromHistory(history) presenter.removeFromHistory(history)
} }
},
)
}
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -201,46 +74,33 @@ class HistoryController :
searchView.clearFocus() searchView.clearFocus()
} }
searchView.queryTextChanges() searchView.queryTextChanges()
.drop(1) // Drop first event after subscribed
.filter { router.backstack.lastOrNull()?.controller == this } .filter { router.backstack.lastOrNull()?.controller == this }
.onEach { .onEach {
query = it.toString() query = it.toString()
presenter.updateList(query) presenter.search(query)
} }
.launchIn(viewScope) .launchIn(viewScope)
// Fixes problem with the overflow icon showing up in lieu of search
searchItem.fixExpand(
onExpand = { invalidateMenuOnExpand() },
)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { return when (item.itemId) {
R.id.action_clear_history -> { R.id.action_clear_history -> {
val ctrl = ClearHistoryDialogController() val dialog = ClearHistoryDialogController()
ctrl.targetController = this@HistoryController dialog.targetController = this@HistoryController
ctrl.showDialog(router) dialog.showDialog(router)
true
}
else -> super.onOptionsItemSelected(item)
} }
} }
return super.onOptionsItemSelected(item) fun openChapter(chapter: Chapter?) {
} val activity = activity ?: return
if (chapter != null) {
class ClearHistoryDialogController : DialogController() { val intent = ReaderActivity.newIntent(activity, chapter.manga_id, chapter.id)
override fun onCreateDialog(savedViewState: Bundle?): Dialog { startActivity(intent)
return MaterialAlertDialogBuilder(activity!!) } else {
.setMessage(R.string.clear_history_confirmation) activity.toast(R.string.no_next_chapter)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? HistoryController)?.clearHistory()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
} }
} }
private fun clearHistory() {
db.deleteHistory().executeAsBlocking()
activity?.toast(R.string.clear_history_completed)
}
} }

View File

@ -1,71 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.history
import android.view.View
import coil.dispose
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.databinding.HistoryItemBinding
import eu.kanade.tachiyomi.util.lang.toTimestampString
import java.util.Date
/**
* Holder that contains recent manga item
* Uses R.layout.item_recently_read.
* UI related actions should be called from here.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new recent chapter holder.
*/
class HistoryHolder(
view: View,
val adapter: HistoryAdapter,
) : FlexibleViewHolder(view, adapter) {
private val binding = HistoryItemBinding.bind(view)
init {
binding.holder.setOnClickListener {
adapter.itemClickListener.onItemClick(bindingAdapterPosition)
}
binding.remove.setOnClickListener {
adapter.removeClickListener.onRemoveClick(bindingAdapterPosition)
}
binding.resume.setOnClickListener {
adapter.resumeClickListener.onResumeClick(bindingAdapterPosition)
}
}
/**
* Set values of view
*
* @param item item containing history information
*/
fun bind(item: MangaChapterHistory) {
// Retrieve objects
val (manga, chapter, history) = item
// Set manga title
binding.mangaTitle.text = manga.title
// Set chapter number + timestamp
if (chapter.chapter_number > -1f) {
val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
binding.mangaSubtitle.text = itemView.context.getString(
R.string.recent_manga_time,
formattedNumber,
Date(history.last_read).toTimestampString(),
)
} else {
binding.mangaSubtitle.text = Date(history.last_read).toTimestampString()
}
// Set cover
binding.cover.dispose()
binding.cover.load(item.manga)
}
}

View File

@ -1,42 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.history
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.ui.recent.DateSectionItem
class HistoryItem(val mch: MangaChapterHistory, header: DateSectionItem) :
AbstractSectionableItem<HistoryHolder, DateSectionItem>(header) {
override fun getLayoutRes(): Int {
return R.layout.history_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): HistoryHolder {
return HistoryHolder(view, adapter as HistoryAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: HistoryHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(mch)
}
override fun equals(other: Any?): Boolean {
if (other is HistoryItem) {
return mch.manga.id == other.mch.manga.id
}
return false
}
override fun hashCode(): Int {
return mch.manga.id!!.hashCode()
}
}

View File

@ -1,157 +1,135 @@
package eu.kanade.tachiyomi.ui.recent.history package eu.kanade.tachiyomi.ui.recent.history
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.insertSeparators
import androidx.paging.map
import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory
import eu.kanade.domain.history.interactor.GetNextChapterForManga
import eu.kanade.domain.history.interactor.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.recent.DateSectionItem import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.toDateKey import eu.kanade.tachiyomi.util.lang.toDateKey
import rx.Observable import eu.kanade.tachiyomi.util.system.toast
import rx.Subscription import kotlinx.coroutines.flow.Flow
import rx.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.MutableStateFlow
import uy.kohesive.injekt.injectLazy import kotlinx.coroutines.flow.StateFlow
import java.text.DateFormat import kotlinx.coroutines.flow.flatMapLatest
import java.util.Calendar import kotlinx.coroutines.flow.map
import java.util.Date import kotlinx.coroutines.flow.update
import java.util.TreeMap import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
/** /**
* Presenter of HistoryFragment. * Presenter of HistoryFragment.
* Contains information and data for fragment. * Contains information and data for fragment.
* Observable updates should be called from here. * Observable updates should be called from here.
*/ */
class HistoryPresenter : BasePresenter<HistoryController>() { class HistoryPresenter(
private val getHistory: GetHistory = Injekt.get(),
private val getNextChapterForManga: GetNextChapterForManga = Injekt.get(),
private val deleteHistoryTable: DeleteHistoryTable = Injekt.get(),
private val removeHistoryById: RemoveHistoryById = Injekt.get(),
private val removeHistoryByMangaId: RemoveHistoryByMangaId = Injekt.get(),
) : BasePresenter<HistoryController>() {
private val db: DatabaseHelper by injectLazy() private var _query: MutableStateFlow<String> = MutableStateFlow("")
private val preferences: PreferencesHelper by injectLazy() private var _state: MutableStateFlow<HistoryState> = MutableStateFlow(HistoryState.EMPTY)
val state: StateFlow<HistoryState> = _state
private val relativeTime: Int = preferences.relativeTime().get()
private val dateFormat: DateFormat = preferences.dateFormat()
private var recentMangaSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
// Used to get a list of recently read manga presenterScope.launchIO {
updateList() _state.update { state ->
state.copy(
list = _query.flatMapLatest { query ->
getHistory.subscribe(query)
.map { pagingData ->
pagingData
.map {
UiModel.History(it)
} }
.insertSeparators { before, after ->
fun requestNext(offset: Int, search: String = "") { val beforeDate =
getRecentMangaObservable(offset = offset, search = search) before?.item?.history?.last_read?.toDateKey()
.subscribeLatestCache( val afterDate =
{ view, mangas -> after?.item?.history?.last_read?.toDateKey()
view.onNextManga(mangas) when {
}, beforeDate == null && afterDate != null -> UiModel.Header(
HistoryController::onAddPageError, afterDate,
)
beforeDate != null && afterDate != null -> UiModel.Header(
afterDate,
)
// Return null to avoid adding a separator between two items.
else -> null
}
}
}
}
.cachedIn(presenterScope),
) )
} }
}
/**
* Get recent manga observable
* @return list of history
*/
private fun getRecentMangaObservable(limit: Int = 25, offset: Int = 0, search: String = ""): Observable<List<HistoryItem>> {
// Set date limit for recent manga
val cal = Calendar.getInstance().apply {
time = Date()
add(Calendar.YEAR, -50)
} }
return db.getRecentManga(cal.time, limit, offset, search).asRxObservable() fun search(query: String) {
.map { recents -> presenterScope.launchIO {
val map = TreeMap<Date, MutableList<MangaChapterHistory>> { d1, d2 -> d2.compareTo(d1) } _query.emit(query)
val byDay = recents
.groupByTo(map) { it.history.last_read.toDateKey() }
byDay.flatMap { entry ->
val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat)
entry.value.map { HistoryItem(it, dateItem) }
} }
} }
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Reset last read of chapter to 0L
* @param history history belonging to chapter
*/
fun removeFromHistory(history: History) { fun removeFromHistory(history: History) {
history.last_read = 0L presenterScope.launchIO {
db.updateHistoryLastRead(history).asRxObservable() removeHistoryById.await(history)
.subscribe() }
} }
/**
* Pull a list of history from the db
* @param search a search query to use for filtering
*/
fun updateList(search: String = "") {
recentMangaSubscription?.unsubscribe()
recentMangaSubscription = getRecentMangaObservable(search = search)
.subscribeLatestCache(
{ view, mangas ->
view.onNextManga(mangas, true)
},
HistoryController::onAddPageError,
)
}
/**
* Removes all chapters belonging to manga from history.
* @param mangaId id of manga
*/
fun removeAllFromHistory(mangaId: Long) { fun removeAllFromHistory(mangaId: Long) {
db.getHistoryByMangaId(mangaId).asRxSingle() presenterScope.launchIO {
.map { list -> removeHistoryByMangaId.await(mangaId)
list.forEach { it.last_read = 0L }
db.updateHistoryLastRead(list).executeAsBlocking()
} }
.subscribe()
} }
/** fun getNextChapterForManga(manga: Manga, chapter: Chapter) {
* Retrieves the next chapter of the given one. presenterScope.launchIO {
* val chapter = getNextChapterForManga.await(manga, chapter)
* @param chapter the chapter of the history object. view?.openChapter(chapter)
* @param manga the manga of the chapter. }
*/
fun getNextChapter(chapter: Chapter, manga: Manga): Chapter? {
if (!chapter.read) {
return chapter
} }
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { fun deleteAllHistory() {
Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } presenterScope.launchIO {
Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } val result = deleteHistoryTable.await()
Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) } if (!result) return@launchIO
else -> throw NotImplementedError("Unknown sorting method") launchUI {
view?.activity?.toast(R.string.clear_history_completed)
}
}
}
}
sealed class UiModel {
data class History(val item: MangaChapterHistory) : UiModel()
data class Header(val date: Date) : UiModel()
} }
val chapters = db.getChapters(manga).executeAsBlocking() data class HistoryState(
.sortedWith { c1, c2 -> sortFunction(c1, c2) } val list: Flow<PagingData<UiModel>>? = null,
) {
val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id } companion object {
return when (manga.sorting) { val EMPTY = HistoryState(null)
Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
Manga.CHAPTER_SORTING_NUMBER -> {
val chapterNumber = chapter.chapter_number
((currChapterIndex + 1) until chapters.size)
.map { chapters[it] }
.firstOrNull {
it.chapter_number > chapterNumber &&
it.chapter_number <= chapterNumber + 1
}
}
Manga.CHAPTER_SORTING_UPLOAD_DATE -> {
chapters.drop(currChapterIndex + 1)
.firstOrNull { it.date_upload >= chapter.date_upload }
}
else -> throw NotImplementedError("Unknown sorting method")
}
} }
} }

View File

@ -1,54 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.history
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.DialogCheckboxView
class RemoveHistoryDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : RemoveHistoryDialog.Listener {
private var manga: Manga? = null
private var history: History? = null
constructor(target: T, manga: Manga, history: History) : this() {
this.manga = manga
this.history = history
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
// Create custom view
val dialogCheckboxView = DialogCheckboxView(activity).apply {
setDescription(R.string.dialog_with_checkbox_remove_description)
setOptionDescription(R.string.dialog_with_checkbox_reset)
}
return MaterialAlertDialogBuilder(activity)
.setTitle(R.string.action_remove)
.setView(dialogCheckboxView)
.setPositiveButton(R.string.action_remove) { _, _ -> onPositive(dialogCheckboxView.isChecked()) }
.setNegativeButton(android.R.string.cancel, null)
.create()
}
private fun onPositive(checked: Boolean) {
val target = targetController as? Listener ?: return
val manga = manga ?: return
val history = history ?: return
target.removeHistory(manga, history, checked)
}
interface Listener {
fun removeHistory(manga: Manga, history: History, all: Boolean)
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/history_item" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>

View File

@ -1,85 +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:id="@+id/holder"
android:layout_width="match_parent"
android:layout_height="96dp"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp"
android:background="?attr/selectableItemBackground"
android:orientation="horizontal">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/cover"
android:layout_width="0dp"
android:layout_height="match_parent"
android:contentDescription="@string/description_cover"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,3:2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
tools:src="@mipmap/ic_launcher" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/remove"
app:layout_constraintStart_toEndOf="@+id/cover"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/manga_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="Title" />
<TextView
android:id="@+id/manga_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
tools:text="Subtitle" />
</LinearLayout>
<ImageButton
android:id="@+id/remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_resume"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/resume"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_delete_24dp"
app:tint="?android:attr/textColorPrimary" />
<ImageButton
android:id="@+id/resume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_resume"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_play_arrow_24dp"
app:tint="?android:attr/textColorPrimary" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -702,6 +702,7 @@
<string name="updating_library">Updating library</string> <string name="updating_library">Updating library</string>
<!-- History fragment --> <!-- History fragment -->
<string name="history_prefix">Ch. %1$s - </string>
<string name="recent_manga_time">Ch. %1$s - %2$s</string> <string name="recent_manga_time">Ch. %1$s - %2$s</string>
<string name="pref_clear_history">Clear history</string> <string name="pref_clear_history">Clear history</string>
<string name="clear_history_completed">History deleted</string> <string name="clear_history_completed">History deleted</string>

View File

@ -344,7 +344,7 @@ class BackupTest {
private fun clearDatabase() { private fun clearDatabase() {
db.deleteMangas().executeAsBlocking() db.deleteMangas().executeAsBlocking()
db.deleteHistory().executeAsBlocking() db.dropHistoryTable().executeAsBlocking()
} }
private fun getSingleHistory(chapter: Chapter): DHistory { private fun getSingleHistory(chapter: Chapter): DHistory {

View File

@ -21,6 +21,9 @@ lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", ve
work-runtime = "androidx.work:work-runtime-ktx:2.6.0" work-runtime = "androidx.work:work-runtime-ktx:2.6.0"
guava = "com.google.guava:guava:31.1-android" guava = "com.google.guava:guava:31.1-android"
paging-runtime = "androidx.paging:paging-runtime:3.1.1"
paging-compose = "androidx.paging:paging-compose:1.0.0-alpha14"
[bundles] [bundles]
lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"] lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"]
workmanager = ["work-runtime", "guava"] workmanager = ["work-runtime", "guava"]

View File

@ -0,0 +1,9 @@
[versions]
compose = "1.2.0-alpha07"
[libraries]
foundation = { module = "androidx.compose.foundation:foundation", version.ref="compose" }
material3-core = "androidx.compose.material3:material3:1.0.0-alpha09"
material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.6"
animation = { module = "androidx.compose.animation:animation", version.ref="compose" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref="compose" }

View File

@ -1,5 +1,5 @@
[versions] [versions]
kotlin_version = "1.6.20" kotlin_version = "1.6.10"
coroutines_version = "1.6.1" coroutines_version = "1.6.1"
serialization_version = "1.3.2" serialization_version = "1.3.2"

View File

@ -49,6 +49,7 @@ injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440"
coil-core = { module = "io.coil-kt:coil", version.ref = "coil_version" } coil-core = { module = "io.coil-kt:coil", version.ref = "coil_version" }
coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil_version" } coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil_version" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil_version" }
subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:846abe0" subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:846abe0"
image-decoder = "com.github.tachiyomiorg:image-decoder:7481a4a" image-decoder = "com.github.tachiyomiorg:image-decoder:7481a4a"
@ -100,12 +101,12 @@ okhttp = ["okhttp-core","okhttp-logging","okhttp-dnsoverhttps"]
js-engine = ["quickjs-android", "duktape-android"] js-engine = ["quickjs-android", "duktape-android"]
sqlite = ["sqlitektx", "sqlite-android"] sqlite = ["sqlitektx", "sqlite-android"]
nucleus = ["nucleus-core","nucleus-supportv7"] nucleus = ["nucleus-core","nucleus-supportv7"]
coil = ["coil-core","coil-gif",] coil = ["coil-core","coil-gif","coil-compose"]
flowbinding = ["flowbinding-android","flowbinding-appcompat","flowbinding-recyclerview","flowbinding-swiperefreshlayout","flowbinding-viewpager"] flowbinding = ["flowbinding-android","flowbinding-appcompat","flowbinding-recyclerview","flowbinding-swiperefreshlayout","flowbinding-viewpager"]
conductor = ["conductor-core","conductor-viewpager","conductor-support-preference"] conductor = ["conductor-core","conductor-viewpager","conductor-support-preference"]
shizuku = ["shizuku-api","shizuku-provider"] shizuku = ["shizuku-api","shizuku-provider"]
robolectric = ["robolectric-core","robolectric-playservices"] robolectric = ["robolectric-core","robolectric-playservices"]
[plugins] [plugins]
kotlinter = { id = "org.jmailen.kotlinter", version = "3.10.0"} kotlinter = { id = "org.jmailen.kotlinter", version = "3.6.0"}
versionsx = { id = "com.github.ben-manes.versions", version = "0.42.0"} versionsx = { id = "com.github.ben-manes.versions", version = "0.42.0"}

View File

@ -22,6 +22,9 @@ dependencyResolutionManagement {
create("androidx") { create("androidx") {
from(files("gradle/androidx.versions.toml")) from(files("gradle/androidx.versions.toml"))
} }
create("compose") {
from(files("gradle/compose.versions.toml"))
}
} }
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {