Просмотр исходного кода

feat: add app drawer folders feature

Implement full folder support for the app drawer:

Data layer:
- app_drawer_folders.proto with AppDrawerFolder/FolderApp/AppDrawerFolders messages
- AppDrawerFoldersSerializer, AppDrawerFoldersRepository (create/rename/delete folder, add/remove app)
- UnlauncherDataSource now exposes appDrawerFoldersRepo

UI:
- AppDrawerAdapter: folder rows (collapsed/expanded) prepend alphabetical list when not searching
- CustomizeFoldersFragment + CustomizeFolderDetailFragment for folder management
- CustomizeFoldersAdapter, CustomizeFolderAppsAdapter
- CreateFolderDialog, RenameFolderDialog, ManageAppFoldersDialog
- "Folders" entry in app long-press menu → ManageAppFoldersDialog
- "Folders" navigation entry in Customize Drawer screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
User 3 недель назад
Родитель
Сommit
f4363e36e4
25 измененных файлов с 897 добавлено и 23 удалено
  1. 88 23
      app/src/main/java/com/simplauncher/adapters/AppDrawerAdapter.kt
  2. 42 0
      app/src/main/java/com/simplauncher/adapters/CustomizeFolderAppsAdapter.kt
  3. 41 0
      app/src/main/java/com/simplauncher/adapters/CustomizeFoldersAdapter.kt
  4. 10 0
      app/src/main/java/com/simplauncher/datasource/UnlauncherDataSource.kt
  5. 125 0
      app/src/main/java/com/simplauncher/datasource/folders/AppDrawerFoldersRepository.kt
  6. 25 0
      app/src/main/java/com/simplauncher/datasource/folders/AppDrawerFoldersSerializer.kt
  7. 35 0
      app/src/main/java/com/simplauncher/ui/dialogs/CreateFolderDialog.kt
  8. 45 0
      app/src/main/java/com/simplauncher/ui/dialogs/ManageAppFoldersDialog.kt
  9. 46 0
      app/src/main/java/com/simplauncher/ui/dialogs/RenameFolderDialog.kt
  10. 4 0
      app/src/main/java/com/simplauncher/ui/main/HomeFragment.kt
  11. 3 0
      app/src/main/java/com/simplauncher/ui/options/CustomizeAppDrawerFragment.kt
  12. 82 0
      app/src/main/java/com/simplauncher/ui/options/CustomizeFolderDetailFragment.kt
  13. 63 0
      app/src/main/java/com/simplauncher/ui/options/CustomizeFoldersFragment.kt
  14. 20 0
      app/src/main/proto/app_drawer_folders.proto
  15. 9 0
      app/src/main/res/drawable/ic_folder.xml
  16. 9 0
      app/src/main/res/drawable/ic_folder_arrow.xml
  17. 24 0
      app/src/main/res/layout/app_drawer_folder_item.xml
  18. 11 0
      app/src/main/res/layout/customize_app_drawer_fragment.xml
  19. 25 0
      app/src/main/res/layout/customize_folder_detail_app_item.xml
  20. 73 0
      app/src/main/res/layout/customize_folder_detail_fragment.xml
  21. 61 0
      app/src/main/res/layout/customize_folders_fragment.xml
  22. 24 0
      app/src/main/res/layout/customize_folders_list_item.xml
  23. 4 0
      app/src/main/res/menu/app_long_press_menu.xml
  24. 21 0
      app/src/main/res/navigation/nav_graph.xml
  25. 7 0
      app/src/main/res/values/strings.xml

+ 88 - 23
app/src/main/java/com/simplauncher/adapters/AppDrawerAdapter.kt

@@ -6,9 +6,12 @@ import android.text.TextWatcher
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import android.widget.ImageView
 import android.widget.TextView
 import androidx.lifecycle.LifecycleOwner
 import androidx.recyclerview.widget.RecyclerView
+import com.simplauncher.datastore.AppDrawerFolder
+import com.simplauncher.datastore.AppDrawerFolders
 import com.simplauncher.datastore.UnlauncherApp
 import com.simplauncher.R
 import com.simplauncher.datasource.UnlauncherDataSource
@@ -25,6 +28,8 @@ class AppDrawerAdapter(
     private val WORK_APP_PREFIX = "\uD83C\uDD46 " //Unicode for boxed w
     private val regex = Regex("[!@#\$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/? ]")
     private var apps: List<UnlauncherApp> = listOf()
+    private var folders: AppDrawerFolders = AppDrawerFolders.getDefaultInstance()
+    private val expandedFolderIds: MutableSet<String> = mutableSetOf()
     private var filteredApps: List<AppDrawerRow> = listOf()
     private var gravity = 3
 
@@ -37,6 +42,10 @@ class AppDrawerAdapter(
             gravity = corePrefs.alignmentFormat.gravity()
             updateFilteredApps()
         }
+        unlauncherDataSource.appDrawerFoldersRepo.liveData().observe(lifecycleOwner) { appDrawerFolders ->
+            folders = appDrawerFolders
+            updateFilteredApps()
+        }
     }
 
     override fun getItemCount(): Int = filteredApps.size
