Reorganize navigation

pull/421/head
Koitharu 3 years ago
parent fb674b6028
commit 4739da2774
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.ui
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.coroutines.suspendCoroutine
abstract class BaseListAdapter : AsyncListDifferDelegationAdapter<ListModel>(ListModelDiffCallback),
FlowCollector<List<ListModel>> {
override suspend fun emit(value: List<ListModel>) = suspendCoroutine { cont ->
setItems(value, ContinuationResumeRunnable(cont))
}
fun addListListener(listListener: ListListener<ListModel>) {
differ.addListListener(listListener)
}
fun removeListListener(listListener: ListListener<ListModel>) {
differ.removeListListener(listListener)
}
}

@ -4,7 +4,6 @@ import android.content.res.Resources
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.core.util.ext.format import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Date import java.util.Date
sealed class DateTimeAgo { sealed class DateTimeAgo {
@ -77,6 +76,7 @@ sealed class DateTimeAgo {
} }
class DaysAgo(val days: Int) : DateTimeAgo() { class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days) return resources.getQuantityString(R.plurals.days_ago, days, days)
} }
@ -93,6 +93,30 @@ sealed class DateTimeAgo {
override fun toString() = "days_ago_$days" override fun toString() = "days_ago_$days"
} }
class MonthsAgo(val months: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return if (months == 0) {
resources.getString(R.string.this_month)
} else {
resources.getQuantityString(R.plurals.months_ago, months, months)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MonthsAgo
return months == other.months
}
override fun hashCode(): Int {
return months
}
}
class Absolute(private val date: Date) : DateTimeAgo() { class Absolute(private val date: Date) : DateTimeAgo() {
private val day = date.daysDiff(0) private val day = date.daysDiff(0)

@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.util
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class ContinuationResumeRunnable(
private val continuation: Continuation<Unit>,
) : Runnable {
override fun run() {
continuation.resume(Unit)
}
}

@ -30,7 +30,7 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
} }
fun Fragment.addMenuProvider(provider: MenuProvider) { fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED) requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
} }
@MainThread @MainThread

@ -133,7 +133,7 @@ class ExploreFragment :
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
startActivity(SettingsActivity.newManageSourcesIntent(context ?: return)) startActivity(SettingsActivity.newManageSourcesIntent(context ?: return))
} }
@ -185,9 +185,4 @@ class ExploreFragment :
return true return true
} }
} }
companion object {
fun newInstance() = ExploreFragment()
}
} }

@ -0,0 +1,54 @@
package org.koitharu.kotatsu.favourites.ui.container
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import kotlin.coroutines.suspendCoroutine
class FavouritesContainerAdapter(fragment: Fragment) : FragmentStateAdapter(
fragment.childFragmentManager,
fragment.viewLifecycleOwner.lifecycle,
),
TabConfigurationStrategy,
FlowCollector<List<FavouriteCategory>> {
private val differ = AsyncListDiffer(this, DiffCallback())
override fun getItemCount(): Int = differ.currentList.size
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
override fun createFragment(position: Int): Fragment {
return FavouritesListFragment.newInstance(getItemId(position))
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title
tab.tag = item
}
override suspend fun emit(value: List<FavouriteCategory>) = suspendCoroutine { cont ->
differ.submitList(value, ContinuationResumeRunnable(cont))
}
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.title == newItem.title
}
}
}

@ -0,0 +1,66 @@
package org.koitharu.kotatsu.favourites.ui.container
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding
@AndroidEntryPoint
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesContainerBinding>(), ActionModeListener {
private val viewModel: FavouritesContainerViewModel by viewModels()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentFavouritesContainerBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentFavouritesContainerBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = FavouritesContainerAdapter(this)
binding.pager.adapter = adapter
TabLayoutMediator(
binding.tabs,
binding.pager,
adapter,
).attach()
binding.pager.offscreenPageLimit = 1
actionModeDelegate.addListener(this)
viewModel.categories.observe(viewLifecycleOwner, adapter)
}
override fun onDestroyView() {
actionModeDelegate.removeListener(this)
super.onDestroyView()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding?.tabs?.updatePadding(
left = insets.left,
right = insets.right,
)
}
override fun onActionModeStarted(mode: ActionMode) {
viewBinding?.run {
pager.isUserInputEnabled = false
tabs.isEnabled = false
}
}
override fun onActionModeFinished(mode: ActionMode) {
viewBinding?.run {
pager.isUserInputEnabled = true
tabs.isEnabled = true
}
}
}

