Reorganize navigation
This commit is contained in:
@@ -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.core.util.ext.daysDiff
|
||||
import org.koitharu.kotatsu.core.util.ext.format
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.util.Date
|
||||
|
||||
sealed class DateTimeAgo {
|
||||
@@ -77,6 +76,7 @@ sealed class DateTimeAgo {
|
||||
}
|
||||
|
||||
class DaysAgo(val days: Int) : DateTimeAgo() {
|
||||
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.days_ago, days, days)
|
||||
}
|
||||
@@ -93,6 +93,30 @@ sealed class DateTimeAgo {
|
||||
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() {
|
||||
|
||||
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) {
|
||||
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED)
|
||||
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
|
||||
@@ -133,7 +133,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
override fun onEmptyActionClick() {
|
||||
startActivity(SettingsActivity.newManageSourcesIntent(context ?: return))
|
||||
}
|
||||
|
||||
@@ -185,9 +185,4 @@ class ExploreFragment :
|
||||
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) {
|
||||
fm.commit {
|
||||
setReorderingAllowed(true)
|
||||
val fragment = HistoryListFragment.newInstance()
|
||||
val fragment = HistoryListFragment()
|
||||
replace(R.id.container, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import android.content.Context
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
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.MangaListListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
|
||||
class HistoryListAdapter(
|
||||
coil: ImageLoader,
|
||||
@@ -18,8 +18,8 @@ class HistoryListAdapter(
|
||||
val list = items
|
||||
for (i in (0..position).reversed()) {
|
||||
val item = list.getOrNull(i) ?: continue
|
||||
if (item is DateTimeAgo) {
|
||||
return item.format(context.resources)
|
||||
if (item is ListHeader) {
|
||||
return item.getText(context)
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -55,9 +55,4 @@ class HistoryListFragment : MangaListFragment() {
|
||||
}
|
||||
|
||||
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.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.R as materialR
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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(
|
||||
private val context: Context,
|
||||
@@ -20,24 +24,45 @@ class HistoryListMenuProvider(
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_clear_history -> {
|
||||
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
|
||||
.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()
|
||||
showClearHistoryDialog()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_history_grouping -> {
|
||||
viewModel.setGrouping(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
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
|
||||
|
||||
fun clearHistory() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
repository.clear()
|
||||
fun clearHistory(minDate: Long) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
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.Yesterday
|
||||
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
|
||||
diffDays < 200 -> DateTimeAgo.MonthsAgo(diffDays / 30)
|
||||
else -> DateTimeAgo.LongAgo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@ package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
|
||||
open class MangaListAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: MangaListListener,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(ListModelDiffCallback) {
|
||||
) : BaseListAdapter() {
|
||||
|
||||
init {
|
||||
delegatesManager
|
||||
|
||||
@@ -5,14 +5,13 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.SparseIntArray
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.util.size
|
||||
@@ -39,21 +38,18 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
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.ui.BaseActivity
|
||||
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.observe
|
||||
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.setNavigationBarTransparentCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
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.main.ui.owners.AppBarOwner
|
||||
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.SearchSuggestionListener
|
||||
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.onboard.OnboardDialogFragment
|
||||
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val TAG_SEARCH = "search"
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity :
|
||||
BaseActivity<ActivityMainBinding>(),
|
||||
AppBarOwner,
|
||||
BottomNavOwner,
|
||||
View.OnClickListener,
|
||||
View.OnFocusChangeListener,
|
||||
SearchSuggestionListener,
|
||||
MainNavigationDelegate.OnFragmentChangedListener {
|
||||
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner, View.OnClickListener,
|
||||
View.OnFocusChangeListener, SearchSuggestionListener, MainNavigationDelegate.OnFragmentChangedListener {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val viewModel by viewModels<MainViewModel>()
|
||||
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
|
||||
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
|
||||
private val closeSearchCallback = CloseSearchCallback()
|
||||
private lateinit var navigationDelegate: MainNavigationDelegate
|
||||
|
||||
@@ -122,7 +111,6 @@ class MainActivity :
|
||||
|
||||
viewBinding.fab?.setOnClickListener(this)
|
||||
viewBinding.navRail?.headerView?.setOnClickListener(this)
|
||||
viewBinding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
|
||||
|
||||
navigationDelegate = MainNavigationDelegate(
|
||||
navBar = checkNotNull(bottomNav ?: viewBinding.navRail),
|
||||
@@ -162,18 +150,41 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home && !isSearchOpened()) {
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
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()
|
||||
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) {
|
||||
when (v.id) {
|
||||
R.id.fab -> viewModel.openLastReader()
|
||||
R.id.railFab -> viewModel.openLastReader()
|
||||
R.id.fab, R.id.railFab -> viewModel.openLastReader()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,19 +231,6 @@ class MainActivity :
|
||||
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) {
|
||||
searchSuggestionViewModel.onSourceToggle(source, isEnabled)
|
||||
}
|
||||
@@ -282,6 +280,7 @@ class MainActivity :
|
||||
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
|
||||
}
|
||||
viewBinding.searchView.imeOptions = options
|
||||
invalidateMenu()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
@@ -334,12 +333,7 @@ class MainActivity :
|
||||
isSearchOpened: Boolean = isSearchOpened(),
|
||||
) {
|
||||
val fab = viewBinding.fab ?: return
|
||||
if (
|
||||
isResumeEnabled &&
|
||||
!actionModeDelegate.isActionModeStarted &&
|
||||
!isSearchOpened &&
|
||||
topFragment is ShelfFragment
|
||||
) {
|
||||
if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) {
|
||||
if (!fab.isVisible) {
|
||||
fab.show()
|
||||
}
|
||||
@@ -376,23 +370,15 @@ class MainActivity :
|
||||
}
|
||||
|
||||
private fun requestNotificationsPermission() {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PERMISSION_GRANTED
|
||||
) {
|
||||
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) {
|
||||
|
||||
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.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.explore.ui.ExploreFragment
|
||||
import org.koitharu.kotatsu.settings.tools.ToolsFragment
|
||||
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
|
||||
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
|
||||
import org.koitharu.kotatsu.history.ui.HistoryListFragment
|
||||
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
|
||||
import java.util.LinkedList
|
||||
|
||||
@@ -53,7 +53,7 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
navBar.selectedItemId = R.id.nav_shelf
|
||||
navBar.selectedItemId = R.id.nav_history
|
||||
}
|
||||
|
||||
fun onCreate() {
|
||||
@@ -99,20 +99,20 @@ class MainNavigationDelegate(
|
||||
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
|
||||
return setPrimaryFragment(
|
||||
when (itemId) {
|
||||
R.id.nav_shelf -> ShelfFragment.newInstance()
|
||||
R.id.nav_explore -> ExploreFragment.newInstance()
|
||||
R.id.nav_feed -> FeedFragment.newInstance()
|
||||
R.id.nav_tools -> ToolsFragment.newInstance()
|
||||
R.id.nav_history -> HistoryListFragment()
|
||||
R.id.nav_favourites -> FavouritesContainerFragment()
|
||||
R.id.nav_explore -> ExploreFragment()
|
||||
R.id.nav_feed -> FeedFragment()
|
||||
else -> return false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 FeedFragment -> R.id.nav_feed
|
||||
is ToolsFragment -> R.id.nav_tools
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
|
||||
isEnabled = fragment !is ShelfFragment
|
||||
isEnabled = fragment !is HistoryListFragment
|
||||
listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class MainViewModel @Inject constructor(
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
trackingRepository: TrackingRepository,
|
||||
settings: AppSettings,
|
||||
private val settings: AppSettings,
|
||||
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
|
||||
) : BaseViewModel() {
|
||||
|
||||
@@ -51,7 +51,7 @@ class MainViewModel @Inject constructor(
|
||||
trackingRepository.observeUpdatedMangaCount(),
|
||||
) { appUpdate, tracks ->
|
||||
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
|
||||
}.stateIn(
|
||||
@@ -72,4 +72,8 @@ class MainViewModel @Inject constructor(
|
||||
onOpenReader.call(manga)
|
||||
}
|
||||
}
|
||||
|
||||
fun setIncognitoMode(isEnabled: Boolean) {
|
||||
settings.isIncognitoModeEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.VoiceInputContract
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -26,6 +27,11 @@ class SearchSuggestionFragment :
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by activityViewModels<SearchSuggestionViewModel>()
|
||||
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result ->
|
||||
if (result != null) {
|
||||
viewModel.onQueryChanged(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
@@ -39,7 +45,7 @@ class SearchSuggestionFragment :
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
listener = requireActivity() as SearchSuggestionListener,
|
||||
)
|
||||
addMenuProvider(SearchSuggestionMenuProvider(binding.root.context, viewModel))
|
||||
addMenuProvider(SearchSuggestionMenuProvider(binding.root.context, voiceInputLauncher, viewModel))
|
||||
binding.root.adapter = adapter
|
||||
binding.root.setHasFixedSize(true)
|
||||
viewModel.suggestion.observe(viewLifecycleOwner) {
|
||||
|
||||
@@ -17,6 +17,4 @@ interface SearchSuggestionListener {
|
||||
fun onSourceClick(source: MangaSource)
|
||||
|
||||
fun onTagClick(tag: MangaTag)
|
||||
|
||||
fun onVoiceSearchClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,17 @@ import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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
|
||||
|
||||
class SearchSuggestionMenuProvider(
|
||||
private val context: Context,
|
||||
private val voiceInputLauncher: ActivityResultLauncher<String?>,
|
||||
private val viewModel: SearchSuggestionViewModel,
|
||||
) : MenuProvider {
|
||||
|
||||
@@ -24,10 +28,20 @@ class SearchSuggestionMenuProvider(
|
||||
clearSearchHistory()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_voice_search -> {
|
||||
voiceInputLauncher.tryLaunch(context.getString(R.string.search_manga), null)
|
||||
}
|
||||
|
||||
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() {
|
||||
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
|
||||
.setTitle(R.string.clear_search_history)
|
||||
@@ -38,4 +52,4 @@ class SearchSuggestionMenuProvider(
|
||||
viewModel.clearSearchHistory()
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import android.view.inputmethod.EditorInfo
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
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.drawableStart
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
@@ -31,19 +30,12 @@ class SearchEditText @JvmOverloads constructor(
|
||||
|
||||
var searchSuggestionListener: SearchSuggestionListener? = null
|
||||
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()
|
||||
|
||||
init {
|
||||
wrapHint()
|
||||
}
|
||||
|
||||
var isVoiceSearchEnabled: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
updateActionIcon()
|
||||
}
|
||||
|
||||
var query: String
|
||||
get() = text?.trim()?.toString().orEmpty()
|
||||
set(value) {
|
||||
@@ -117,14 +109,12 @@ class SearchEditText @JvmOverloads constructor(
|
||||
private fun onActionIconClick() {
|
||||
when {
|
||||
!text.isNullOrEmpty() -> text?.clear()
|
||||
isVoiceSearchEnabled -> searchSuggestionListener?.onVoiceSearchClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateActionIcon() {
|
||||
val icon = when {
|
||||
!text.isNullOrEmpty() -> clearIcon
|
||||
isVoiceSearchEnabled -> voiceIcon
|
||||
else -> null
|
||||
}
|
||||
if (icon !== drawableEnd) {
|
||||
|
||||
@@ -19,6 +19,10 @@ abstract class SuggestionDao {
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit")
|
||||
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")
|
||||
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.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionWithManga
|
||||
import javax.inject.Inject
|
||||
|
||||
class SuggestionRepository @Inject constructor(
|
||||
@@ -28,6 +29,10 @@ class SuggestionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getRandom(): SuggestionWithManga? {
|
||||
return db.suggestionDao.getRandom()
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
db.suggestionDao.deleteAll()
|
||||
}
|
||||
|
||||
@@ -140,9 +140,4 @@ class FeedFragment :
|
||||
override fun onReadClick(manga: Manga, 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 com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
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.adapter.MangaListListener
|
||||
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.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class FeedAdapter(
|
||||
@@ -36,8 +36,8 @@ class FeedAdapter(
|
||||
val list = items
|
||||
for (i in (0..position).reversed()) {
|
||||
val item = list.getOrNull(i) ?: continue
|
||||
if (item is DateTimeAgo) {
|
||||
return item.format(context.resources)
|
||||
if (item is ListHeader) {
|
||||
return item.getText(context)
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
31
app/src/main/res/layout/fragment_favourites_container.xml
Normal file
31
app/src/main/res/layout/fragment_favourites_container.xml
Normal file
@@ -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">
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_shelf"
|
||||
android:icon="@drawable/ic_bookshelf_selector"
|
||||
android:title="@string/manga_shelf" />
|
||||
android:id="@+id/nav_history"
|
||||
android:icon="@drawable/ic_history"
|
||||
android:title="@string/history" />
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_favourites"
|
||||
android:icon="@drawable/ic_heart_outline"
|
||||
android:title="@string/favourites" />
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_explore"
|
||||
@@ -17,9 +22,4 @@
|
||||
android:icon="@drawable/ic_feed_selector"
|
||||
android:title="@string/feed" />
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_tools"
|
||||
android:icon="@drawable/ic_tools_selector"
|
||||
android:title="@string/options" />
|
||||
|
||||
</menu>
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
android:id="@+id/action_history_grouping"
|
||||
android:checkable="true"
|
||||
android:checked="true"
|
||||
android:orderInCategory="15"
|
||||
android:orderInCategory="25"
|
||||
android:title="@string/group"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_clear_history"
|
||||
android:orderInCategory="50"
|
||||
android:orderInCategory="10"
|
||||
android:title="@string/clear_history"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
</menu>
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
android:title="@string/list_mode"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
</menu>
|
||||
|
||||
19
app/src/main/res/menu/opt_main.xml
Normal file
19
app/src/main/res/menu/opt_main.xml
Normal file
@@ -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: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
|
||||
android:id="@+id/action_clear"
|
||||
android:title="@string/clear_search_history"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
</menu>
|
||||
|
||||
@@ -24,4 +24,8 @@
|
||||
<item quantity="one">%1$d day ago</item>
|
||||
<item quantity="other">%1$d days ago</item>
|
||||
</plurals>
|
||||
<plurals name="months_ago">
|
||||
<item quantity="one">%1$d month ago</item>
|
||||
<item quantity="other">%1$d months ago</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -450,4 +450,6 @@
|
||||
<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="description">Description</string>
|
||||
<string name="this_month">This month</string>
|
||||
<string name="voice_search">Voice search</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user