@@ -55,6 +64,18 @@ class AppDrawerAdapter(
             }
 
             is AppDrawerRow.Header -> (holder as HeaderViewHolder).bind(drawerRow.letter)
+
+            is AppDrawerRow.FolderHeader -> {
+                (holder as FolderViewHolder).bind(drawerRow.folder, drawerRow.isExpanded)
+                holder.itemView.setOnClickListener { toggleFolder(drawerRow.folder.id) }
+            }
+
+            is AppDrawerRow.FolderItem -> {
+                val app = drawerRow.app
+                (holder as FolderAppViewHolder).bind(app)
+                holder.itemView.setOnClickListener { listener.onAppClicked(app) }
+                holder.itemView.setOnLongClickListener { listener.onAppLongClicked(app, it) }
+            }
         }
     }
 
@@ -70,13 +91,43 @@ class AppDrawerAdapter(
             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)
             )
+            RowType.FolderHeader -> FolderViewHolder(
+                inflater.inflate(R.layout.app_drawer_folder_item, parent, false)
+            )
+            RowType.FolderApp -> FolderAppViewHolder(
+                inflater.inflate(R.layout.add_app_fragment_list_item, parent, false)
+            )
+        }
+    }
+
+    private fun toggleFolder(folderId: String) {
+        if (expandedFolderIds.contains(folderId)) {
+            expandedFolderIds.remove(folderId)
+        } else {
+            expandedFolderIds.add(folderId)
         }
+        updateFilteredApps()
     }
 
+    private fun buildFolderRows(): List<AppDrawerRow> {
+        val rows = mutableListOf<AppDrawerRow>()
+        for (folder in folders.foldersList) {
+            val isExpanded = expandedFolderIds.contains(folder.id)
+            rows.add(AppDrawerRow.FolderHeader(folder, isExpanded))
+            if (isExpanded) {
+                for (folderApp in folder.appsList) {
+                    val unlauncherApp = apps.firstOrNull {
+                        it.packageName == folderApp.packageName && it.className == folderApp.className
+                    } ?: continue
+                    rows.add(AppDrawerRow.FolderItem(unlauncherApp))
+                }
+            }
+        }
+        return rows
+    }
 
     private fun onlyFirstStringStartsWith(first: String, second: String, query: String) : Boolean {
         return first.startsWith(query, true) and !second.startsWith(query, true);
@@ -99,21 +150,15 @@ class AppDrawerAdapter(
             }
 
         val includeHeadings = !showDrawerHeadings || filterQuery != ""
-        val updatedApps = when (includeHeadings) {
+        val appRows = 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
                         else -> a.displayName.compareTo(b.displayName, true)
                     }
                 }.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 ->
                     if(app.displayName.startsWith(WORK_APP_PREFIX)) WORK_APP_PREFIX
@@ -125,6 +170,13 @@ class AppDrawerAdapter(
                     )
                 }
         }
+
+        val updatedApps = if (filterQuery.isEmpty() && folders.foldersCount > 0) {
+            buildFolderRows() + appRows
+        } else {
+            appRows
+        }
+
         if (updatedApps != filteredApps) {
             filteredApps = updatedApps
             notifyDataSetChanged()
@@ -132,13 +184,9 @@ class AppDrawerAdapter(
     }
 
     val searchBoxListener: TextWatcher = object : TextWatcher {
-        override fun afterTextChanged(s: Editable?) {
-            // Do nothing
-        }
+        override fun afterTextChanged(s: Editable?) {}
 
-        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
-            // Do nothing
-        }
+        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
 
         override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
             setAppFilter(s.toString())
@@ -149,9 +197,7 @@ class AppDrawerAdapter(
 
         val item: TextView = itemView.findViewById(R.id.aa_list_item_app_name)
 
-        override fun toString(): String {
-            return "${super.toString()} '${item.text}'"
-        }
+        override fun toString(): String = "${super.toString()} '${item.text}'"
 
         fun bind(item: UnlauncherApp) {
             this.item.text = item.displayName
@@ -162,22 +208,41 @@ class AppDrawerAdapter(
     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}'"
-        }
+        override fun toString(): String = "${super.toString()} '${header.text}'"
 
         fun bind(letter: String) {
             header.text = letter
         }
     }
+
+    inner class FolderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        private val name: TextView = itemView.findViewById(R.id.folder_item_name)
+        private val arrow: ImageView = itemView.findViewById(R.id.folder_item_arrow)
+
+        fun bind(folder: AppDrawerFolder, isExpanded: Boolean) {
+            name.text = folder.name
+            name.gravity = gravity
+            arrow.rotation = if (isExpanded) 180f else 0f
+        }
+    }
+
+    inner class FolderAppViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        private val item: TextView = itemView.findViewById(R.id.aa_list_item_app_name)
+
+        fun bind(app: UnlauncherApp) {
+            item.text = app.displayName
+            item.gravity = gravity
+        }
+    }
 }
 
 enum class RowType {
-    Header, App
+    Header, App, FolderHeader, FolderApp
 }
 
 sealed class AppDrawerRow(val rowType: RowType) {
     data class Item(val app: UnlauncherApp) : AppDrawerRow(RowType.App)
-
     data class Header(val letter: String) : AppDrawerRow(RowType.Header)
-}
+    data class FolderHeader(val folder: AppDrawerFolder, val isExpanded: Boolean) : AppDrawerRow(RowType.FolderHeader)
+    data class FolderItem(val app: UnlauncherApp) : AppDrawerRow(RowType.FolderApp)
+}