@ -0,0 +1,14 @@
package org.koitharu.kotatsu.favourites.ui.container
import dagger.hilt.android.lifecycle.HiltViewModel
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import javax.inject.Inject
@HiltViewModel
class FavouritesContainerViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val categories = favouritesRepository.observeCategories()
}

@ -29,7 +29,7 @@ class HistoryActivity :
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
setReorderingAllowed(true) setReorderingAllowed(true)
val fragment = HistoryListFragment.newInstance() val fragment = HistoryListFragment()
replace(R.id.container, fragment) replace(R.id.container, fragment)
} }
} }

@ -4,9 +4,9 @@ import android.content.Context
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
class HistoryListAdapter( class HistoryListAdapter(
coil: ImageLoader, coil: ImageLoader,
@ -18,8 +18,8 @@ class HistoryListAdapter(
val list = items val list = items
for (i in (0..position).reversed()) { for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue val item = list.getOrNull(i) ?: continue
if (item is DateTimeAgo) { if (item is ListHeader) {
return item.format(context.resources) return item.getText(context)
} }
} }
return null return null

@ -55,9 +55,4 @@ class HistoryListFragment : MangaListFragment() {
} }
override fun onCreateAdapter() = HistoryListAdapter(coil, viewLifecycleOwner, this) override fun onCreateAdapter() = HistoryListAdapter(coil, viewLifecycleOwner, this)
companion object {
fun newInstance() = HistoryListFragment()
}
} }

@ -5,9 +5,13 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.startOfDay
import java.util.Date
import java.util.concurrent.TimeUnit
import com.google.android.material.R as materialR
class HistoryListMenuProvider( class HistoryListMenuProvider(
private val context: Context, private val context: Context,
@ -20,24 +24,45 @@ class HistoryListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_clear_history -> { R.id.action_clear_history -> {
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) showClearHistoryDialog()
.setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearHistory()
}.show()
true true
} }
R.id.action_history_grouping -> { R.id.action_history_grouping -> {
viewModel.setGrouping(!menuItem.isChecked) viewModel.setGrouping(!menuItem.isChecked)
true true
} }
else -> false else -> false
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_history_grouping)?.isChecked = viewModel.isGroupingEnabled.value == true menu.findItem(R.id.action_history_grouping)?.isChecked = viewModel.isGroupingEnabled.value == true
} }
}
private fun showClearHistoryDialog() {
val selectionListener = RememberSelectionDialogListener(2)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_history)
.setSingleChoiceItems(
arrayOf(
context.getString(R.string.last_2_hours),
context.getString(R.string.today),
context.getString(R.string.clear_all_history),
),
selectionListener.selection,
selectionListener,
)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
val minDate = when (selectionListener.selection) {
0 -> System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
1 -> Date().startOfDay()
2 -> 0L
else -> return@setPositiveButton
}
viewModel.clearHistory(minDate)
}.show()
}
}

@ -78,9 +78,16 @@ class HistoryListViewModel @Inject constructor(
override fun onRetry() = Unit override fun onRetry() = Unit
fun clearHistory() { fun clearHistory(minDate: Long) {
launchLoadingJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
repository.clear() val stringRes = if (minDate <= 0) {
repository.clear()
R.string.history_cleared
} else {
repository.deleteAfter(minDate)
R.string.removed_from_history
}
onActionDone.call(ReversibleAction(stringRes, null))
} }
} }
@ -131,6 +138,7 @@ class HistoryListViewModel @Inject constructor(
diffDays < 1 -> DateTimeAgo.Today diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays) diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
diffDays < 200 -> DateTimeAgo.MonthsAgo(diffDays / 30)
else -> DateTimeAgo.LongAgo else -> DateTimeAgo.LongAgo
} }
} }

