diff --git a/app/build.gradle b/app/build.gradle index 913934c49..1c67c45f5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,15 +49,15 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() freeCompilerArgs += [ - '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', - '-opt-in=kotlinx.coroutines.FlowPreview', - '-opt-in=kotlin.contracts.ExperimentalContracts', - '-opt-in=coil.annotation.ExperimentalCoilApi', + '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-opt-in=kotlinx.coroutines.FlowPreview', + '-opt-in=kotlin.contracts.ExperimentalContracts', + '-opt-in=coil.annotation.ExperimentalCoilApi', ] } lint { abortOnError false - disable 'MissingTranslation', 'PrivateResource' + disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged' } testOptions { unitTests.includeAndroidResources = true @@ -96,12 +96,12 @@ dependencies { kapt 'androidx.room:room-compiler:2.4.2' implementation 'com.squareup.okhttp3:okhttp:4.9.3' - implementation 'com.squareup.okio:okio:3.0.0' + implementation 'com.squareup.okio:okio:3.1.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' - implementation 'io.insert-koin:koin-android:3.1.6' + implementation 'io.insert-koin:koin-android:3.2.0' implementation 'io.coil-kt:coil-base:2.0.0-rc03' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.github.solkin:disk-lru-cache:1.4' @@ -110,7 +110,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1' - testImplementation 'io.insert-koin:koin-test-junit4:3.1.5' + testImplementation 'io.insert-koin:koin-test-junit4:3.2.0' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0' diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index 7e755507a..4ae7a438f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.strictmode.FragmentStrictMode import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.koitharu.kotatsu.bookmarks.bookmarksModule import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.network.networkModule @@ -67,6 +68,7 @@ class KotatsuApp : Application() { readerModule, appWidgetModule, suggestionsModule, + bookmarksModule, ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt new file mode 100644 index 000000000..650e816c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.base.ui.list + +import android.view.View +import android.view.View.OnClickListener +import android.view.View.OnLongClickListener +import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder + +class AdapterDelegateClickListenerAdapter( + private val adapterDelegate: AdapterDelegateViewBindingViewHolder, + private val clickListener: OnListItemClickListener, +) : OnClickListener, OnLongClickListener { + + override fun onClick(v: View) { + clickListener.onItemClick(adapterDelegate.item, v) + } + + override fun onLongClick(v: View): Boolean { + return clickListener.onItemLongClick(adapterDelegate.item, v) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt new file mode 100644 index 000000000..4a8294765 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.bookmarks + +import org.koin.dsl.module +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository + +val bookmarksModule + get() = module { + + factory { BookmarksRepository(get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt new file mode 100644 index 000000000..0959b3362 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import org.koitharu.kotatsu.core.db.entity.MangaEntity + +@Entity( + tableName = "bookmarks", + primaryKeys = ["manga_id", "page_id"], + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE + ), + ] +) +class BookmarkEntity( + @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, + @ColumnInfo(name = "page_id", index = true) val pageId: Long, + @ColumnInfo(name = "chapter_id") val chapterId: Long, + @ColumnInfo(name = "page") val page: Int, + @ColumnInfo(name = "scroll") val scroll: Int, + @ColumnInfo(name = "image") val imageUrl: String, + @ColumnInfo(name = "created_at") val createdAt: Long, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt new file mode 100644 index 000000000..4bd63d65d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity + +class BookmarkWithManga( + @Embedded val bookmark: BookmarkEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "manga_id" + ) + val manga: MangaEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "tag_id", + associateBy = Junction(MangaTagsEntity::class) + ) + val tags: List, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt new file mode 100644 index 000000000..dd023be7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class BookmarksDao { + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page") + abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC") + abstract fun observe(mangaId: Long): Flow> + + @Insert + abstract suspend fun insert(entity: BookmarkEntity) + + @Delete + abstract suspend fun delete(entity: BookmarkEntity) + + @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") + abstract suspend fun delete(mangaId: Long, pageId: Long) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt new file mode 100644 index 000000000..981aa05ea --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.bookmarks.data + +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.db.entity.toMangaTags +import org.koitharu.kotatsu.parsers.model.Manga +import java.util.* + +fun BookmarkWithManga.toBookmark() = bookmark.toBookmark( + manga.toManga(tags.toMangaTags()) +) + +fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( + manga = manga, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = Date(createdAt), +) + +fun Bookmark.toEntity() = BookmarkEntity( + mangaId = manga.id, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = createdAt.time, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt new file mode 100644 index 000000000..0b76c6537 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.bookmarks.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import java.util.* + +class Bookmark( + val manga: Manga, + val pageId: Long, + val chapterId: Long, + val page: Int, + val scroll: Int, + val imageUrl: String, + val createdAt: Date, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Bookmark + + if (manga != other.manga) return false + if (pageId != other.pageId) return false + if (chapterId != other.chapterId) return false + if (page != other.page) return false + if (scroll != other.scroll) return false + if (imageUrl != other.imageUrl) return false + if (createdAt != other.createdAt) return false + + return true + } + + override fun hashCode(): Int { + var result = manga.hashCode() + result = 31 * result + pageId.hashCode() + result = 31 * result + chapterId.hashCode() + result = 31 * result + page + result = 31 * result + scroll + result = 31 * result + imageUrl.hashCode() + result = 31 * result + createdAt.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt new file mode 100644 index 000000000..df63c03aa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt @@ -0,0 +1,38 @@ +package org.koitharu.kotatsu.bookmarks.domain + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.bookmarks.data.toBookmark +import org.koitharu.kotatsu.bookmarks.data.toEntity +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.toEntities +import org.koitharu.kotatsu.core.db.entity.toEntity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.mapItems + +class BookmarksRepository( + private val db: MangaDatabase, +) { + + fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow { + return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } + } + + fun observeBookmarks(manga: Manga): Flow> { + return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) } + } + + suspend fun addBookmark(bookmark: Bookmark) { + db.withTransaction { + val tags = bookmark.manga.tags.toEntities() + db.tagsDao.upsert(tags) + db.mangaDao.upsert(bookmark.manga.toEntity(), tags) + db.bookmarksDao.insert(bookmark.toEntity()) + } + } + + suspend fun removeBookmark(mangaId: Long, pageId: Long) { + db.bookmarksDao.delete(mangaId, pageId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt new file mode 100644 index 000000000..f8aa0e638 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.bookmarks.ui + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import coil.request.Disposable +import coil.size.Scale +import coil.util.CoilUtils +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.databinding.ItemBookmarkBinding +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.referer + +fun bookmarkListAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) } +) { + + var imageRequest: Disposable? = null + val listener = AdapterDelegateClickListenerAdapter(this, clickListener) + + binding.root.setOnClickListener(listener) + binding.root.setOnLongClickListener(listener) + + bind { + imageRequest?.dispose() + imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl) + .referer(item.manga.publicUrl) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .allowRgb565(true) + .scale(Scale.FILL) + .lifecycle(lifecycleOwner) + .enqueueWith(coil) + } + + onViewRecycled { + imageRequest?.dispose() + imageRequest = null + CoilUtils.dispose(binding.imageViewThumb) + binding.imageViewThumb.setImageDrawable(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt new file mode 100644 index 000000000..92040bc97 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.bookmarks.ui + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.bookmarks.domain.Bookmark + +class BookmarksAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter( + DiffCallback(), + bookmarkListAD(coil, lifecycleOwner, clickListener) +) { + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { + return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId + } + + override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { + return oldItem.imageUrl == newItem.imageUrl + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 436455014..0714c0fcc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity +import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.migrations.* @@ -20,9 +22,9 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity entities = [ MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, - TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class + TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ], - version = 10 + version = 11, ) abstract class MangaDatabase : RoomDatabase() { @@ -43,6 +45,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract val trackLogsDao: TrackLogsDao abstract val suggestionDao: SuggestionDao + + abstract val bookmarksDao: BookmarksDao } fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( @@ -59,6 +63,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( Migration7To8(), Migration8To9(), Migration9To10(), + Migration10To11(), ).addCallback( DatabasePrePopulateCallback(context.resources) ).build() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt new file mode 100644 index 000000000..5d80708fe --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration10To11 : Migration(10, 11) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `bookmarks` ( + `manga_id` INTEGER NOT NULL, + `page_id` INTEGER NOT NULL, + `chapter_id` INTEGER NOT NULL, + `page` INTEGER NOT NULL, + `scroll` INTEGER NOT NULL, + `image` TEXT NOT NULL, + `created_at` INTEGER NOT NULL, + PRIMARY KEY(`manga_id`, `page_id`), + FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt index 7e3bd8622..916b75de1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt @@ -8,6 +8,6 @@ val detailsModule get() = module { viewModel { intent -> - DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get()) + DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index a1920bf80..f6ecc6f0d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -83,6 +83,9 @@ class DetailsActivity : viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onError.observe(this, ::onError) + viewModel.onShowToast.observe(this) { + binding.snackbar.show(messageText = getString(it), longDuration = false) + } registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index 817909268..51d320e00 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.net.toUri import androidx.core.text.parseAsHtml +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader @@ -21,7 +22,11 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet @@ -41,7 +46,8 @@ class DetailsFragment : BaseFragment(), View.OnClickListener, View.OnLongClickListener, - ChipsView.OnChipClickListener { + ChipsView.OnChipClickListener, + OnListItemClickListener { private val viewModel by sharedViewModel() private val coil by inject(mode = LazyThreadSafetyMode.NONE) @@ -69,6 +75,7 @@ class DetailsFragment : viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) + viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -76,6 +83,24 @@ class DetailsFragment : inflater.inflate(R.menu.opt_details_info, menu) } + override fun onItemClick(item: Bookmark, view: View) { + val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.measuredWidth, view.measuredHeight) + startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle()) + } + + override fun onItemLongClick(item: Bookmark, view: View): Boolean { + val menu = PopupMenu(view.context, view) + menu.inflate(R.menu.popup_bookmark) + menu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_remove -> viewModel.removeBookmark(item) + } + true + } + menu.show() + return true + } + private fun onMangaUpdated(manga: Manga) { with(binding) { // Main @@ -176,6 +201,20 @@ class DetailsFragment : } } + private fun onBookmarksChanged(bookmarks: List) { + var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter + binding.groupBookmarks.isGone = bookmarks.isEmpty() + if (adapter != null) { + adapter.items = bookmarks + } else { + adapter = BookmarksAdapter(coil, viewLifecycleOwner, this) + adapter.items = bookmarks + binding.recyclerViewBookmarks.adapter = adapter + val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing) + binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing)) + } + } + override fun onClick(v: View) { val manga = viewModel.manga.value ?: return when (v.id) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 0ee363efd..c1624a6b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -10,9 +10,12 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings @@ -39,6 +42,7 @@ class DetailsViewModel( private val localMangaRepository: LocalMangaRepository, private val trackingRepository: TrackingRepository, private val mangaDataRepository: MangaDataRepository, + private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, ) : BaseViewModel() { @@ -46,6 +50,8 @@ class DetailsViewModel( private val mangaData = MutableStateFlow(intent.manga) private val selectedBranch = MutableStateFlow(null) + val onShowToast = SingleLiveEvent() + private val history = mangaData.mapNotNull { it?.id } .distinctUntilChanged() .flatMapLatest { mangaId -> @@ -85,6 +91,10 @@ class DetailsViewModel( val isChaptersReversed = chaptersReversed .asLiveData(viewModelScope.coroutineContext) + val bookmarks = mangaData.flatMapLatest { + if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + val onMangaRemoved = SingleLiveEvent() val branches = mangaData.map { @@ -149,6 +159,13 @@ class DetailsViewModel( } } + fun removeBookmark(bookmark: Bookmark) { + launchJob { + bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId) + onShowToast.call(R.string.bookmark_removed) + } + } + fun setChaptersReversed(newValue: Boolean) { settings.chaptersReverse = newValue } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 9a423b2e2..f65951d47 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -1,9 +1,9 @@ package org.koitharu.kotatsu.details.ui.adapter -import android.view.View import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem @@ -21,11 +21,7 @@ fun chapterListItemAD( { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } ) { - val eventListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item, v) - override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v) - } - + val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener) itemView.setOnClickListener(eventListener) itemView.setOnLongClickListener(eventListener) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt index 81c79e1ae..7583b2e8c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.view.* import androidx.appcompat.widget.SearchView import androidx.fragment.app.FragmentManager -import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseBottomSheet @@ -14,11 +13,14 @@ import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.utils.BottomSheetToolbarController -class FilterBottomSheet : BaseBottomSheet(), MenuItem.OnActionExpandListener, - SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { +class FilterBottomSheet : + BaseBottomSheet(), + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + DialogInterface.OnKeyListener { private val viewModel by sharedViewModel( - owner = { from(requireParentFragment(), requireParentFragment()) } + owner = { requireParentFragment() } ) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt index c83ad608b..a27fb9e8d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -26,6 +26,7 @@ val readerModule shortcutsRepository = get(), settings = get(), pageSaveHelper = get(), + bookmarksRepository = get(), ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index ca9228207..c54ed94d6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -29,6 +29,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity +import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.prefs.ReaderMode @@ -103,6 +104,12 @@ class ReaderActivity : onLoadingStateChanged(viewModel.isLoading.value == true) } viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) + viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) + viewModel.onShowToast.observe(this) { msgId -> + Snackbar.make(binding.container, msgId, Snackbar.LENGTH_SHORT) + .setAnchorView(binding.appbarBottom) + .show() + } } private fun onInitReader(mode: ReaderMode) { @@ -189,6 +196,13 @@ class ReaderActivity : viewModel.saveCurrentPage(page, savePageRequest) } ?: showWaitWhileLoading() } + R.id.action_bookmark -> { + if (viewModel.isBookmarkAdded.value == true) { + viewModel.removeBookmark() + } else { + viewModel.addBookmark() + } + } else -> return super.onOptionsItemSelected(item) } return true @@ -309,8 +323,8 @@ class ReaderActivity : val transition = TransitionSet() .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTransition(Slide(Gravity.TOP).addTarget(binding.appbarTop)) - binding.appbarBottom?.let { botomBar -> - transition.addTransition(Slide(Gravity.BOTTOM).addTarget(botomBar)) + binding.appbarBottom?.let { bottomBar -> + transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar)) } TransitionManager.beginDelayedTransition(binding.root, transition) binding.appbarTop.isVisible = isUiVisible @@ -351,6 +365,12 @@ class ReaderActivity : setUiIsVisible(!binding.appbarTop.isVisible) } + private fun onBookmarkStateChanged(isAdded: Boolean) { + val menuItem = binding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return + menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add) + menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) + } + private fun onUiStateChanged(uiState: ReaderUiState, previous: ReaderUiState?) { title = uiState.chapterName ?: uiState.mangaName ?: getString(R.string.loading_) supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) { @@ -419,6 +439,11 @@ class ReaderActivity : .putExtra(EXTRA_STATE, state) } + fun newIntent(context: Context, bookmark: Bookmark): Intent { + val state = ReaderState(bookmark.chapterId, bookmark.page, bookmark.scroll) + return newIntent(context, bookmark.manga, state) + } + fun newIntent(context: Context, mangaId: Long): Intent { return Intent(context, ReaderActivity::class.java) .putExtra(MangaIntent.KEY_ID, mangaId) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt index 0fe72b499..2eb7cc960 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt @@ -9,23 +9,20 @@ import org.koitharu.kotatsu.parsers.model.Manga data class ReaderState( val chapterId: Long, val page: Int, - val scroll: Int + val scroll: Int, ) : Parcelable { - companion object { + constructor(history: MangaHistory) : this( + chapterId = history.chapterId, + page = history.page, + scroll = history.scroll, + ) - fun from(history: MangaHistory) = ReaderState( - chapterId = history.chapterId, - page = history.page, - scroll = history.scroll - ) - - fun initial(manga: Manga, branch: String?) = ReaderState( - chapterId = manga.chapters?.firstOrNull { - it.branch == branch - }?.id ?: error("Cannot find first chapter"), - page = 0, - scroll = 0 - ) - } + constructor(manga: Manga, branch: String?) : this( + chapterId = manga.chapters?.firstOrNull { + it.branch == branch + }?.id ?: error("Cannot find first chapter"), + page = 0, + scroll = 0, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index bfe5ac663..f9ba0655f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri import android.util.LongSparseArray import androidx.activity.result.ActivityResultLauncher +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.* @@ -10,10 +11,13 @@ import kotlinx.coroutines.flow.* import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaUtils import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.parser.MangaRepository @@ -31,6 +35,7 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.IgnoreErrors import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.processLifecycleScope +import java.util.* class ReaderViewModel( private val intent: MangaIntent, @@ -39,12 +44,14 @@ class ReaderViewModel( private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val shortcutsRepository: ShortcutsRepository, + private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, private val pageSaveHelper: PageSaveHelper, ) : BaseViewModel() { private var loadingJob: Job? = null private var pageSaveJob: Job? = null + private var bookmarkJob: Job? = null private val currentState = MutableStateFlow(initialState) private val mangaData = MutableStateFlow(intent.manga) private val chapters = LongSparseArray() @@ -53,6 +60,7 @@ class ReaderViewModel( val readerMode = MutableLiveData() val onPageSaved = SingleLiveEvent() + val onShowToast = SingleLiveEvent() val uiState = combine( mangaData, currentState, @@ -89,6 +97,16 @@ class ReaderViewModel( val onZoomChanged = SingleLiveEvent() + val isBookmarkAdded: LiveData = currentState.flatMapLatest { state -> + val manga = mangaData.value + if (state == null || manga == null) { + flowOf(false) + } else { + bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) + .map { it != null } + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + init { loadImpl() subscribeToSettings() @@ -187,10 +205,9 @@ class ReaderViewModel( fun onCurrentPageChanged(position: Int) { val pages = content.value?.pages ?: return - pages.getOrNull(position)?.let { - val currentValue = currentState.value - if (currentValue != null && currentValue.chapterId != it.chapterId) { - currentState.value = currentValue.copy(chapterId = it.chapterId) + pages.getOrNull(position)?.let { page -> + currentState.update { cs -> + cs?.copy(chapterId = page.chapterId, page = page.index) } } if (pages.isEmpty() || loadingJob?.isActive == true) { @@ -207,6 +224,41 @@ class ReaderViewModel( } } + fun addBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob { + loadingJob?.join() + val state = checkNotNull(currentState.value) + val page = checkNotNull(getCurrentPage()) { "Page not found" } + val bookmark = Bookmark( + manga = checkNotNull(mangaData.value), + pageId = page.id, + chapterId = state.chapterId, + page = state.page, + scroll = state.scroll, + imageUrl = page.preview ?: pageLoader.getPageUrl(page), + createdAt = Date(), + ) + bookmarksRepository.addBookmark(bookmark) + onShowToast.call(R.string.bookmark_added) + } + } + + fun removeBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob { + loadingJob?.join() + val manga = checkNotNull(mangaData.value) + val page = checkNotNull(getCurrentPage()) { "Page not found" } + bookmarksRepository.removeBookmark(manga.id, page.id) + onShowToast.call(R.string.bookmark_removed) + } + } + private fun loadImpl() { loadingJob = launchLoadingJob(Dispatchers.Default) { var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") @@ -229,8 +281,8 @@ class ReaderViewModel( // obtain state if (currentState.value == null) { currentState.value = historyRepository.getOne(manga)?.let { - ReaderState.from(it) - } ?: ReaderState.initial(manga, preselectedBranch) + ReaderState(it) + } ?: ReaderState(manga, preselectedBranch) } val branch = chapters[currentState.value?.chapterId ?: 0L].branch @@ -259,7 +311,7 @@ class ReaderViewModel( val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } val repo = MangaRepository(manga.source) return repo.getPages(chapter).mapIndexed { index, page -> - ReaderPage.from(page, index, chapterId) + ReaderPage(page, index, chapterId) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt index bc3f3220e..6773f5dc3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt @@ -13,27 +13,24 @@ data class ReaderPage( val preview: String?, val chapterId: Long, val index: Int, - val source: MangaSource + val source: MangaSource, ) : Parcelable { + constructor(page: MangaPage, index: Int, chapterId: Long) : this( + id = page.id, + url = page.url, + referer = page.referer, + preview = page.preview, + chapterId = chapterId, + index = index, + source = page.source, + ) + fun toMangaPage() = MangaPage( id = id, url = url, referer = referer, preview = preview, - source = source + source = source, ) - - companion object { - - fun from(page: MangaPage, index: Int, chapterId: Long) = ReaderPage( - id = page.id, - url = page.url, - referer = page.referer, - preview = page.preview, - chapterId = chapterId, - index = index, - source = page.source - ) - } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 000000000..f5457e1bd --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark_added.xml b/app/src/main/res/drawable/ic_bookmark_added.xml new file mode 100644 index 000000000..9ebb3889b --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_added.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 936a0ac6c..6a862073a 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -157,6 +157,37 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/layout_titles" /> + + + + @@ -189,6 +220,14 @@ app:showAnimationBehavior="inward" tools:visibility="visible" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_details.xml b/app/src/main/res/layout/fragment_details.xml index e001bb43d..148179075 100644 --- a/app/src/main/res/layout/fragment_details.xml +++ b/app/src/main/res/layout/fragment_details.xml @@ -161,6 +161,37 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/button_read" /> + + + + @@ -193,6 +224,14 @@ app:showAnimationBehavior="inward" tools:visibility="visible" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookmark.xml b/app/src/main/res/layout/item_bookmark.xml new file mode 100644 index 000000000..78aab3400 --- /dev/null +++ b/app/src/main/res/layout/item_bookmark.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_reader_bottom.xml b/app/src/main/res/menu/opt_reader_bottom.xml index b56029f73..978a29d74 100644 --- a/app/src/main/res/menu/opt_reader_bottom.xml +++ b/app/src/main/res/menu/opt_reader_bottom.xml @@ -6,10 +6,9 @@ tools:ignore="AlwaysShowAction"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 282d15034..20debe81b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -13,6 +13,8 @@ 2dp 86dp 120dp + 120dp + 4dp 62dp 120dp 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d13563fab..13dde4b4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,241 +1,241 @@ Kotatsu - Close menu - Open menu - Local storage - Favourites - History - An error occurred - Could not connect to the Internet - Details - Chapters - List - Detailed list - Grid - List mode - Settings - Remote sources - Loading… - Computing… - Chapter %1$d of %2$d - Close - Try again - Clear history - Nothing found - No history yet - Read - No favourites yet - Favourite this - New category - Add - Enter category name - Save - Share - Create shortcut… - Share %s - Search - Search manga - Downloading… - Processing… - Downloaded - Downloads - Name - Popular - Updated - Newest - Rating - Sorting order - Filter - Theme - Light - Dark - Follow system - Pages - Clear - Clear all reading history permanently? - Remove - \"%s\" removed from history - \"%s\" deleted from local storage - Wait for loading to finish… - Save page - Saved - Share image - Import - Delete - This operation is not supported - Either pick a ZIP or CBZ file. - No description - History and cache - Clear page cache - Cache - B|kB|MB|GB|TB - Standard - Webtoon - Read mode - Grid size - Search on %s - Delete manga - Delete \"%s\" from device permanently? - Reader settings - Switch pages - Edge taps - Volume buttons - Continue - Warning - This may transfer a lot of data - Don\'t ask again - Cancelling… - Error - Clear thumbnails cache - Clear search history - Cleared - Gestures only - Internal storage - External storage - Domain - Check for new versions of the app - A new version of the app is available - Show notification if a new version is available - Open in web browser - This manga has %s. Save all of it? - Save - Notifications - %1$d of %2$d on - New chapters - Download - Read from start - Restart - Notifications settings - Notification sound - LED indicator - Vibration - Favourite categories - Categories… - Rename - Remove the \"%s\" category from your favourites? \nAll manga in it will be lost. - Remove - It\'s kind of empty here… - You can use categories to organize your favourites. Press «+» to create a category - Try to reformulate the query. - What you read will be displayed here - Find what to read in side menu. - Save something first - Save it from online sources or import files. - Shelf - Recent - Page animation - Folder for downloads - Not available - No available storage - Other storage + Close menu + Open menu + Local storage + Favourites + History + An error occurred + Could not connect to the Internet + Details + Chapters + List + Detailed list + Grid + List mode + Settings + Remote sources + Loading… + Computing… + Chapter %1$d of %2$d + Close + Try again + Clear history + Nothing found + No history yet + Read + No favourites yet + Favourite this + New category + Add + Enter category name + Save + Share + Create shortcut… + Share %s + Search + Search manga + Downloading… + Processing… + Downloaded + Downloads + Name + Popular + Updated + Newest + Rating + Sorting order + Filter + Theme + Light + Dark + Follow system + Pages + Clear + Clear all reading history permanently? + Remove + \"%s\" removed from history + \"%s\" deleted from local storage + Wait for loading to finish… + Save page + Saved + Share image + Import + Delete + This operation is not supported + Either pick a ZIP or CBZ file. + No description + History and cache + Clear page cache + Cache + B|kB|MB|GB|TB + Standard + Webtoon + Read mode + Grid size + Search on %s + Delete manga + Delete \"%s\" from device permanently? + Reader settings + Switch pages + Edge taps + Volume buttons + Continue + Warning + This may transfer a lot of data + Don\'t ask again + Cancelling… + Error + Clear thumbnails cache + Clear search history + Cleared + Gestures only + Internal storage + External storage + Domain + Check for new versions of the app + A new version of the app is available + Show notification if a new version is available + Open in web browser + This manga has %s. Save all of it? + Save + Notifications + %1$d of %2$d on + New chapters + Download + Read from start + Restart + Notifications settings + Notification sound + LED indicator + Vibration + Favourite categories + Categories… + Rename + Remove the \"%s\" category from your favourites? \nAll manga in it will be lost. + Remove + It\'s kind of empty here… + You can use categories to organize your favourites. Press «+» to create a category + Try to reformulate the query. + What you read will be displayed here + Find what to read in side menu. + Save something first + Save it from online sources or import files. + Shelf + Recent + Page animation + Folder for downloads + Not available + No available storage + Other storage Done - All favourites - Empty category - Read later - Updates - New chapters of what you are reading is shown here - Search results - Related - New version: %s - Size: %s - Waiting for network… - Clear updates feed - Cleared - Rotate screen - Update - Feed update will start soon - Look for updates - Don\'t check - Enter password - Wrong password - Protect the app - Ask for password when starting Kotatsu - Repeat the password - Mismatching passwords - About - Version %s - Check for updates - Checking for updates… - Could not look for updates - No updates available - Right-to-left (←) - Prefer right-to-left (←) reader - Reading mode can be set up separately for each series - New category - Scale mode - Fit center - Fit to height - Fit to width - Keep at start - Black - Uses less power on AMOLED screens - Restart required - Backup and restore - Create data backup - Restore from backup - Restored - Preparing… - Create issue on GitHub - File not found - All data was restored - The data was restored, but there are errors - You can create backup of your history and favourites and restore it - Just now - Yesterday - Long ago - Group - Today - Tap to try again - The chosen configuration will be remembered for this manga - Silent - CAPTCHA required - Solve - Clear cookies - All cookies were removed - Checking for new chapters: %1$d of %2$d - Clear feed - Clear all update history permanently? - Check for new chapters - Reverse - Sign in - Sign in to view this content - Default: %s - …and %1$d more - Next - Enter a password to start the app with - Confirm - The password must be 4 characters or more + All favourites + Empty category + Read later + Updates + New chapters of what you are reading is shown here + Search results + Related + New version: %s + Size: %s + Waiting for network… + Clear updates feed + Cleared + Rotate screen + Update + Feed update will start soon + Look for updates + Don\'t check + Enter password + Wrong password + Protect the app + Ask for password when starting Kotatsu + Repeat the password + Mismatching passwords + About + Version %s + Check for updates + Checking for updates… + Could not look for updates + No updates available + Right-to-left (←) + Prefer right-to-left (←) reader + Reading mode can be set up separately for each series + New category + Scale mode + Fit center + Fit to height + Fit to width + Keep at start + Black + Uses less power on AMOLED screens + Restart required + Backup and restore + Create data backup + Restore from backup + Restored + Preparing… + Create issue on GitHub + File not found + All data was restored + The data was restored, but there are errors + You can create backup of your history and favourites and restore it + Just now + Yesterday + Long ago + Group + Today + Tap to try again + The chosen configuration will be remembered for this manga + Silent + CAPTCHA required + Solve + Clear cookies + All cookies were removed + Checking for new chapters: %1$d of %2$d + Clear feed + Clear all update history permanently? + Check for new chapters + Reverse + Sign in + Sign in to view this content + Default: %s + …and %1$d more + Next + Enter a password to start the app with + Confirm + The password must be 4 characters or more Search only on %s - Remove all recent search queries permanently? - Other - Welcome - Backup saved - Some devices have different system behavior, which may break background tasks. - Read more - Queued - No active downloads - Download or read this missing chapter online. - The chapter is missing - Translate this app - Translation - Feedback - Topic on 4PDA + Remove all recent search queries permanently? + Other + Welcome + Backup saved + Some devices have different system behavior, which may break background tasks. + Read more + Queued + No active downloads + Download or read this missing chapter online. + The chapter is missing + Translate this app + Translation + Feedback + Topic on 4PDA Authorized - Logging in on %s is not supported - You will be logged out from all sources - Genres - Finished - Ongoing - Date format - Default - Exclude NSFW manga from history - You must enter a name - Numbered pages - Used sources - Available sources - Dynamic theme - Applies a theme created on the color scheme of your wallpaper + Logging in on %s is not supported + You will be logged out from all sources + Genres + Finished + Ongoing + Date format + Default + Exclude NSFW manga from history + You must enter a name + Numbered pages + Used sources + Available sources + Dynamic theme + Applies a theme created on the color scheme of your wallpaper Importing manga: %1$d of %2$d Screenshot policy Allow @@ -288,4 +288,9 @@ Edit Edit category No favourite categories + Add bookmark + Remove bookmark + Bookmarks + Bookmark removed + Bookmark added \ No newline at end of file