+ 42 - 0
app/src/main/java/com/simplauncher/adapters/CustomizeFolderAppsAdapter.kt

@@ -0,0 +1,42 @@
+package com.simplauncher.adapters
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.simplauncher.R
+import com.simplauncher.datastore.FolderApp
+
+class CustomizeFolderAppsAdapter(
+    private val onRemoveClicked: (FolderApp) -> Unit
+) : RecyclerView.Adapter<CustomizeFolderAppsAdapter.ViewHolder>() {
+
+    private var items: List<Pair<FolderApp, String>> = emptyList()
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun setItems(apps: List<FolderApp>, displayNameFor: (FolderApp) -> String) {
+        items = apps.map { it to displayNameFor(it) }
+        notifyDataSetChanged()
+    }
+
+    override fun getItemCount() = items.size
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val view = LayoutInflater.from(parent.context)
+            .inflate(R.layout.customize_folder_detail_app_item, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val (folderApp, displayName) = items[position]
+        holder.name.text = displayName
+        holder.remove.setOnClickListener { onRemoveClicked(folderApp) }
+    }
+
+    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+        val name: TextView = view.findViewById(R.id.folder_detail_app_name)
+        val remove: TextView = view.findViewById(R.id.folder_detail_app_remove)
+    }
+}

+ 41 - 0
app/src/main/java/com/simplauncher/adapters/CustomizeFoldersAdapter.kt

@@ -0,0 +1,41 @@
+package com.simplauncher.adapters
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.simplauncher.R
+import com.simplauncher.datastore.AppDrawerFolder
+
+class CustomizeFoldersAdapter(
+    private val onFolderClicked: (AppDrawerFolder) -> Unit
+) : RecyclerView.Adapter<CustomizeFoldersAdapter.ViewHolder>() {
+
+    private var folders: List<AppDrawerFolder> = emptyList()
+
+    @SuppressLint("NotifyDataSetChanged")
+    fun setFolders(folders: List<AppDrawerFolder>) {
+        this.folders = folders
+        notifyDataSetChanged()
+    }
+
+    override fun getItemCount() = folders.size
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val view = LayoutInflater.from(parent.context)
+            .inflate(R.layout.customize_folders_list_item, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val folder = folders[position]
+        holder.name.text = folder.name
+        holder.itemView.setOnClickListener { onFolderClicked(folder) }
+    }
+
+    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+        val name: TextView = view.findViewById(R.id.folder_name)
+    }
+}

+ 10 - 0
app/src/main/java/com/simplauncher/datasource/UnlauncherDataSource.kt

@@ -4,6 +4,7 @@ import android.content.Context
 import androidx.datastore.core.DataStore
 import androidx.datastore.dataStore
 import androidx.lifecycle.LifecycleCoroutineScope
+import com.simplauncher.datastore.AppDrawerFolders
 import com.simplauncher.datastore.CorePreferences
 import com.simplauncher.datastore.QuickButtonPreferences
 import com.simplauncher.datastore.UnlauncherApps
@@ -13,10 +14,17 @@ import com.simplauncher.datasource.apps.UnlauncherAppsSerializer
 import com.simplauncher.datasource.coreprefs.CorePreferencesMigrations
 import com.simplauncher.datasource.coreprefs.CorePreferencesRepository
 import com.simplauncher.datasource.coreprefs.CorePreferencesSerializer
+import com.simplauncher.datasource.folders.AppDrawerFoldersRepository
+import com.simplauncher.datasource.folders.AppDrawerFoldersSerializer
 import com.simplauncher.datasource.quickbuttonprefs.QuickButtonPreferencesMigrations
 import com.simplauncher.datasource.quickbuttonprefs.QuickButtonPreferencesRepository
 import com.simplauncher.datasource.quickbuttonprefs.QuickButtonPreferencesSerializer
 
