Skip to content

Commit

Permalink
feat(drawer): group apps in app drawer by first letter (#144)
Browse files Browse the repository at this point in the history
Co-authored-by: Joshua Kuestersteffen <[email protected]>
  • Loading branch information
hayribakici and jkuester authored Dec 21, 2023
1 parent 32b677e commit 2e0b302
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 49 deletions.
158 changes: 113 additions & 45 deletions app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppDrawerAdapter.ViewHolder>() {
appsRepo: UnlauncherAppsRepository,
private val corePreferencesRepo: CorePreferencesRepository
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

private val regex = Regex("[!@#\$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/? ]")
private var apps: List<UnlauncherApp> = listOf()
private var filteredApps: List<UnlauncherApp> = listOf()
private var filterQuery = ""
private var filteredApps: List<AppDrawerRow> = 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<AppDrawerRow.Item>().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<UnlauncherApp>{
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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,12 @@ class CorePreferencesRepository(
}
}
}

fun updateShowDrawerHeadings(showDrawerHeadings: Boolean) {
lifecycleScope.launch {
corePreferencesStore.updateData {
it.toBuilder().setShowDrawerHeadings(showDrawerHeadings).build()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ class HomeFragment : BaseFragment(), OnLaunchAppListener {
}
}

appDrawerAdapter = AppDrawerAdapter(AppDrawerListener(), viewLifecycleOwner, unlauncherAppsRepo)
appDrawerAdapter = AppDrawerAdapter(
AppDrawerListener(),
viewLifecycleOwner,
unlauncherAppsRepo,
unlauncherDataSource.corePreferencesRepo
)

setEventListeners()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,6 +38,7 @@ class CustomizeAppDrawerFragment : BaseFragment() {
.setOnClickListener(Navigation.createNavigateOnClickListener(R.id.action_customiseAppDrawerFragment_to_customiseAppDrawerAppListFragment))

setupSearchFieldOptionsButton()
setupHeadingSwitch()
}

private fun setupSearchFieldOptionsButton() {
Expand Down Expand Up @@ -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
)
}
}
4 changes: 3 additions & 1 deletion app/src/main/java/com/sduduzog/slimlauncher/utils/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,6 @@ fun createTitleAndSubtitleText(context: Context, title: CharSequence, subtitle:
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
return spanBuilder
}
}

fun String.firstUppercase() = this.first().uppercase()
5 changes: 3 additions & 2 deletions app/src/main/proto/core_preferences.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/res/layout/app_drawer_fragment_header_item.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<TextView
android:id="@+id/aa_list_header_letter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:padding="6dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textSize="@dimen/font_size_customize_group_header"
tools:text="A" />

<View
android:id="@+id/divider"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="?android:attr/listDivider" />

</LinearLayout>
16 changes: 16 additions & 0 deletions app/src/main/res/layout/customize_app_drawer_fragment.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

<androidx.appcompat.widget.SwitchCompat
android:id="@+id/customize_app_drawer_fragment_show_headings_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/_16sdp"
android:layout_marginLeft="@dimen/_16sdp"
android:layout_marginTop="32dp"
android:layout_marginEnd="@dimen/_16sdp"
android:layout_marginRight="@dimen/_16sdp"
android:layout_marginBottom="32dp"
android:text="@string/customize_app_drawer_fragment_show_headings"
android:textAppearance="@style/TextAppearance.AppCompat"
android:textSize="@dimen/font_size_customize_options"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/customize_app_drawer_fragment_search_options" />

</androidx.constraintlayout.widget.ConstraintLayout>
1 change: 1 addition & 0 deletions app/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

<dimen name="font_size_customize_title">@dimen/_24ssp</dimen>
<dimen name="font_size_customize_options">@dimen/_20ssp</dimen>
<dimen name="font_size_customize_group_header">@dimen/_12ssp</dimen>

<dimen name="font_size_customize_apps_list_item">@dimen/_24ssp</dimen>
<dimen name="font_size_add_apps_search">@dimen/_18ssp</dimen>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
<string name="customize_app_drawer_fragment_auto_theme_wallpaper_subtext_no_default_launcher">App needs to be default launcher</string>
<string name="customize_app_drawer_fragment_visible_apps">Visible Apps</string>
<string name="customize_app_drawer_fragment_search_field_options">Search Field Options</string>
<string name="customize_app_drawer_fragment_show_headings">Show Groupings</string>
<string name="customize_app_drawer_fragment_show_headings_subtitle">Separate apps into alphabetical groups</string>
<string name="customize_app_drawer_fragment_search_field_options_subtitle_status_hidden">Search Field is hidden</string>
<string name="customize_app_drawer_fragment_search_field_options_subtitle_status_shown">%s position, keyboard is %s</string>
<string name="customize_app_drawer_fragment_show_search_bar">Show Search Field</string>
Expand Down

0 comments on commit 2e0b302

Please sign in to comment.