diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index 45dc67fdb9..b2ffb6e5e2 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions @@ -18,13 +19,16 @@ import eu.kanade.presentation.components.AppBarTitle import eu.kanade.presentation.components.RelativeDateHeader import eu.kanade.presentation.components.SearchToolbar import eu.kanade.presentation.history.components.HistoryItem +import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.history.HistoryScreenModel +import tachiyomi.core.preference.InMemoryPreferenceStore import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.LoadingScreen +import tachiyomi.presentation.core.util.ThemePreviews import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Date @@ -37,6 +41,7 @@ fun HistoryScreen( onClickCover: (mangaId: Long) -> Unit, onClickResume: (mangaId: Long, chapterId: Long) -> Unit, onDialogChange: (HistoryScreenModel.Dialog?) -> Unit, + preferences: UiPreferences = Injekt.get(), ) { Scaffold( topBar = { scrollBehavior -> @@ -82,6 +87,7 @@ fun HistoryScreen( onClickCover = { history -> onClickCover(history.mangaId) }, onClickResume = { history -> onClickResume(history.mangaId, history.chapterId) }, onClickDelete = { item -> onDialogChange(HistoryScreenModel.Dialog.Delete(item)) }, + preferences = preferences, ) } } @@ -95,7 +101,7 @@ private fun HistoryScreenContent( onClickCover: (HistoryWithRelations) -> Unit, onClickResume: (HistoryWithRelations) -> Unit, onClickDelete: (HistoryWithRelations) -> Unit, - preferences: UiPreferences = Injekt.get(), + preferences: UiPreferences, ) { val relativeTime = remember { preferences.relativeTime().get() } val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } @@ -141,3 +147,32 @@ sealed interface HistoryUiModel { data class Header(val date: Date) : HistoryUiModel data class Item(val item: HistoryWithRelations) : HistoryUiModel } + +@ThemePreviews +@Composable +internal fun HistoryScreenPreviews( + @PreviewParameter(HistoryScreenModelStateProvider::class) + historyState: HistoryScreenModel.State, +) { + TachiyomiTheme { + HistoryScreen( + state = historyState, + snackbarHostState = SnackbarHostState(), + onSearchQueryChange = {}, + onClickCover = {}, + onClickResume = { _, _ -> run {} }, + onDialogChange = {}, + preferences = UiPreferences( + InMemoryPreferenceStore( + sequenceOf( + InMemoryPreferenceStore.InMemoryPreference( + key = "relative_time_v2", + data = false, + defaultValue = false, + ), + ), + ), + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreenModelStateProvider.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreenModelStateProvider.kt new file mode 100644 index 0000000000..2c07b81e0b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreenModelStateProvider.kt @@ -0,0 +1,109 @@ +package eu.kanade.presentation.history + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import eu.kanade.tachiyomi.ui.history.HistoryScreenModel +import tachiyomi.domain.history.model.HistoryWithRelations +import tachiyomi.domain.manga.model.MangaCover +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Date +import kotlin.random.Random + +class HistoryScreenModelStateProvider : PreviewParameterProvider { + + private val multiPage = HistoryScreenModel.State( + searchQuery = null, + list = + listOf(HistoryUiModelExamples.headerToday) + .asSequence() + .plus(HistoryUiModelExamples.items().take(3)) + .plus(HistoryUiModelExamples.header { it.minus(1, ChronoUnit.DAYS) }) + .plus(HistoryUiModelExamples.items().take(1)) + .plus(HistoryUiModelExamples.header { it.minus(2, ChronoUnit.DAYS) }) + .plus(HistoryUiModelExamples.items().take(7)) + .toList(), + dialog = null, + ) + + private val shortRecent = HistoryScreenModel.State( + searchQuery = null, + list = listOf( + HistoryUiModelExamples.headerToday, + HistoryUiModelExamples.items().first(), + ), + dialog = null, + ) + + private val shortFuture = HistoryScreenModel.State( + searchQuery = null, + list = listOf( + HistoryUiModelExamples.headerTomorrow, + HistoryUiModelExamples.items().first(), + ), + dialog = null, + ) + + private val empty = HistoryScreenModel.State( + searchQuery = null, + list = listOf(), + dialog = null, + ) + + private val loadingWithSearchQuery = HistoryScreenModel.State( + searchQuery = "Example Search Query", + ) + + private val loading = HistoryScreenModel.State( + searchQuery = null, + list = null, + dialog = null, + ) + + override val values: Sequence = sequenceOf( + multiPage, + shortRecent, + shortFuture, + empty, + loadingWithSearchQuery, + loading, + ) + + private object HistoryUiModelExamples { + val headerToday = header() + val headerTomorrow = + HistoryUiModel.Header(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + + fun header(instantBuilder: (Instant) -> Instant = { it }) = + HistoryUiModel.Header(Date.from(instantBuilder(Instant.now()))) + + fun items() = sequence { + var count = 1 + while (true) { + yield(randItem { it.copy(title = "Example Title $count") }) + count += 1 + } + } + + fun randItem(historyBuilder: (HistoryWithRelations) -> HistoryWithRelations = { it }) = + HistoryUiModel.Item( + historyBuilder( + HistoryWithRelations( + id = Random.nextLong(), + chapterId = Random.nextLong(), + mangaId = Random.nextLong(), + title = "Test Title", + chapterNumber = Random.nextDouble(), + readAt = Date.from(Instant.now()), + readDuration = Random.nextLong(), + coverData = MangaCover( + mangaId = Random.nextLong(), + sourceId = Random.nextLong(), + isMangaFavorite = Random.nextBoolean(), + url = "https://example.com/cover.png", + lastModified = Random.nextLong(), + ), + ), + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryUiModelProviders.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryUiModelProviders.kt new file mode 100644 index 0000000000..16200635fb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryUiModelProviders.kt @@ -0,0 +1,13 @@ +package eu.kanade.presentation.history + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import java.time.Instant +import java.util.Date + +object HistoryUiModelProviders { + + class HeadNow : PreviewParameterProvider { + override val values: Sequence = + sequenceOf(HistoryUiModel.Header(Date.from(Instant.now()))) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryDialogs.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryDialogs.kt index 12db68602b..f76a1fa5c6 100644 --- a/app/src/main/java/eu/kanade/presentation/history/components/HistoryDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryDialogs.kt @@ -18,7 +18,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.tachiyomi.R +import tachiyomi.presentation.core.util.ThemePreviews @Composable fun HistoryDeleteDialog( @@ -101,3 +103,14 @@ fun HistoryDeleteAllDialog( }, ) } + +@ThemePreviews +@Composable +internal fun HistoryDeleteDialogPreview() { + TachiyomiTheme { + HistoryDeleteDialog( + onDismissRequest = {}, + onDelete = { _ -> run {} }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt index 08ce2635b5..6b1bb1e0e4 100644 --- a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt @@ -19,13 +19,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import eu.kanade.presentation.manga.components.MangaCover +import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.util.formatChapterNumber import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.lang.toTimestampString import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.util.ThemePreviews private val HISTORY_ITEM_HEIGHT = 96.dp @@ -87,3 +90,19 @@ fun HistoryItem( } } } + +@ThemePreviews +@Composable +internal fun HistoryItemPreviews( + @PreviewParameter(HistoryWithRelationsProvider::class) + historyWithRelations: HistoryWithRelations, +) { + TachiyomiTheme { + HistoryItem( + history = historyWithRelations, + onClickCover = {}, + onClickResume = {}, + onClickDelete = {}, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryWithRelationsProvider.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryWithRelationsProvider.kt new file mode 100644 index 0000000000..78347ed36c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryWithRelationsProvider.kt @@ -0,0 +1,62 @@ +package eu.kanade.presentation.history.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import tachiyomi.domain.history.model.HistoryWithRelations +import java.util.Date + +internal class HistoryWithRelationsProvider : PreviewParameterProvider { + + private val simple = HistoryWithRelations( + id = 1L, + chapterId = 2L, + mangaId = 3L, + title = "Test Title", + chapterNumber = 10.2, + readAt = Date(1697247357L), + readDuration = 123L, + coverData = tachiyomi.domain.manga.model.MangaCover( + mangaId = 3L, + sourceId = 4L, + isMangaFavorite = false, + url = "https://example.com/cover.png", + lastModified = 5L, + ), + ) + + private val historyWithoutReadAt = HistoryWithRelations( + id = 1L, + chapterId = 2L, + mangaId = 3L, + title = "Test Title", + chapterNumber = 10.2, + readAt = null, + readDuration = 123L, + coverData = tachiyomi.domain.manga.model.MangaCover( + mangaId = 3L, + sourceId = 4L, + isMangaFavorite = false, + url = "https://example.com/cover.png", + lastModified = 5L, + ), + ) + + private val historyWithNegativeChapterNumber = HistoryWithRelations( + id = 1L, + chapterId = 2L, + mangaId = 3L, + title = "Test Title", + chapterNumber = -2.0, + readAt = Date(1697247357L), + readDuration = 123L, + coverData = tachiyomi.domain.manga.model.MangaCover( + mangaId = 3L, + sourceId = 4L, + isMangaFavorite = false, + url = "https://example.com/cover.png", + lastModified = 5L, + ), + ) + + override val values: Sequence + get() = sequenceOf(simple, historyWithoutReadAt, historyWithNegativeChapterNumber) +} diff --git a/core/src/main/java/tachiyomi/core/preference/InMemoryPreferenceStore.kt b/core/src/main/java/tachiyomi/core/preference/InMemoryPreferenceStore.kt new file mode 100644 index 0000000000..ad3937d915 --- /dev/null +++ b/core/src/main/java/tachiyomi/core/preference/InMemoryPreferenceStore.kt @@ -0,0 +1,96 @@ +package tachiyomi.core.preference + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn + +/** + * Local-copy implementation of PreferenceStore mostly for test and preview purposes + */ +class InMemoryPreferenceStore( + private val initialPreferences: Sequence> = sequenceOf(), +) : PreferenceStore { + + private val preferences: Map> = + initialPreferences.toList().associateBy { it.key() } + + override fun getString(key: String, defaultValue: String): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: String? = preferences[key]?.get() as? String + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getLong(key: String, defaultValue: Long): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: Long? = preferences[key]?.get() as? Long + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getInt(key: String, defaultValue: Int): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: Int? = preferences[key]?.get() as? Int + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getFloat(key: String, defaultValue: Float): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: Float? = preferences[key]?.get() as? Float + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getBoolean(key: String, defaultValue: Boolean): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: Boolean? = preferences[key]?.get() as? Boolean + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getStringSet(key: String, defaultValue: Set): Preference> { + TODO("Not yet implemented") + } + + override fun getObject( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): Preference { + val default = InMemoryPreference(key, null, defaultValue) + val data: T? = preferences[key]?.get() as? T + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getAll(): Map { + return preferences + } + + class InMemoryPreference( + private val key: String, + private var data: T?, + private val defaultValue: T, + ) : Preference { + override fun key(): String = key + + override fun get(): T = data ?: defaultValue() + + override fun isSet(): Boolean = data != null + + override fun delete() { + data = null + } + + override fun defaultValue(): T = defaultValue + + override fun changes(): Flow = flow { data } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } + + override fun set(value: T) { + data = value + } + } +}