@ -2,15 +2,13 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
open class MangaListAdapter( open class MangaListAdapter(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
listener: MangaListListener, listener: MangaListListener,
) : AsyncListDifferDelegationAdapter<ListModel>(ListModelDiffCallback) { ) : BaseListAdapter() {
init { init {
delegatesManager delegatesManager

@ -5,14 +5,13 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.SparseIntArray import android.util.SparseIntArray
import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.util.size import androidx.core.util.size
@ -39,21 +38,18 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.VoiceInputContract
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.hideKeyboard import org.koitharu.kotatsu.core.util.ext.hideKeyboard
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.resolve
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
@ -66,30 +62,23 @@ import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner, View.OnClickListener,
BaseActivity<ActivityMainBinding>(), View.OnFocusChangeListener, SearchSuggestionListener, MainNavigationDelegate.OnFragmentChangedListener {
AppBarOwner,
BottomNavOwner,
View.OnClickListener,
View.OnFocusChangeListener,
SearchSuggestionListener,
MainNavigationDelegate.OnFragmentChangedListener {
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private val viewModel by viewModels<MainViewModel>() private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>() private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
private val closeSearchCallback = CloseSearchCallback() private val closeSearchCallback = CloseSearchCallback()
private lateinit var navigationDelegate: MainNavigationDelegate private lateinit var navigationDelegate: MainNavigationDelegate
@ -122,7 +111,6 @@ class MainActivity :
viewBinding.fab?.setOnClickListener(this) viewBinding.fab?.setOnClickListener(this)
viewBinding.navRail?.headerView?.setOnClickListener(this) viewBinding.navRail?.headerView?.setOnClickListener(this)
viewBinding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
navigationDelegate = MainNavigationDelegate( navigationDelegate = MainNavigationDelegate(
navBar = checkNotNull(bottomNav ?: viewBinding.navRail), navBar = checkNotNull(bottomNav ?: viewBinding.navRail),
@ -162,18 +150,41 @@ class MainActivity :
} }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
if (item.itemId == android.R.id.home && !isSearchOpened()) { super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_main, menu)
return true
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
menu?.findItem(R.id.action_incognito)?.isChecked = searchSuggestionViewModel.isIncognitoModeEnabled.value
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> if (isSearchOpened()) {
super.onOptionsItemSelected(item)
} else {
viewBinding.searchView.requestFocus() viewBinding.searchView.requestFocus()
return true true
} }
return super.onOptionsItemSelected(item)
R.id.action_settings -> {
startActivity(SettingsActivity.newIntent(this))
true
}
R.id.action_incognito -> {
viewModel.setIncognitoMode(!item.isChecked)
true
}
else -> super.onOptionsItemSelected(item)
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.fab -> viewModel.openLastReader() R.id.fab, R.id.railFab -> viewModel.openLastReader()
R.id.railFab -> viewModel.openLastReader()
} }
} }
@ -220,19 +231,6 @@ class MainActivity :
searchSuggestionViewModel.onQueryChanged(query) searchSuggestionViewModel.onQueryChanged(query)
} }
override fun onVoiceSearchClick() {
val options = viewBinding.searchView.drawableEnd?.bounds?.let { bounds ->
ActivityOptionsCompat.makeScaleUpAnimation(
viewBinding.searchView,
bounds.centerX(),
bounds.centerY(),
bounds.width(),
bounds.height(),
)
}
voiceInputLauncher.tryLaunch(viewBinding.searchView.hint?.toString(), options)
}
override fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { override fun onSourceToggle(source: MangaSource, isEnabled: Boolean) {
searchSuggestionViewModel.onSourceToggle(source, isEnabled) searchSuggestionViewModel.onSourceToggle(source, isEnabled)
} }
@ -282,6 +280,7 @@ class MainActivity :
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
} }
viewBinding.searchView.imeOptions = options viewBinding.searchView.imeOptions = options
invalidateMenu()
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
@ -334,12 +333,7 @@ class MainActivity :
isSearchOpened: Boolean = isSearchOpened(), isSearchOpened: Boolean = isSearchOpened(),
) { ) {
val fab = viewBinding.fab ?: return val fab = viewBinding.fab ?: return
if ( if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) {
isResumeEnabled &&
!actionModeDelegate.isActionModeStarted &&
!isSearchOpened &&
topFragment is ShelfFragment
) {
if (!fab.isVisible) { if (!fab.isVisible) {
fab.show() fab.show()
} }
@ -376,23 +370,15 @@ class MainActivity :
} }
private fun requestNotificationsPermission() { private fun requestNotificationsPermission() {
if ( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && this,
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED Manifest.permission.POST_NOTIFICATIONS,
) != PERMISSION_GRANTED
) { ) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
} }
} }
private inner class VoiceInputCallback : ActivityResultCallback<String?> {
override fun onActivityResult(result: String?) {
if (result != null) {
viewBinding.searchView.query = result
}
}
}
private inner class CloseSearchCallback : OnBackPressedCallback(false) { private inner class CloseSearchCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {

@ -13,8 +13,8 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.explore.ui.ExploreFragment import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.settings.tools.ToolsFragment import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
import java.util.LinkedList import java.util.LinkedList
@ -53,7 +53,7 @@ class MainNavigationDelegate(
} }
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
navBar.selectedItemId = R.id.nav_shelf navBar.selectedItemId = R.id.nav_history
} }
fun onCreate() { fun onCreate() {
@ -99,20 +99,20 @@ class MainNavigationDelegate(
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean { private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
return setPrimaryFragment( return setPrimaryFragment(
when (itemId) { when (itemId) {
R.id.nav_shelf -> ShelfFragment.newInstance() R.id.nav_history -> HistoryListFragment()
R.id.nav_explore -> ExploreFragment.newInstance() R.id.nav_favourites -> FavouritesContainerFragment()
R.id.nav_feed -> FeedFragment.newInstance() R.id.nav_explore -> ExploreFragment()
R.id.nav_tools -> ToolsFragment.newInstance() R.id.nav_feed -> FeedFragment()
else -> return false else -> return false
}, },
) )
} }
private fun getItemId(fragment: Fragment) = when (fragment) { private fun getItemId(fragment: Fragment) = when (fragment) {
is ShelfFragment -> R.id.nav_shelf is HistoryListFragment -> R.id.nav_history
is FavouritesContainerFragment -> R.id.nav_favourites
is ExploreFragment -> R.id.nav_explore is ExploreFragment -> R.id.nav_explore
is FeedFragment -> R.id.nav_feed is FeedFragment -> R.id.nav_feed
is ToolsFragment -> R.id.nav_tools
else -> 0 else -> 0
} }
@ -130,7 +130,7 @@ class MainNavigationDelegate(
} }
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
isEnabled = fragment !is ShelfFragment isEnabled = fragment !is HistoryListFragment
listeners.forEach { it.onFragmentChanged(fragment, fromUser) } listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
} }

