소스 검색

feat(drawer): group apps in app drawer by first letter (#144)

Co-authored-by: Joshua Kuestersteffen <jkuester@kuester7.com>
Hayri Bakici 2 년 전
부모
커밋
2e0b3020f4

+ 113 - 45
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<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
@@ -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)
 }

+ 8 - 0
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()
+            }
+        }
+    }
 }

+ 6 - 1
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()
 

+ 17 - 0
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
+            )
+    }
 }

+ 3 - 1
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
-}
+}
+
+fun String.firstUppercase() = this.first().uppercase()

+ 3 - 2
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 {

+ 27 - 0
app/src/main/res/layout/app_drawer_fragment_header_item.xml

@@ -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 - 0
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" />
 
+    <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 - 0
app/src/main/res/values/dimens.xml

@@ -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>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -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>