diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt new file mode 100644 index 000000000..8e6949df1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt @@ -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(ListModelDiffCallback), + FlowCollector> { + + override suspend fun emit(value: List) = suspendCoroutine { cont -> + setItems(value, ContinuationResumeRunnable(cont)) + } + + fun addListListener(listListener: ListListener) { + differ.addListListener(listListener) + } + + fun removeListListener(listListener: ListListener) { + differ.removeListListener(listListener) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt index e5f67cba5..cfb69a705 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt @@ -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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt new file mode 100644 index 000000000..5eb098c86 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.core.util + +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class ContinuationResumeRunnable( + private val continuation: Continuation, +) : Runnable { + + override fun run() { + continuation.resume(Unit) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt index aaa71435f..62de07110 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 94fe7fe75..b74c22aff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -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() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt new file mode 100644 index 000000000..44ef66c0e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt @@ -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> { + + 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) = suspendCoroutine { cont -> + differ.submitList(value, ContinuationResumeRunnable(cont)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt new file mode 100644 index 000000000..b8d5ca256 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt @@ -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(), 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt new file mode 100644 index 000000000..4e4a17940 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt @@ -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() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt index e9b2dcda1..5c05fbf46 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt @@ -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) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt index a17e0da8f..93b65c0da 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index 47157c4a7..f50af0f3e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -55,9 +55,4 @@ class HistoryListFragment : MangaListFragment() { } override fun onCreateAdapter() = HistoryListAdapter(coil, viewLifecycleOwner, this) - - companion object { - - fun newInstance() = HistoryListFragment() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt index 00c5dee0b..32b0e6f03 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt @@ -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 } -} \ No newline at end of file + + 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() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 818252d9f..bc394e3f7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -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 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index a28a6c390..bf4c3c07d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -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(ListModelDiffCallback) { +) : BaseListAdapter() { init { delegatesManager diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 37e03e512..619ec29f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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(), - AppBarOwner, - BottomNavOwner, - View.OnClickListener, - View.OnFocusChangeListener, - SearchSuggestionListener, - MainNavigationDelegate.OnFragmentChangedListener { +class MainActivity : BaseActivity(), AppBarOwner, BottomNavOwner, View.OnClickListener, + View.OnFocusChangeListener, SearchSuggestionListener, MainNavigationDelegate.OnFragmentChangedListener { @Inject lateinit var settings: AppSettings private val viewModel by viewModels() private val searchSuggestionViewModel by viewModels() - 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 { - - override fun onActivityResult(result: String?) { - if (result != null) { - viewBinding.searchView.query = result - } - } - } - private inner class CloseSearchCallback : OnBackPressedCallback(false) { override fun handleOnBackPressed() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt index 188ce58ec..d461d49e3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt @@ -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) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt index cbec456a3..8329bc8a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -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 + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 804e00f19..b5436848e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -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() + 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) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt index cef229058..0a35e13a7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -17,6 +17,4 @@ interface SearchSuggestionListener { fun onSourceClick(source: MangaSource) fun onTagClick(tag: MangaTag) - - fun onVoiceSearchClick() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt index 036d7ce32..12d58adf9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt @@ -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, 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() } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 417e48d94..914e461dd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -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) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt index cb77f7a68..4c55cd299 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -19,6 +19,10 @@ abstract class SuggestionDao { @Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit") abstract fun observeAll(limit: Int): Flow> + @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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index 873d7750e..23e703b59 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -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() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 588e22677..cb34ab395 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -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() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt index 41aab371d..f4c27b9cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt @@ -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 diff --git a/app/src/main/res/layout/fragment_favourites_container.xml b/app/src/main/res/layout/fragment_favourites_container.xml new file mode 100644 index 000000000..09498da3f --- /dev/null +++ b/app/src/main/res/layout/fragment_favourites_container.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/menu/nav_bottom.xml b/app/src/main/res/menu/nav_bottom.xml index bf1aed02b..89c4e2dc4 100644 --- a/app/src/main/res/menu/nav_bottom.xml +++ b/app/src/main/res/menu/nav_bottom.xml @@ -3,9 +3,14 @@ xmlns:android="http://schemas.android.com/apk/res/android"> + android:id="@+id/nav_history" + android:icon="@drawable/ic_history" + android:title="@string/history" /> + + - - diff --git a/app/src/main/res/menu/opt_history.xml b/app/src/main/res/menu/opt_history.xml index 13dead55b..0c8d45d4e 100644 --- a/app/src/main/res/menu/opt_history.xml +++ b/app/src/main/res/menu/opt_history.xml @@ -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" /> - \ No newline at end of file + diff --git a/app/src/main/res/menu/opt_list.xml b/app/src/main/res/menu/opt_list.xml index 421f8a2c5..1ea6a47ff 100644 --- a/app/src/main/res/menu/opt_list.xml +++ b/app/src/main/res/menu/opt_list.xml @@ -9,4 +9,4 @@ android:title="@string/list_mode" app:showAsAction="never" /> - \ No newline at end of file + diff --git a/app/src/main/res/menu/opt_main.xml b/app/src/main/res/menu/opt_main.xml new file mode 100644 index 000000000..58a1fc530 --- /dev/null +++ b/app/src/main/res/menu/opt_main.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/menu/opt_search_suggestion.xml b/app/src/main/res/menu/opt_search_suggestion.xml index 9570cd8a3..710342964 100644 --- a/app/src/main/res/menu/opt_search_suggestion.xml +++ b/app/src/main/res/menu/opt_search_suggestion.xml @@ -3,9 +3,16 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> + + - \ No newline at end of file + diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 5c87de9f0..794970f7e 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -24,4 +24,8 @@ %1$d day ago %1$d days ago + + %1$d month ago + %1$d months ago + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bb16d66e..4526b48c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -450,4 +450,6 @@ You have no access to this file or directory Local manga directories Description + This month + Voice search