@ -28,7 +28,7 @@ class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val appUpdateRepository: AppUpdateRepository, private val appUpdateRepository: AppUpdateRepository,
trackingRepository: TrackingRepository, trackingRepository: TrackingRepository,
settings: AppSettings, private val settings: AppSettings,
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase, readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
@ -51,7 +51,7 @@ class MainViewModel @Inject constructor(
trackingRepository.observeUpdatedMangaCount(), trackingRepository.observeUpdatedMangaCount(),
) { appUpdate, tracks -> ) { appUpdate, tracks ->
val a = SparseIntArray(2) val a = SparseIntArray(2)
a[R.id.nav_tools] = if (appUpdate != null) 1 else 0 // a[R.id.nav_tools] = if (appUpdate != null) 1 else 0
a[R.id.nav_feed] = tracks a[R.id.nav_feed] = tracks
a a
}.stateIn( }.stateIn(
@ -72,4 +72,8 @@ class MainViewModel @Inject constructor(
onOpenReader.call(manga) onOpenReader.call(manga)
} }
} }
fun setIncognitoMode(isEnabled: Boolean) {
settings.isIncognitoModeEnabled = isEnabled
}
} }

@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ItemTouchHelper
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.VoiceInputContract
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@ -26,6 +27,11 @@ class SearchSuggestionFragment :
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private val viewModel by activityViewModels<SearchSuggestionViewModel>() private val viewModel by activityViewModels<SearchSuggestionViewModel>()
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result ->
if (result != null) {
viewModel.onQueryChanged(result)
}
}
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -39,7 +45,7 @@ class SearchSuggestionFragment :
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
listener = requireActivity() as SearchSuggestionListener, listener = requireActivity() as SearchSuggestionListener,
) )
addMenuProvider(SearchSuggestionMenuProvider(binding.root.context, viewModel)) addMenuProvider(SearchSuggestionMenuProvider(binding.root.context, voiceInputLauncher, viewModel))
binding.root.adapter = adapter binding.root.adapter = adapter
binding.root.setHasFixedSize(true) binding.root.setHasFixedSize(true)
viewModel.suggestion.observe(viewLifecycleOwner) { viewModel.suggestion.observe(viewLifecycleOwner) {

@ -17,6 +17,4 @@ interface SearchSuggestionListener {
fun onSourceClick(source: MangaSource) fun onSourceClick(source: MangaSource)
fun onTagClick(tag: MangaTag) fun onTagClick(tag: MangaTag)
}
fun onVoiceSearchClick()
}

@ -4,13 +4,17 @@ import android.content.Context
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 androidx.activity.result.ActivityResultLauncher
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.resolve
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class SearchSuggestionMenuProvider( class SearchSuggestionMenuProvider(
private val context: Context, private val context: Context,
private val voiceInputLauncher: ActivityResultLauncher<String?>,
private val viewModel: SearchSuggestionViewModel, private val viewModel: SearchSuggestionViewModel,
) : MenuProvider { ) : MenuProvider {
@ -24,10 +28,20 @@ class SearchSuggestionMenuProvider(
clearSearchHistory() clearSearchHistory()
true true
} }
R.id.action_voice_search -> {
voiceInputLauncher.tryLaunch(context.getString(R.string.search_manga), null)
}
else -> false else -> false
} }
} }
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_voice_search)?.isVisible = voiceInputLauncher.resolve(context, null) != null
}
private fun clearSearchHistory() { private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_search_history) .setTitle(R.string.clear_search_history)
@ -38,4 +52,4 @@ class SearchSuggestionMenuProvider(
viewModel.clearSearchHistory() viewModel.clearSearchHistory()
}.show() }.show()
} }
} }

