From 2e0b3020f48ee1b0669286dc8ee83e927c4b234a Mon Sep 17 00:00:00 2001 From: Hayri Bakici <3295340+hayribakici@users.noreply.github.com> Date: Fri, 22 Dec 2023 00:19:46 +0100 Subject: [PATCH] feat(drawer): group apps in app drawer by first letter (#144) Co-authored-by: Joshua Kuestersteffen --- .../slimlauncher/adapters/AppDrawerAdapter.kt | 158 +++++++++++++----- .../coreprefs/CorePreferencesRepository.kt | 8 + .../slimlauncher/ui/main/HomeFragment.kt | 7 +- .../ui/options/CustomizeAppDrawerFragment.kt | 17 ++ .../com/sduduzog/slimlauncher/utils/Utils.kt | 4 +- app/src/main/proto/core_preferences.proto | 5 +- .../app_drawer_fragment_header_item.xml | 27 +++ .../layout/customize_app_drawer_fragment.xml | 16 ++ app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 2 + 10 files changed, 196 insertions(+), 49 deletions(-) create mode 100644 app/src/main/res/layout/app_drawer_fragment_header_item.xml diff --git a/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt b/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt index 71dc1f9a..1c6467f4 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt @@ -12,77 +12,119 @@ import androidx.recyclerview.widget.RecyclerView import com.jkuester.unlauncher.datastore.UnlauncherApp import com.sduduzog.slimlauncher.R import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsRepository +import com.sduduzog.slimlauncher.datasource.coreprefs.CorePreferencesRepository import com.sduduzog.slimlauncher.ui.main.HomeFragment +import com.sduduzog.slimlauncher.utils.firstUppercase class AppDrawerAdapter( private val listener: HomeFragment.AppDrawerListener, lifecycleOwner: LifecycleOwner, - appsRepo: UnlauncherAppsRepository -) : RecyclerView.Adapter() { + appsRepo: UnlauncherAppsRepository, + private val corePreferencesRepo: CorePreferencesRepository +) : RecyclerView.Adapter() { + private val regex = Regex("[!@#\$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/? ]") private var apps: List = listOf() - private var filteredApps: List = listOf() - private var filterQuery = "" + private var filteredApps: List = listOf() init { - appsRepo.liveData().observe(lifecycleOwner, { unlauncherApps -> - apps = unlauncherApps.appsList.filter { app -> app.displayInDrawer }.toList() - updateDisplayedApps() - }) + appsRepo.liveData().observe(lifecycleOwner) { unlauncherApps -> + apps = unlauncherApps.appsList + updateFilteredApps() + } + corePreferencesRepo.liveData().observe(lifecycleOwner) { _ -> + updateFilteredApps() + } } override fun getItemCount(): Int = filteredApps.size - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = filteredApps[position] - holder.appName.text = item.displayName - holder.itemView.setOnClickListener { - listener.onAppClicked(item) - } - holder.itemView.setOnLongClickListener { - listener.onAppLongClicked(item, it) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val drawerRow = filteredApps[position]) { + is AppDrawerRow.Item -> { + val unlauncherApp = drawerRow.app + (holder as ItemViewHolder).bind(unlauncherApp) + holder.itemView.setOnClickListener { + listener.onAppClicked(unlauncherApp) + } + holder.itemView.setOnLongClickListener { + listener.onAppLongClicked(unlauncherApp, it) + } + } + + is AppDrawerRow.Header -> (holder as HeaderViewHolder).bind(drawerRow.letter) } } fun getFirstApp(): UnlauncherApp { - return filteredApps.first() + return filteredApps.filterIsInstance().first().app } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.add_app_fragment_list_item, parent, false) - return ViewHolder(view) - } + override fun getItemViewType(position: Int): Int = filteredApps[position].rowType.ordinal - fun setAppFilter(query: String = "") { - filterQuery = regex.replace(query, "") - this.updateDisplayedApps() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (RowType.values()[viewType]) { + RowType.App -> ItemViewHolder( + inflater.inflate(R.layout.add_app_fragment_list_item, parent, false) + ) + + RowType.Header -> HeaderViewHolder( + inflater.inflate(R.layout.app_drawer_fragment_header_item, parent, false) + ) + } } + private fun onlyFirstStringStartsWith(first: String, second: String, query: String) : Boolean { return first.startsWith(query, true) and !second.startsWith(query, true); } - + @SuppressLint("NotifyDataSetChanged") - private fun updateDisplayedApps() { - filteredApps = apps.filter { app -> - regex.replace(app.displayName, "").contains(filterQuery, ignoreCase = true) - }.toList().sortedWith( - Comparator{ - a, b -> when { - // if an app's name starts with the query prefer it - onlyFirstStringStartsWith(a.displayName, b.displayName, filterQuery) -> -1 - onlyFirstStringStartsWith(b.displayName, a.displayName, filterQuery) -> 1 - // if both or none start with the query sort in normal oder - a.displayName > b.displayName -> 1 - a.displayName < b.displayName -> -1 - else -> 0 - } - } - ) + fun setAppFilter(query: String = "") { + val filterQuery = regex.replace(query, "") + updateFilteredApps(filterQuery) notifyDataSetChanged() } + private fun updateFilteredApps(filterQuery: String = "") { + val showDrawerHeadings = corePreferencesRepo.get().showDrawerHeadings + val displayableApps = apps + .filter { app -> + app.displayInDrawer && regex.replace(app.displayName, "") + .contains(filterQuery, ignoreCase = true) + } + + val includeHeadings = !showDrawerHeadings || filterQuery != "" + filteredApps = when (includeHeadings) { + true -> displayableApps + .sortedWith { a, b -> + when { + // if an app's name starts with the query prefer it + onlyFirstStringStartsWith(a.displayName, b.displayName, filterQuery) -> -1 + onlyFirstStringStartsWith(b.displayName, a.displayName, filterQuery) -> 1 + // if both or none start with the query sort in normal oder + a.displayName > b.displayName -> 1 + a.displayName < b.displayName -> -1 + else -> 0 + } + }.map { AppDrawerRow.Item(it) } + // building a list with each letter and filtered app resulting in a list of + // [ + // Header<"G">, App<"Gmail">, App<"Google Drive">, Header<"Y">, App<"YouTube">, ... + // ] + false -> displayableApps + .groupBy { + app -> app.displayName.firstUppercase() + }.flatMap { entry -> + listOf( + AppDrawerRow.Header(entry.key), + *(entry.value.map { AppDrawerRow.Item(it) }).toTypedArray() + ) + } + } + } + val searchBoxListener: TextWatcher = object : TextWatcher { override fun afterTextChanged(s: Editable?) { // Do nothing @@ -97,12 +139,38 @@ class AppDrawerAdapter( } } - inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + inner class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val appName: TextView = itemView.findViewById(R.id.aa_list_item_app_name) + val item: TextView = itemView.findViewById(R.id.aa_list_item_app_name) override fun toString(): String { - return super.toString() + " '${appName.text}'" + return "${super.toString()} '${item.text}'" + } + + fun bind(item: UnlauncherApp) { + this.item.text = item.displayName } } + + inner class HeaderViewHolder(headerView: View) : RecyclerView.ViewHolder(headerView) { + private val header: TextView = itemView.findViewById(R.id.aa_list_header_letter) + + override fun toString(): String { + return "${super.toString()} '${header.text}'" + } + + fun bind(letter: String) { + header.text = letter + } + } +} + +enum class RowType { + Header, App +} + +sealed class AppDrawerRow(val rowType: RowType) { + data class Item(val app: UnlauncherApp) : AppDrawerRow(RowType.App) + + data class Header(val letter: String) : AppDrawerRow(RowType.Header) } \ No newline at end of file diff --git a/app/src/main/java/com/sduduzog/slimlauncher/datasource/coreprefs/CorePreferencesRepository.kt b/app/src/main/java/com/sduduzog/slimlauncher/datasource/coreprefs/CorePreferencesRepository.kt index 36213b82..4c743b1b 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/datasource/coreprefs/CorePreferencesRepository.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/datasource/coreprefs/CorePreferencesRepository.kt @@ -85,4 +85,12 @@ class CorePreferencesRepository( } } } + + fun updateShowDrawerHeadings(showDrawerHeadings: Boolean) { + lifecycleScope.launch { + corePreferencesStore.updateData { + it.toBuilder().setShowDrawerHeadings(showDrawerHeadings).build() + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt b/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt index 131432b0..fa8b0d0a 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt @@ -97,7 +97,12 @@ class HomeFragment : BaseFragment(), OnLaunchAppListener { } } - appDrawerAdapter = AppDrawerAdapter(AppDrawerListener(), viewLifecycleOwner, unlauncherAppsRepo) + appDrawerAdapter = AppDrawerAdapter( + AppDrawerListener(), + viewLifecycleOwner, + unlauncherAppsRepo, + unlauncherDataSource.corePreferencesRepo + ) setEventListeners() diff --git a/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerFragment.kt b/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerFragment.kt index 73c91349..10f72e33 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerFragment.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerFragment.kt @@ -13,6 +13,7 @@ import com.sduduzog.slimlauncher.utils.createTitleAndSubtitleText import dagger.hilt.android.AndroidEntryPoint import kotlinx.android.synthetic.main.customize_app_drawer_fragment.customize_app_drawer_fragment import kotlinx.android.synthetic.main.customize_app_drawer_fragment.customize_app_drawer_fragment_search_options +import kotlinx.android.synthetic.main.customize_app_drawer_fragment.customize_app_drawer_fragment_show_headings_switch import kotlinx.android.synthetic.main.customize_app_drawer_fragment.customize_app_drawer_fragment_visible_apps import javax.inject.Inject @@ -37,6 +38,7 @@ class CustomizeAppDrawerFragment : BaseFragment() { .setOnClickListener(Navigation.createNavigateOnClickListener(R.id.action_customiseAppDrawerFragment_to_customiseAppDrawerAppListFragment)) setupSearchFieldOptionsButton() + setupHeadingSwitch() } private fun setupSearchFieldOptionsButton() { @@ -69,4 +71,19 @@ class CustomizeAppDrawerFragment : BaseFragment() { createTitleAndSubtitleText(requireContext(), title, subtitle) } } + + private fun setupHeadingSwitch() { + val prefsRepo = unlauncherDataSource.corePreferencesRepo + customize_app_drawer_fragment_show_headings_switch.setOnCheckedChangeListener { _, checked -> + prefsRepo.updateShowDrawerHeadings(checked) + } + prefsRepo.liveData().observe(viewLifecycleOwner) { + customize_app_drawer_fragment_show_headings_switch.isChecked = it.showDrawerHeadings + } + customize_app_drawer_fragment_show_headings_switch.text = + createTitleAndSubtitleText( + requireContext(), R.string.customize_app_drawer_fragment_show_headings, + R.string.customize_app_drawer_fragment_show_headings_subtitle + ) + } } diff --git a/app/src/main/java/com/sduduzog/slimlauncher/utils/Utils.kt b/app/src/main/java/com/sduduzog/slimlauncher/utils/Utils.kt index a772d316..650ff3e8 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/utils/Utils.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/utils/Utils.kt @@ -91,4 +91,6 @@ fun createTitleAndSubtitleText(context: Context, title: CharSequence, subtitle: Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) return spanBuilder -} \ No newline at end of file +} + +fun String.firstUppercase() = this.first().uppercase() \ No newline at end of file diff --git a/app/src/main/proto/core_preferences.proto b/app/src/main/proto/core_preferences.proto index 9c443c5d..7243fc91 100644 --- a/app/src/main/proto/core_preferences.proto +++ b/app/src/main/proto/core_preferences.proto @@ -6,8 +6,9 @@ option java_multiple_files = true; message CorePreferences { bool activate_keyboard_in_drawer = 1; bool keep_device_wallpaper = 2; - optional bool showSearchBar = 3; - SearchBarPosition searchBarPosition = 4; + optional bool show_search_bar = 3; + SearchBarPosition search_bar_position = 4; + bool show_drawer_headings = 5; } enum SearchBarPosition { diff --git a/app/src/main/res/layout/app_drawer_fragment_header_item.xml b/app/src/main/res/layout/app_drawer_fragment_header_item.xml new file mode 100644 index 00000000..a96889a5 --- /dev/null +++ b/app/src/main/res/layout/app_drawer_fragment_header_item.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/customize_app_drawer_fragment.xml b/app/src/main/res/layout/customize_app_drawer_fragment.xml index 12f7b564..68d37ed4 100644 --- a/app/src/main/res/layout/customize_app_drawer_fragment.xml +++ b/app/src/main/res/layout/customize_app_drawer_fragment.xml @@ -34,4 +34,20 @@ app:layout_constraintStart_toStartOf="@id/customize_app_drawer_fragment_visible_apps" app:layout_constraintTop_toBottomOf="@id/customize_app_drawer_fragment_visible_apps" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 85b7a5c6..930ce12b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,6 +12,7 @@ @dimen/_24ssp @dimen/_20ssp + @dimen/_12ssp @dimen/_24ssp @dimen/_18ssp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0f5d4c5..43a2b185 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,6 +56,8 @@ App needs to be default launcher Visible Apps Search Field Options + Show Groupings + Separate apps into alphabetical groups Search Field is hidden %s position, keyboard is %s Show Search Field