Redesign is now the new norm

This commit is contained in:
topjohnwu
2020-01-13 00:43:09 +08:00
parent 1449486958
commit 3490ba0a56
90 changed files with 313 additions and 313 deletions
@@ -0,0 +1,151 @@
package com.topjohnwu.magisk.ui.module
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding
import com.topjohnwu.magisk.model.events.InstallExternalModuleEvent
import com.topjohnwu.magisk.model.events.ViewEvent
import com.topjohnwu.magisk.ui.MainActivity
import com.topjohnwu.magisk.ui.ReselectionTarget
import com.topjohnwu.magisk.ui.compat.CompatFragment
import com.topjohnwu.magisk.ui.compat.hideKeyboard
import com.topjohnwu.magisk.utils.EndlessRecyclerScrollListener
import com.topjohnwu.magisk.utils.MotionRevealHelper
import com.topjohnwu.magisk.utils.PinchZoomTouchListener
import org.koin.androidx.viewmodel.ext.android.viewModel
class ModuleFragment : CompatFragment<ModuleViewModel, FragmentModuleMd2Binding>(),
ReselectionTarget {
override val layoutRes = R.layout.fragment_module_md2
override val viewModel by viewModel<ModuleViewModel>()
private val listeners = hashSetOf<EndlessRecyclerScrollListener>()
private var isFilterVisible
get() = binding.moduleFilter.isVisible
set(value) {
if (!value) hideKeyboard()
(activity as? MainActivity)?.requestNavigationHidden(value)
MotionRevealHelper.withViews(binding.moduleFilter, binding.moduleFilterToggle, value)
}
override fun consumeSystemWindowInsets(insets: Insets) = insets
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
InstallExternalModuleEvent.onActivityResult(requireContext(), requestCode, resultCode, data)
}
override fun onStart() {
super.onStart()
setHasOptionsMenu(true)
activity.title = resources.getString(R.string.section_modules)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setEndlessScroller()
setEndlessSearch()
binding.moduleFilterToggle.setOnClickListener {
isFilterVisible = true
}
binding.moduleFilterInclude.moduleFilterDone.setOnClickListener {
isFilterVisible = false
}
binding.moduleFilterInclude.moduleFilterList.addOnScrollListener(object :
RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState != RecyclerView.SCROLL_STATE_IDLE) hideKeyboard()
}
})
PinchZoomTouchListener.attachTo(binding.moduleFilterInclude.moduleFilterList)
PinchZoomTouchListener.attachTo(binding.moduleList)
}
override fun onDestroyView() {
listeners.forEach {
binding.moduleList.removeOnScrollListener(it)
binding.moduleFilterInclude.moduleFilterList.removeOnScrollListener(it)
}
PinchZoomTouchListener.clear(binding.moduleList)
PinchZoomTouchListener.clear(binding.moduleFilterInclude.moduleFilterList)
super.onDestroyView()
}
override fun onBackPressed(): Boolean {
if (isFilterVisible) {
isFilterVisible = false
return true
}
return super.onBackPressed()
}
// ---
override fun onEventDispatched(event: ViewEvent) = when (event) {
is EndlessRecyclerScrollListener.ResetState -> listeners.forEach { it.resetState() }
else -> super.onEventDispatched(event)
}
// ---
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_module_md2, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_refresh -> viewModel.loadRemoteImplicit()
}
return super.onOptionsItemSelected(item)
}
// ---
override fun onReselected() {
binding.moduleList
.takeIf {
(it.layoutManager as? StaggeredGridLayoutManager)?.let {
it.findFirstVisibleItemPositions(IntArray(it.spanCount)).min()
} ?: 0 > 10
}
?.also { it.scrollToPosition(10) }
.let { binding.moduleList }
.also { it.post { it.smoothScrollToPosition(0) } }
}
// ---
override fun onPreBind(binding: FragmentModuleMd2Binding) = Unit
private fun setEndlessScroller() {
val lama = binding.moduleList.layoutManager ?: return
lama.isAutoMeasureEnabled = false
val listener = EndlessRecyclerScrollListener(lama, viewModel::loadRemote)
binding.moduleList.addOnScrollListener(listener)
listeners.add(listener)
}
private fun setEndlessSearch() {
val lama = binding.moduleFilterInclude.moduleFilterList.layoutManager ?: return
lama.isAutoMeasureEnabled = false
val listener = EndlessRecyclerScrollListener(lama, viewModel::loadMoreQuery)
binding.moduleFilterInclude.moduleFilterList.addOnScrollListener(listener)
listeners.add(listener)
}
}
@@ -0,0 +1,313 @@
package com.topjohnwu.magisk.ui.module
import androidx.annotation.WorkerThread
import androidx.databinding.Bindable
import androidx.databinding.ObservableArrayList
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.data.database.RepoByNameDao
import com.topjohnwu.magisk.data.database.RepoByUpdatedDao
import com.topjohnwu.magisk.databinding.ComparableRvItem
import com.topjohnwu.magisk.extensions.reboot
import com.topjohnwu.magisk.extensions.subscribeK
import com.topjohnwu.magisk.model.download.RemoteFileService
import com.topjohnwu.magisk.model.entity.internal.DownloadSubject
import com.topjohnwu.magisk.model.entity.module.Module
import com.topjohnwu.magisk.model.entity.module.Repo
import com.topjohnwu.magisk.model.entity.recycler.*
import com.topjohnwu.magisk.model.events.InstallExternalModuleEvent
import com.topjohnwu.magisk.model.events.OpenChangelogEvent
import com.topjohnwu.magisk.model.events.dialog.ModuleInstallDialog
import com.topjohnwu.magisk.tasks.RepoUpdater
import com.topjohnwu.magisk.ui.compat.*
import com.topjohnwu.magisk.utils.EndlessRecyclerScrollListener
import com.topjohnwu.magisk.utils.KObservableField
import com.topjohnwu.magisk.utils.currentLocale
import io.reactivex.Completable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import me.tatarka.bindingcollectionadapter2.collections.MergeObservableList
import timber.log.Timber
import kotlin.math.roundToInt
class ModuleViewModel(
private val repoName: RepoByNameDao,
private val repoUpdated: RepoByUpdatedDao,
private val repoUpdater: RepoUpdater
) : CompatViewModel(), Queryable by Queryable.impl(1000) {
override val queryRunnable = Runnable { query() }
var query = ""
@Bindable get
set(value) {
if (field == value) return
field = value
notifyPropertyChanged(BR.query)
submitQuery()
// Yes we do lie about the search being loaded
searchLoading.value = true
}
private var queryJob: Disposable? = null
val searchLoading = KObservableField(false)
val itemsSearch = diffListOf<RepoItem>()
val itemSearchBinding = itemBindingOf<RepoItem> {
it.bindExtra(BR.viewModel, this)
}
private val itemNoneInstalled = TextItem(R.string.module_install_none)
private val itemNoneUpdatable = TextItem(R.string.module_update_none)
private val itemsInstalledHelpers = ObservableArrayList<TextItem>().also {
it.add(itemNoneInstalled)
}
private val itemsUpdatableHelpers = ObservableArrayList<TextItem>().also {
it.add(itemNoneUpdatable)
}
private val itemsInstalled = diffListOf<ModuleItem>()
private val itemsUpdatable = diffListOf<RepoItem.Update>()
private val itemsRemote = diffListOf<RepoItem.Remote>()
val adapter = adapterOf<ComparableRvItem<*>>()
val items = MergeObservableList<ComparableRvItem<*>>()
.insertItem(sectionActive)
.insertList(itemsInstalledHelpers)
.insertList(itemsInstalled)
.insertItem(InstallModule)
.insertItem(sectionUpdate)
.insertList(itemsUpdatableHelpers)
.insertList(itemsUpdatable)
.insertItem(sectionRemote)
.insertList(itemsRemote)!!
val itemBinding = itemBindingOf<ComparableRvItem<*>> {
it.bindExtra(BR.viewModel, this)
}
companion object {
private val sectionRemote = SectionTitle(
R.string.module_section_remote,
R.string.sorting_order
)
private val sectionUpdate = SectionTitle(
R.string.module_section_pending,
R.string.module_section_pending_action,
R.drawable.ic_update_md2
// enable with implementation of https://github.com/topjohnwu/Magisk/issues/2036
).also { it.hasButton = false }
private val sectionActive = SectionTitle(
R.string.module_section_installed,
R.string.reboot,
R.drawable.ic_restart
).also { it.hasButton = false }
init {
updateOrderIcon()
}
private fun updateOrderIcon() {
sectionRemote.icon = when (Config.repoOrder) {
Config.Value.ORDER_NAME -> R.drawable.ic_order_name
Config.Value.ORDER_DATE -> R.drawable.ic_order_date
else -> return
}
}
}
// ---
private var remoteJob: Disposable? = null
private val dao
get() = when (Config.repoOrder) {
Config.Value.ORDER_DATE -> repoUpdated
Config.Value.ORDER_NAME -> repoName
else -> throw IllegalArgumentException()
}
// ---
init {
RemoteFileService.reset()
RemoteFileService.progressBroadcast.observeForever {
val (progress, subject) = it ?: return@observeForever
if (subject !is DownloadSubject.Module) {
return@observeForever
}
update(subject.module, progress.times(100).roundToInt())
}
}
// ---
override fun refresh(): Disposable {
val installedTask = loadInstalled()
val remoteTask = if (itemsRemote.isEmpty()) {
Completable.fromAction { loadRemote() }
} else {
Completable.complete()
}
return Completable.merge(listOf(installedTask, remoteTask)).subscribeK()
}
private fun loadInstalled() = Single.fromCallable { Module.loadModules() }
.map { it.map { ModuleItem(it) } }
.map { it.order() }
.map { it.loadDetail() }
.map { it to itemsInstalled.calculateDiff(it) }
.applyViewModel(this)
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess { itemsInstalled.update(it.first, it.second) }
.doOnSuccess {
if (itemsInstalled.isNotEmpty()) itemsInstalledHelpers.remove(itemNoneInstalled)
}
.observeOn(Schedulers.io())
.map { loadUpdates(it.first) }
.observeOn(AndroidSchedulers.mainThread())
.map { it to itemsUpdatable.calculateDiff(it) }
.doOnSuccess { itemsUpdatable.update(it.first, it.second) }
.doOnSuccess {
if (itemsUpdatable.isNotEmpty()) itemsUpdatableHelpers.remove(itemNoneUpdatable)
}
.ignoreElement()!!
fun loadRemoteImplicit() = let { itemsRemote.clear(); itemsSearch.clear() }
.run { downloadRepos() }
.applyViewModel(this, false)
.subscribeK { refresh(); submitQuery() }
.add()
@Synchronized
fun loadRemote() {
// check for existing jobs
if (remoteJob?.isDisposed?.not() == true) {
return
}
if (itemsRemote.isEmpty()) {
EndlessRecyclerScrollListener.ResetState().publish()
}
remoteJob = Single.fromCallable { itemsRemote.size }
.flatMap { loadRemoteInternal(offset = it) }
.subscribeK(onError = Timber::e) {
itemsRemote.addAll(it)
}
}
// ---
override fun submitQuery() {
queryHandler.removeCallbacks(queryRunnable)
queryHandler.postDelayed(queryRunnable, queryDelay)
}
private fun queryInternal(query: String, offset: Int): Single<List<RepoItem>> {
if (query.isBlank()) {
return Single.just(listOf<RepoItem>())
.doOnSubscribe { itemsSearch.clear() }
.subscribeOn(AndroidSchedulers.mainThread())
}
return Single.fromCallable { dao.searchRepos(query, offset) }
.map { it.map { RepoItem.Remote(it) } }
}
private fun query(query: String = this.query, offset: Int = 0) {
queryJob?.dispose()
queryJob = queryInternal(query, offset)
.map { it to itemsSearch.calculateDiff(it) }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess { searchLoading.value = false }
.subscribeK { itemsSearch.update(it.first, it.second) }
}
@Synchronized
fun loadMoreQuery() {
if (queryJob?.isDisposed == false) return
queryJob = queryInternal(query, itemsSearch.size)
.subscribeK { itemsSearch.addAll(it) }
}
// ---
private fun loadRemoteInternal(
offset: Int = 0,
downloadRepos: Boolean = offset == 0
): Single<List<RepoItem.Remote>> = Single.fromCallable { dao.getRepos(offset) }
.map { it.map { RepoItem.Remote(it) } }
.flatMap {
when {
// in case we find result empty and offset is initial we need to refresh the repos.
downloadRepos && it.isEmpty() && offset == 0 -> downloadRepos()
.andThen(loadRemoteInternal(downloadRepos = false))
else -> Single.just(it)
}
}
private fun downloadRepos() = Single.just(Unit)
.flatMap { repoUpdater() }
.ignoreElement()
// ---
@WorkerThread
private fun List<ModuleItem>.order() = asSequence()
.sortedBy { it.item.name.toLowerCase(currentLocale) }
.toList()
@WorkerThread
private fun List<ModuleItem>.loadDetail() = onEach { module ->
Single.fromCallable { dao.getRepoById(module.item.id)!! }
.subscribeK { module.repo = it }
.add()
}
private fun update(repo: Repo, progress: Int) =
Single.fromCallable { itemsRemote + itemsSearch }
.map { it.first { it.item.id == repo.id } }
.subscribeK { it.progress.value = progress }
.add()
// ---
@WorkerThread
private fun loadUpdates(installed: List<ModuleItem>) = installed
.mapNotNull { dao.getUpdatableRepoById(it.item.id, it.item.versionCode) }
.map { RepoItem.Update(it) }
// ---
fun updateActiveState() = Single.fromCallable { itemsInstalled.any { it.isModified } }
.subscribeK { sectionActive.hasButton = it }
.add()
fun sectionPressed(item: SectionTitle) = when (item) {
sectionActive -> reboot() //TODO add reboot picker, regular reboot is not always preferred
sectionRemote -> {
Config.repoOrder = when (Config.repoOrder) {
Config.Value.ORDER_NAME -> Config.Value.ORDER_DATE
Config.Value.ORDER_DATE -> Config.Value.ORDER_NAME
else -> Config.Value.ORDER_NAME
}
updateOrderIcon()
Single.fromCallable { itemsRemote }
.subscribeK {
itemsRemote.removeAll(it)
remoteJob?.dispose()
loadRemote()
}.add()
}
else -> Unit
}
fun downloadPressed(item: RepoItem) = ModuleInstallDialog(item.item).publish()
fun installPressed() = InstallExternalModuleEvent().publish()
fun infoPressed(item: RepoItem) = OpenChangelogEvent(item.item).publish()
fun infoPressed(item: ModuleItem) {
OpenChangelogEvent(item.repo ?: return).publish()
}
}