@ -15,7 +15,6 @@ import android.view.inputmethod.EditorInfo
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
@ -31,19 +30,12 @@ class SearchEditText @JvmOverloads constructor(
var searchSuggestionListener: SearchSuggestionListener? = null var searchSuggestionListener: SearchSuggestionListener? = null
private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material) private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
private val voiceIcon = ContextCompat.getDrawable(context, R.drawable.ic_voice_input)
private var isEmpty = text.isNullOrEmpty() private var isEmpty = text.isNullOrEmpty()
init { init {
wrapHint() wrapHint()
} }
var isVoiceSearchEnabled: Boolean = false
set(value) {
field = value
updateActionIcon()
}
var query: String var query: String
get() = text?.trim()?.toString().orEmpty() get() = text?.trim()?.toString().orEmpty()
set(value) { set(value) {
@ -117,14 +109,12 @@ class SearchEditText @JvmOverloads constructor(
private fun onActionIconClick() { private fun onActionIconClick() {
when { when {
!text.isNullOrEmpty() -> text?.clear() !text.isNullOrEmpty() -> text?.clear()
isVoiceSearchEnabled -> searchSuggestionListener?.onVoiceSearchClick()
} }
} }
private fun updateActionIcon() { private fun updateActionIcon() {
val icon = when { val icon = when {
!text.isNullOrEmpty() -> clearIcon !text.isNullOrEmpty() -> clearIcon
isVoiceSearchEnabled -> voiceIcon
else -> null else -> null
} }
if (icon !== drawableEnd) { if (icon !== drawableEnd) {

@ -19,6 +19,10 @@ abstract class SuggestionDao {
@Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit") @Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<SuggestionWithManga>> abstract fun observeAll(limit: Int): Flow<List<SuggestionWithManga>>
@Transaction
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
abstract suspend fun getRandom(): SuggestionWithManga?
@Query("SELECT COUNT(*) FROM suggestions") @Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int abstract suspend fun count(): Int

@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionWithManga
import javax.inject.Inject import javax.inject.Inject
class SuggestionRepository @Inject constructor( class SuggestionRepository @Inject constructor(
@ -28,6 +29,10 @@ class SuggestionRepository @Inject constructor(
} }
} }
suspend fun getRandom(): SuggestionWithManga? {
return db.suggestionDao.getRandom()
}
suspend fun clear() { suspend fun clear() {
db.suggestionDao.deleteAll() db.suggestionDao.deleteAll()
} }

@ -140,9 +140,4 @@ class FeedFragment :
override fun onReadClick(manga: Manga, view: View) = Unit override fun onReadClick(manga: Manga, view: View) = Unit
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) = Unit override fun onTagClick(manga: Manga, tag: MangaTag, view: View) = Unit
companion object {
fun newInstance() = FeedFragment()
}
} }

@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
@ -14,6 +13,7 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class FeedAdapter( class FeedAdapter(
@ -36,8 +36,8 @@ class FeedAdapter(
val list = items val list = items
for (i in (0..position).reversed()) { for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue val item = list.getOrNull(i) ?: continue
if (item is DateTimeAgo) { if (item is ListHeader) {
return item.format(context.resources) return item.getText(context)
} }
} }
return null return null

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="0dp"
app:elevation="0dp"
app:liftOnScroll="false">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="start"
app:tabMode="scrollable" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -3,9 +3,14 @@
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android">
<item <item
android:id="@+id/nav_shelf" android:id="@+id/nav_history"
android:icon="@drawable/ic_bookshelf_selector" android:icon="@drawable/ic_history"
android:title="@string/manga_shelf" /> android:title="@string/history" />
<item
android:id="@+id/nav_favourites"
android:icon="@drawable/ic_heart_outline"
android:title="@string/favourites" />
<item <item
android:id="@+id/nav_explore" android:id="@+id/nav_explore"
@ -17,9 +22,4 @@
android:icon="@drawable/ic_feed_selector" android:icon="@drawable/ic_feed_selector"
android:title="@string/feed" /> android:title="@string/feed" />
<item
android:id="@+id/nav_tools"
android:icon="@drawable/ic_tools_selector"
android:title="@string/options" />
</menu> </menu>

@ -7,14 +7,14 @@
android:id="@+id/action_history_grouping" android:id="@+id/action_history_grouping"
android:checkable="true" android:checkable="true"
android:checked="true" android:checked="true"
android:orderInCategory="15" android:orderInCategory="25"
android:title="@string/group" android:title="@string/group"
app:showAsAction="never" /> app:showAsAction="never" />
<item <item
android:id="@+id/action_clear_history" android:id="@+id/action_clear_history"
android:orderInCategory="50" android:orderInCategory="10"
android:title="@string/clear_history" android:title="@string/clear_history"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

@ -9,4 +9,4 @@
android:title="@string/list_mode" android:title="@string/list_mode"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_incognito"
android:checkable="true"
android:orderInCategory="90"
android:title="@string/incognito_mode"
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="90"
android:title="@string/settings"
app:showAsAction="never" />
</menu>

@ -3,9 +3,16 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_voice_search"
android:icon="@drawable/ic_voice_input"
android:orderInCategory="0"
android:title="@string/voice_search"
app:showAsAction="ifRoom" />
<item <item
android:id="@+id/action_clear" android:id="@+id/action_clear"
android:title="@string/clear_search_history" android:title="@string/clear_search_history"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

@ -24,4 +24,8 @@
<item quantity="one">%1$d day ago</item> <item quantity="one">%1$d day ago</item>
<item quantity="other">%1$d days ago</item> <item quantity="other">%1$d days ago</item>
</plurals> </plurals>
<plurals name="months_ago">
<item quantity="one">%1$d month ago</item>
<item quantity="other">%1$d months ago</item>
</plurals>
</resources> </resources>

@ -450,4 +450,6 @@
<string name="no_access_to_file">You have no access to this file or directory</string> <string name="no_access_to_file">You have no access to this file or directory</string>
<string name="local_manga_directories">Local manga directories</string> <string name="local_manga_directories">Local manga directories</string>
<string name="description">Description</string> <string name="description">Description</string>
<string name="this_month">This month</string>
<string name="voice_search">Voice search</string>
</resources> </resources>

Loading…
Cancel
Save