+private val Context.appDrawerFoldersStore: DataStore<AppDrawerFolders> by dataStore(
+    fileName = "app_drawer_folders.proto",
+    serializer = AppDrawerFoldersSerializer
+)
+
 private val Context.quickButtonPreferencesStore: DataStore<QuickButtonPreferences> by dataStore(
     fileName = "quick_button_preferences.proto",
     serializer = QuickButtonPreferencesSerializer,
@@ -36,6 +44,8 @@ private val Context.corePreferencesStore: DataStore<CorePreferences> by dataStor
 )
 
 class UnlauncherDataSource(context: Context, lifecycleScope: LifecycleCoroutineScope) {
+    val appDrawerFoldersRepo =
+        AppDrawerFoldersRepository(context.appDrawerFoldersStore, lifecycleScope)
     val quickButtonPreferencesRepo =
         QuickButtonPreferencesRepository(context.quickButtonPreferencesStore, lifecycleScope)
     val unlauncherAppsRepo = UnlauncherAppsRepository(context.unlauncherAppsStore, lifecycleScope)

+ 125 - 0
app/src/main/java/com/simplauncher/datasource/folders/AppDrawerFoldersRepository.kt

@@ -0,0 +1,125 @@
+package com.simplauncher.datasource.folders
+
+import android.util.Log
+import androidx.datastore.core.DataStore
+import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.asLiveData
+import com.simplauncher.datastore.AppDrawerFolder
+import com.simplauncher.datastore.AppDrawerFolders
+import com.simplauncher.datastore.FolderApp
+import com.simplauncher.datastore.UnlauncherApp
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.io.IOException
+import java.util.UUID
+
+class AppDrawerFoldersRepository(
+    private val appDrawerFoldersStore: DataStore<AppDrawerFolders>,
+    private val lifecycleScope: LifecycleCoroutineScope
+) {
+    private val appDrawerFoldersFlow: Flow<AppDrawerFolders> =
+        appDrawerFoldersStore.data
+            .catch { exception ->
+                if (exception is IOException) {
+                    Log.e("AppDrawerFoldersRepo", "Error reading folders.", exception)
+                    emit(AppDrawerFolders.getDefaultInstance())
+                } else {
+                    throw exception
+                }
+            }
+
+    fun liveData(): LiveData<AppDrawerFolders> = appDrawerFoldersFlow.asLiveData()
+
+    fun get(): AppDrawerFolders = runBlocking { appDrawerFoldersFlow.first() }
+
+    fun createFolder(name: String) {
+        lifecycleScope.launch {
+            appDrawerFoldersStore.updateData { current ->
+                val folder = AppDrawerFolder.newBuilder()
+                    .setId(UUID.randomUUID().toString())
+                    .setName(name)
+                    .build()
+                current.toBuilder().addFolders(folder).build()
+            }
+        }
+    }
+
+    fun renameFolder(folderId: String, name: String) {
+        lifecycleScope.launch {
+            appDrawerFoldersStore.updateData { current ->
+                val builder = current.toBuilder()
+                val index = builder.foldersList.indexOfFirst { it.id == folderId }
+                if (index >= 0) {
+                    builder.setFolders(index, builder.foldersList[index].toBuilder().setName(name))
+                }
+                builder.build()
+            }
+        }
+    }
+
+    fun deleteFolder(folderId: String) {
+        lifecycleScope.launch {
+            appDrawerFoldersStore.updateData { current ->
+                val builder = current.toBuilder()
+                val index = builder.foldersList.indexOfFirst { it.id == folderId }
+                if (index >= 0) builder.removeFolders(index)
+                builder.build()
+            }
+        }
+    }
+
+    fun addAppToFolder(folderId: String, app: UnlauncherApp) {
+        lifecycleScope.launch {
+            appDrawerFoldersStore.updateData { current ->
+                val builder = current.toBuilder()
+                val index = builder.foldersList.indexOfFirst { it.id == folderId }
+                if (index >= 0) {
+                    val folder = builder.foldersList[index]
+                    val alreadyIn = folder.appsList.any {
+                        it.packageName == app.packageName && it.className == app.className
+                    }
+                    if (!alreadyIn) {
+                        val folderApp = FolderApp.newBuilder()
+                            .setPackageName(app.packageName)
+                            .setClassName(app.className)
+                            .setUserSerial(app.userSerial)
+                            .build()
+                        builder.setFolders(index, folder.toBuilder().addApps(folderApp))
+                    }
+                }
+                builder.build()
+            }
+        }
+    }
+
+    fun removeAppFromFolder(folderId: String, app: UnlauncherApp) {
+        removeAppFromFolder(folderId, app.packageName, app.className)
+    }
+
+    fun removeAppFromFolder(folderId: String, folderApp: FolderApp) {
+        removeAppFromFolder(folderId, folderApp.packageName, folderApp.className)
+    }
+
+    private fun removeAppFromFolder(folderId: String, packageName: String, className: String) {
+        lifecycleScope.launch {
+            appDrawerFoldersStore.updateData { current ->
+                val builder = current.toBuilder()
+                val index = builder.foldersList.indexOfFirst { it.id == folderId }
+                if (index >= 0) {
+                    val folder = builder.foldersList[index]
+                    val appIndex = folder.appsList.indexOfFirst {
+                        it.packageName == packageName && it.className == className
+                    }
+                    if (appIndex >= 0) {
+                        builder.setFolders(index, folder.toBuilder().removeApps(appIndex))
+                    }
+                }
+                builder.build()
+            }
+        }
+    }
+}

+ 25 - 0
app/src/main/java/com/simplauncher/datasource/folders/AppDrawerFoldersSerializer.kt

@@ -0,0 +1,25 @@
+package com.simplauncher.datasource.folders
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import com.google.protobuf.InvalidProtocolBufferException
+import com.simplauncher.datastore.AppDrawerFolders
+import java.io.InputStream
+import java.io.OutputStream
+
+object AppDrawerFoldersSerializer : Serializer<AppDrawerFolders> {
+    override val defaultValue: AppDrawerFolders = AppDrawerFolders.getDefaultInstance()
+
+    @Suppress("BlockingMethodInNonBlockingContext")
+    override suspend fun readFrom(input: InputStream): AppDrawerFolders {
+        try {
+            return AppDrawerFolders.parseFrom(input)
+        } catch (exception: InvalidProtocolBufferException) {
+            throw CorruptionException("Cannot read proto.", exception)
+        }
+    }
+
+    @Suppress("BlockingMethodInNonBlockingContext")
+    override suspend fun writeTo(t: AppDrawerFolders, output: OutputStream) =
+        t.writeTo(output)
+}

+ 35 - 0
app/src/main/java/com/simplauncher/ui/dialogs/CreateFolderDialog.kt

@@ -0,0 +1,35 @@
+package com.simplauncher.ui.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.widget.EditText
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import com.simplauncher.R
+import com.simplauncher.datasource.folders.AppDrawerFoldersRepository
+
+class CreateFolderDialog : DialogFragment() {
+
+    private lateinit var foldersRepo: AppDrawerFoldersRepository
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val view = LayoutInflater.from(context).inflate(R.layout.rename_dialog_edit_text, null, false)
+        val editText: EditText = view.findViewById(R.id.rename_editText)
+        val builder = AlertDialog.Builder(requireContext())
+        builder.setTitle(R.string.create_folder)
+        builder.setView(view)
+        builder.setPositiveButton(android.R.string.ok) { _, _ ->
+            val name = editText.text.toString().trim()
+            if (name.isNotEmpty()) foldersRepo.createFolder(name)
+        }
+        builder.setNegativeButton(android.R.string.cancel, null)
+        return builder.create()
+    }
+
+    companion object {
+        fun getInstance(foldersRepo: AppDrawerFoldersRepository): CreateFolderDialog {
+            return CreateFolderDialog().apply { this.foldersRepo = foldersRepo }
+        }
+    }
+}

+ 45 - 0
app/src/main/java/com/simplauncher/ui/dialogs/ManageAppFoldersDialog.kt

@@ -0,0 +1,45 @@
+package com.simplauncher.ui.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import com.simplauncher.R
+import com.simplauncher.datastore.UnlauncherApp
+import com.simplauncher.datasource.folders.AppDrawerFoldersRepository
+
+class ManageAppFoldersDialog : DialogFragment() {
+
+    private lateinit var app: UnlauncherApp
+    private lateinit var foldersRepo: AppDrawerFoldersRepository
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val folders = foldersRepo.get().foldersList
+        val folderNames = folders.map { it.name }.toTypedArray()
+        val checkedItems = folders.map { folder ->
+            folder.appsList.any { it.packageName == app.packageName && it.className == app.className }
+        }.toBooleanArray()
+
+        val builder = AlertDialog.Builder(requireContext())
+        builder.setTitle(R.string.manage_app_folders)
+        builder.setMultiChoiceItems(folderNames, checkedItems) { _, which, isChecked ->
+            val folder = folders[which]
+            if (isChecked) {
+                foldersRepo.addAppToFolder(folder.id, app)
+            } else {
+                foldersRepo.removeAppFromFolder(folder.id, app)
+            }
+        }
+        builder.setPositiveButton(android.R.string.ok, null)
+        return builder.create()
+    }
+
+    companion object {
+        fun getInstance(app: UnlauncherApp, foldersRepo: AppDrawerFoldersRepository): ManageAppFoldersDialog {
+            return ManageAppFoldersDialog().apply {
+                this.app = app
+                this.foldersRepo = foldersRepo
+            }
+        }
+    }
+}

+ 46 - 0
app/src/main/java/com/simplauncher/ui/dialogs/RenameFolderDialog.kt

@@ -0,0 +1,46 @@
+package com.simplauncher.ui.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.widget.EditText
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import com.simplauncher.R
+import com.simplauncher.datasource.folders.AppDrawerFoldersRepository
+
+class RenameFolderDialog : DialogFragment() {
+
+    private lateinit var folderId: String
+    private lateinit var folderName: String
+    private lateinit var foldersRepo: AppDrawerFoldersRepository
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val view = LayoutInflater.from(context).inflate(R.layout.rename_dialog_edit_text, null, false)
+        val editText: EditText = view.findViewById(R.id.rename_editText)
+        editText.text.append(folderName)
+        val builder = AlertDialog.Builder(requireContext())
+        builder.setTitle(R.string.rename_folder)
+        builder.setView(view)
+        builder.setPositiveButton(android.R.string.ok) { _, _ ->
+            val name = editText.text.toString().trim()
+            if (name.isNotEmpty()) foldersRepo.renameFolder(folderId, name)
+        }
+        builder.setNegativeButton(android.R.string.cancel, null)
+        return builder.create()
+    }
+
+    companion object {
+        fun getInstance(
+            folderId: String,
+            folderName: String,
+            foldersRepo: AppDrawerFoldersRepository
+        ): RenameFolderDialog {
+            return RenameFolderDialog().apply {
+                this.folderId = folderId
+                this.folderName = folderName
+                this.foldersRepo = foldersRepo
+            }
+        }
+    }
+}

+ 4 - 0
app/src/main/java/com/simplauncher/ui/main/HomeFragment.kt

@@ -47,6 +47,7 @@ import com.simplauncher.datasource.UnlauncherDataSource
 import com.simplauncher.datasource.quickbuttonprefs.QuickButtonPreferencesRepository
 import com.simplauncher.models.HomeApp
 import com.simplauncher.models.MainViewModel
+import com.simplauncher.ui.dialogs.ManageAppFoldersDialog
 import com.simplauncher.ui.dialogs.RenameAppDisplayNameDialog
 import com.simplauncher.utils.BaseFragment
 import com.simplauncher.utils.OnLaunchAppListener
@@ -430,6 +431,9 @@ class HomeFragment : BaseFragment(), OnLaunchAppListener {
                     R.id.rename -> {
                         RenameAppDisplayNameDialog.getInstance(app, unlauncherDataSource.unlauncherAppsRepo).show(childFragmentManager, "AppListAdapter")
                     }
+                    R.id.folders -> {
+                        ManageAppFoldersDialog.getInstance(app, unlauncherDataSource.appDrawerFoldersRepo).show(childFragmentManager, "ManageFolders")
+                    }
                     R.id.uninstall -> {
                         val intent = Intent(Intent.ACTION_DELETE)
                         intent.data = Uri.parse("package:" + app.packageName)

+ 3 - 0
app/src/main/java/com/simplauncher/ui/options/CustomizeAppDrawerFragment.kt

@@ -51,6 +51,9 @@ class CustomizeAppDrawerFragment : BaseFragment() {
         
         setupSearchFieldOptionsButton()
         setupHeadingSwitch()
+
+        binding.customizeAppDrawerFragmentFolders
+            .setOnClickListener(Navigation.createNavigateOnClickListener(R.id.action_customiseAppDrawerFragment_to_customizeFoldersFragment))
     }
 
     private fun setupSearchFieldOptionsButton() {

+ 82 - 0
app/src/main/java/com/simplauncher/ui/options/CustomizeFolderDetailFragment.kt

@@ -0,0 +1,82 @@
+package com.simplauncher.ui.options
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.simplauncher.adapters.CustomizeFolderAppsAdapter
+import com.simplauncher.datastore.FolderApp
+import com.simplauncher.datastore.UnlauncherApp
+import com.simplauncher.databinding.CustomizeFolderDetailFragmentBinding
+import com.simplauncher.datasource.UnlauncherDataSource
+import com.simplauncher.ui.dialogs.RenameFolderDialog
+import com.simplauncher.utils.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class CustomizeFolderDetailFragment : BaseFragment() {
+
+    @Inject
+    lateinit var unlauncherDataSource: UnlauncherDataSource
+
+    private var _binding: CustomizeFolderDetailFragmentBinding? = null
+    private val binding get() = _binding!!
+
+    private var allApps: List<UnlauncherApp> = emptyList()
+
+    override fun getFragmentView(): ViewGroup = binding.customizeFolderDetailFragment
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        _binding = CustomizeFolderDetailFragmentBinding.inflate(inflater, container, false)
+        return binding.root
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        _binding = null
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        val folderId = arguments?.getString("folderId") ?: return
+        val foldersRepo = unlauncherDataSource.appDrawerFoldersRepo
+        val adapter = CustomizeFolderAppsAdapter { folderApp ->
+            foldersRepo.removeAppFromFolder(folderId, folderApp)
+        }
+
+        binding.customizeFolderDetailList.adapter = adapter
+
+        unlauncherDataSource.unlauncherAppsRepo.liveData().observe(viewLifecycleOwner) { unlauncherApps ->
+            allApps = unlauncherApps.appsList
+        }
+
+        foldersRepo.liveData().observe(viewLifecycleOwner) { folders ->
+            val folder = folders.foldersList.firstOrNull { it.id == folderId } ?: return@observe
+            binding.customizeFolderDetailTitle.text = folder.name
+            adapter.setItems(folder.appsList) { folderApp -> displayNameFor(folderApp) }
+        }
+
+        binding.customizeFolderDetailBack.setOnClickListener {
+            requireActivity().onBackPressedDispatcher.onBackPressed()
+        }
+
+        binding.customizeFolderDetailRename.setOnClickListener {
+            val folder = foldersRepo.get().foldersList.firstOrNull { it.id == folderId } ?: return@setOnClickListener
+            RenameFolderDialog.getInstance(folderId, folder.name, foldersRepo)
+                .show(childFragmentManager, "RENAME_FOLDER")
+        }
+
+        binding.customizeFolderDetailDelete.setOnClickListener {
+            foldersRepo.deleteFolder(folderId)
+            requireActivity().onBackPressedDispatcher.onBackPressed()
+        }
+    }
+
+    private fun displayNameFor(folderApp: FolderApp): String {
+        return allApps.firstOrNull {
+            it.packageName == folderApp.packageName && it.className == folderApp.className
+        }?.displayName ?: folderApp.packageName
+    }
+}

+ 63 - 0
app/src/main/java/com/simplauncher/ui/options/CustomizeFoldersFragment.kt

@@ -0,0 +1,63 @@
+package com.simplauncher.ui.options
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.navigation.Navigation
+import com.simplauncher.R
+import com.simplauncher.adapters.CustomizeFoldersAdapter
+import com.simplauncher.databinding.CustomizeFoldersFragmentBinding
+import com.simplauncher.datasource.UnlauncherDataSource
+import com.simplauncher.ui.dialogs.CreateFolderDialog
+import com.simplauncher.utils.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class CustomizeFoldersFragment : BaseFragment() {
+
+    @Inject
+    lateinit var unlauncherDataSource: UnlauncherDataSource
+
+    private var _binding: CustomizeFoldersFragmentBinding? = null
+    private val binding get() = _binding!!
+
+    override fun getFragmentView(): ViewGroup = binding.customizeFoldersFragment
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        _binding = CustomizeFoldersFragmentBinding.inflate(inflater, container, false)
+        return binding.root
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        _binding = null
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        val foldersRepo = unlauncherDataSource.appDrawerFoldersRepo
+        val adapter = CustomizeFoldersAdapter { folder ->
+            val bundle = Bundle().apply { putString("folderId", folder.id) }
+            Navigation.findNavController(requireView())
+                .navigate(R.id.action_customizeFoldersFragment_to_customizeFolderDetailFragment, bundle)
+        }
+
+        binding.customizeFoldersFragmentList.adapter = adapter
+
+        foldersRepo.liveData().observe(viewLifecycleOwner) { folders ->
+            adapter.setFolders(folders.foldersList)
+        }
+
+        binding.customizeFoldersFragmentBack.setOnClickListener {
+            requireActivity().onBackPressedDispatcher.onBackPressed()
+        }
+
+        binding.customizeFoldersFragmentCreate.setOnClickListener {
+            CreateFolderDialog.getInstance(foldersRepo)
+                .show(childFragmentManager, "CREATE_FOLDER")
+        }
+    }
+}

+ 20 - 0
app/src/main/proto/app_drawer_folders.proto

@@ -0,0 +1,20 @@
+syntax = "proto3";
+
+option java_package = "com.simplauncher.datastore";
+option java_multiple_files = true;
+
+message FolderApp {
+  string package_name = 1;
+  string class_name = 2;
+  int64 user_serial = 3;
+}
+
+message AppDrawerFolder {
+  string id = 1;
+  string name = 2;
+  repeated FolderApp apps = 3;
+}
+
+message AppDrawerFolders {
+  repeated AppDrawerFolder folders = 1;
+}

+ 9 - 0
app/src/main/res/drawable/ic_folder.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:height="24dp"
+    android:width="24dp"
+    android:viewportHeight="24"
+    android:viewportWidth="24">
+    <path
+        android:fillColor="?attr/colorAccent"
+        android:pathData="M10,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/ic_folder_arrow.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:height="24dp"
+    android:width="24dp"
+    android:viewportHeight="24"
+    android:viewportWidth="24">
+    <path
+        android:fillColor="?attr/colorAccent"
+        android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z"/>
+</vector>

+ 24 - 0
app/src/main/res/layout/app_drawer_folder_item.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="center_vertical"
+    android:orientation="horizontal"
+    android:padding="@dimen/padding">
+
+    <TextView
+        android:id="@+id/folder_item_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_apps_list_item" />
+
+    <ImageView
+        android:id="@+id/folder_item_arrow"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:src="@drawable/ic_folder_arrow"
+        android:contentDescription="@null" />
+
+</LinearLayout>

+ 11 - 0
app/src/main/res/layout/customize_app_drawer_fragment.xml

@@ -78,6 +78,17 @@
                 android:textColor="?switchTextColor"
                 app:layout_constraintTop_toBottomOf="@id/customize_app_drawer_fragment_search_options" />
 
+            <TextView
+                android:id="@+id/customize_app_drawer_fragment_folders"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/margin_list_items"
+                android:text="@string/customize_app_drawer_fragment_folders"
+                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_show_headings_switch" />
+
         </androidx.constraintlayout.widget.ConstraintLayout>
     </ScrollView>
 </androidx.constraintlayout.widget.ConstraintLayout>

+ 25 - 0
app/src/main/res/layout/customize_folder_detail_app_item.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="center_vertical"
+    android:orientation="horizontal"
+    android:padding="@dimen/padding">
+
+    <TextView
+        android:id="@+id/folder_detail_app_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_options" />
+
+    <TextView
+        android:id="@+id/folder_detail_app_remove"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/menu_remove"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_options" />
+
+</LinearLayout>

+ 73 - 0
app/src/main/res/layout/customize_folder_detail_fragment.xml

@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/customize_folder_detail_fragment"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:layout_marginStart="@dimen/margin_sides"
+    android:layout_marginEnd="@dimen/margin_sides"
+    android:layout_marginTop="@dimen/margin_top_small"
+    tools:context=".ui.options.CustomizeFolderDetailFragment">
+
+    <ImageView
+        android:id="@+id/customize_folder_detail_back"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="@dimen/padding"
+        android:paddingStart="0dp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:srcCompat="@drawable/ic_back"
+        android:contentDescription="@string/content_description_back"
+        tools:ignore="RtlSymmetry" />
+
+    <TextView
+        android:id="@+id/customize_folder_detail_title"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textColor="?headerTextColor"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_title"
+        app:layout_constraintStart_toEndOf="@+id/customize_folder_detail_back"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:text="My Folder" />
+
+    <TextView
+        android:id="@+id/customize_folder_detail_rename"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/margin_list_items"
+        android:layout_marginStart="@dimen/margin_sides_small"
+        android:text="@string/rename_folder"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_options"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/customize_folder_detail_title" />
+
+    <TextView
+        android:id="@+id/customize_folder_detail_delete"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/margin_list_items"
+        android:layout_marginStart="@dimen/margin_sides_small"
+        android:text="@string/delete_folder"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_options"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/customize_folder_detail_rename" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/customize_folder_detail_list"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginTop="@dimen/margin_top"
+        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/customize_folder_detail_delete"
+        tools:listitem="@layout/customize_folder_detail_app_item" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 61 - 0
app/src/main/res/layout/customize_folders_fragment.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/customize_folders_fragment"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:layout_marginStart="@dimen/margin_sides"
+    android:layout_marginEnd="@dimen/margin_sides"
+    android:layout_marginTop="@dimen/margin_top_small"
+    tools:context=".ui.options.CustomizeFoldersFragment">
+
+    <ImageView
+        android:id="@+id/customize_folders_fragment_back"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="@dimen/padding"
+        android:paddingStart="0dp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:srcCompat="@drawable/ic_back"
+        android:contentDescription="@string/content_description_back"
+        tools:ignore="RtlSymmetry" />
+
+    <TextView
+        android:id="@+id/customize_folders_fragment_title"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textColor="?headerTextColor"
+        android:text="@string/customize_folders_title"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_title"
+        app:layout_constraintStart_toEndOf="@+id/customize_folders_fragment_back"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <TextView
+        android:id="@+id/customize_folders_fragment_create"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/margin_list_items"
+        android:layout_marginStart="@dimen/margin_sides_small"
+        android:text="@string/create_folder"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_options"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/customize_folders_fragment_title" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/customize_folders_fragment_list"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginTop="@dimen/margin_top"
+        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/customize_folders_fragment_create"
+        tools:listitem="@layout/customize_folders_list_item" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 24 - 0
app/src/main/res/layout/customize_folders_list_item.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="center_vertical"
+    android:orientation="horizontal"
+    android:padding="@dimen/padding">
+
+    <TextView
+        android:id="@+id/folder_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:textAppearance="@style/TextAppearance.AppCompat"
+        android:textSize="@dimen/font_size_customize_options" />
+
+    <ImageView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:src="@drawable/ic_back"
+        android:rotation="180"
+        android:contentDescription="@null" />
+
+</LinearLayout>

+ 4 - 0
app/src/main/res/menu/app_long_press_menu.xml

@@ -16,6 +16,10 @@
         android:id="@+id/rename"
         android:title="@string/rename_app"
         android:icon="@drawable/ic_rename_app" />
+    <item
+        android:id="@+id/folders"
+        android:title="@string/folder_menu_item"
+        android:icon="@drawable/ic_folder" />
     <item
         android:id="@+id/uninstall"
         android:title="@string/uninstall"

+ 21 - 0
app/src/main/res/navigation/nav_graph.xml

@@ -59,6 +59,9 @@
         <action
             android:id="@+id/action_customiseAppDrawerFragment_to_customizeSearchFieldFragment"
             app:destination="@id/customizeSearchFieldFragment" />
+        <action
+            android:id="@+id/action_customiseAppDrawerFragment_to_customizeFoldersFragment"
+            app:destination="@id/customizeFoldersFragment" />
     </fragment>
     <fragment
         android:id="@+id/customiseAppDrawerAppListFragment"
@@ -70,5 +73,23 @@
         android:name="com.simplauncher.ui.options.CustomizeSearchFieldFragment"
         android:label="customize_app_drawer_search_field_fragment"
         tools:layout="@layout/customize_app_drawer_fragment_search_field_options" />
+    <fragment
+        android:id="@+id/customizeFoldersFragment"
+        android:name="com.simplauncher.ui.options.CustomizeFoldersFragment"
+        android:label="customize_folders_fragment"
+        tools:layout="@layout/customize_folders_fragment">
+        <action
+            android:id="@+id/action_customizeFoldersFragment_to_customizeFolderDetailFragment"
+            app:destination="@id/customizeFolderDetailFragment" />
+    </fragment>
+    <fragment
+        android:id="@+id/customizeFolderDetailFragment"
+        android:name="com.simplauncher.ui.options.CustomizeFolderDetailFragment"
+        android:label="customize_folder_detail_fragment"
+        tools:layout="@layout/customize_folder_detail_fragment">
+        <argument
+            android:name="folderId"
+            app:argType="string" />
+    </fragment>
 
 </navigation>

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

@@ -94,4 +94,11 @@
     <string name="rename_app">Rename App</string>
     <string name="shown">shown</string>
     <string name="uninstall">Uninstall</string>
+    <string name="create_folder">Create Folder</string>
+    <string name="customize_app_drawer_fragment_folders">Folders</string>
+    <string name="customize_folders_title">Folders</string>
+    <string name="delete_folder">Delete Folder</string>
+    <string name="folder_menu_item">Folders</string>
+    <string name="manage_app_folders">Manage Folders</string>
+    <string name="rename_folder">Rename Folder</string>
 </resources>