Add settings page to manage tags

This commit is contained in:
MM20
2022-12-21 19:24:32 +01:00
parent 85f7a740e0
commit ddc157741a
16 changed files with 586 additions and 38 deletions
@@ -10,6 +10,7 @@ import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext
@@ -85,6 +86,6 @@ class CustomizeSearchableSheetVM(
}
suspend fun autocompleteTags(query: String): List<String> {
return customAttributesRepository.getAllTags(startsWith = query)
return customAttributesRepository.getAllTags(startsWith = query).first()
}
}
@@ -78,6 +78,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
availableTags.value =
customAttributesRepository
.getAllTags()
.first()
.filter {t -> pinnedTags.none { it.tag == t } }
.sortedBy { it.normalize() }
.map { Tag(it) }
@@ -1,11 +1,265 @@
package de.mm20.launcher2.ui.settings.tags
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.SmallMessage
import de.mm20.launcher2.ui.ktx.toPixels
@Composable
fun EditTagSheet(
tag: String?,
onDismiss: () -> Unit,
) {
val viewModel: EditTagSheetVM = viewModel()
val isCreatingNewTag = tag == null
LaunchedEffect(tag) {
viewModel.init(tag)
}
if (viewModel.loading) return
BottomSheetDialog(
title = {
Text(
stringResource(
if (viewModel.page == EditTagSheetPage.CustomizeTag || !isCreatingNewTag) R.string.edit_tag_title
else R.string.create_tag_title
)
)
},
confirmButton = {
if (viewModel.page == EditTagSheetPage.CustomizeTag) {
OutlinedButton(onClick = {
viewModel.save()
onDismiss()
}) {
Text(stringResource(R.string.close))
}
} else if (isCreatingNewTag) {
Button(
enabled = (viewModel.tagName.isNotBlank() && viewModel.page == EditTagSheetPage.CreateTag && !viewModel.tagNameExists)
|| (viewModel.page == EditTagSheetPage.PickItems && viewModel.taggedItems.isNotEmpty()),
onClick = { viewModel.onClickContinue() }) {
Text(stringResource(R.string.action_next))
}
} else {
OutlinedButton(onClick = { viewModel.closeItemPicker() }) {
Text(stringResource(id = R.string.ok))
}
}
},
onDismissRequest = {
if (viewModel.page == EditTagSheetPage.CustomizeTag) viewModel.save()
onDismiss()
},
swipeToDismiss = {
!(!isCreatingNewTag && viewModel.page == EditTagSheetPage.PickItems)
},
dismissOnBackPress = {
!(!isCreatingNewTag && viewModel.page == EditTagSheetPage.PickItems)
}
) {
when (viewModel.page) {
EditTagSheetPage.CreateTag -> CreateNewTagPage(viewModel)
EditTagSheetPage.PickItems -> PickItems(viewModel)
EditTagSheetPage.CustomizeTag -> CustomizeTag(viewModel)
}
}
}
@Composable
fun CreateNewTagPage(viewModel: EditTagSheetVM) {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardActions = KeyboardActions(
onDone = {
viewModel.onClickContinue()
}
),
isError = viewModel.tagNameExists,
supportingText = { if (viewModel.tagNameExists) Text(stringResource(id = R.string.tag_exists_error)) },
label = { Text(stringResource(R.string.tag_name)) },
value = viewModel.tagName,
onValueChange = { viewModel.tagName = it }
)
}
}
@Composable
fun PickItems(viewModel: EditTagSheetVM) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
Text(stringResource(id = R.string.tag_select_items),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 8.dp)
)
}
items(viewModel.taggableApps) {
val iconSize = 32.dp.toPixels()
val icon by remember(it.item.key) {
viewModel.getIcon(it.item, iconSize.toInt())
}.collectAsState(null)
ListItem(item = it, icon = icon, onTagChanged = { tagged ->
if (tagged) viewModel.tagItem(it.item)
else viewModel.untagItem(it.item)
})
}
item {
Box(
modifier = Modifier
.padding(vertical = 8.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
.fillMaxWidth()
.height(1.dp)
)
}
items(viewModel.taggableOther) {
val iconSize = 32.dp.toPixels()
val icon by remember(it.item.key) {
viewModel.getIcon(it.item, iconSize.toInt())
}.collectAsState(null)
ListItem(item = it, icon = icon, onTagChanged = { tagged ->
if (tagged) viewModel.tagItem(it.item)
else viewModel.untagItem(it.item)
})
}
}
}
@Composable
fun ListItem(
item: TaggableItem,
icon: LauncherIcon?,
onTagChanged: (Boolean) -> Unit
) {
OutlinedCard(
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
onClick = {
onTagChanged(!item.isTagged)
}
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShapedLauncherIcon(icon = { icon }, size = 32.dp, modifier = Modifier.padding(4.dp))
Text(
modifier = Modifier
.padding(horizontal = 12.dp)
.weight(1f),
text = item.item.label,
style = MaterialTheme.typography.labelLarge
)
Checkbox(checked = item.isTagged, onCheckedChange = { checked ->
onTagChanged(checked)
})
}
}
}
@Composable
fun CustomizeTag(viewModel: EditTagSheetVM) {
val iconSize = 32.dp.toPixels()
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(R.string.tag_name)) },
value = viewModel.tagName,
onValueChange = { viewModel.tagName = it }
)
val icon1 = remember(viewModel.taggedItems.getOrNull(0)?.key) {
viewModel.taggedItems.getOrNull(0)?.let {
viewModel.getIcon(it, iconSize.toInt())
}
}?.collectAsState(null)
val icon2 = remember(viewModel.taggedItems.getOrNull(1)?.key) {
viewModel.taggedItems.getOrNull(1)?.let {
viewModel.getIcon(it, iconSize.toInt())
}
}?.collectAsState(null)
val icon3 = remember(viewModel.taggedItems.getOrNull(2)?.key) {
viewModel.taggedItems.getOrNull(2)?.let {
viewModel.getIcon(it, iconSize.toInt())
}
}?.collectAsState(null)
TextButton(
modifier = Modifier
.padding(vertical = 16.dp).fillMaxWidth(),
onClick = { viewModel.openItemPicker() }) {
Text(
modifier = Modifier.weight(1f),
text = pluralStringResource(
R.plurals.tag_selected_items,
viewModel.taggedItems.size,
viewModel.taggedItems.size
)
)
Box(modifier = Modifier.padding(start = 8.dp).width(64.dp).height(32.dp), contentAlignment = Alignment.CenterEnd) {
ShapedLauncherIcon(size = 32.dp, icon = { icon1?.value }, modifier = Modifier.offset(x = -0.dp))
ShapedLauncherIcon(size = 32.dp, icon = { icon2?.value }, modifier = Modifier.offset(x = -16.dp))
ShapedLauncherIcon(size = 32.dp, icon = { icon3?.value }, modifier = Modifier.offset(x = -32.dp))
}
}
AnimatedVisibility(viewModel.tagNameExists || viewModel.taggedItems.isEmpty()) {
SmallMessage(
modifier = Modifier.fillMaxWidth(),
icon = Icons.Rounded.Warning,
text = stringResource(
if (viewModel.taggedItems.isEmpty()) R.string.tag_no_items_message else R.string.tag_exists_message
)
)
}
}
}
@@ -0,0 +1,120 @@
package de.mm20.launcher2.ui.settings.tags
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.services.tags.TagsService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class EditTagSheetVM : ViewModel(), KoinComponent {
private val tagService: TagsService by inject()
private val searchService: SearchService by inject()
private val iconRepository: IconRepository by inject()
private val appRepository: AppRepository by inject()
private var oldTagName by mutableStateOf<String?>(null)
private var allTags by mutableStateOf(emptySet<String>())
var tagName by mutableStateOf("")
var loading by mutableStateOf(true)
var page by mutableStateOf(EditTagSheetPage.CreateTag)
var taggedItems by mutableStateOf(emptyList<SavableSearchable>())
var taggableApps by mutableStateOf(emptyList<TaggableItem>())
var taggableOther by mutableStateOf(emptyList<TaggableItem>())
val tagNameExists by derivedStateOf {
tagName != oldTagName && allTags.contains(tagName)
}
fun init(tag: String?) {
Log.d("MM20", "Init with tag: $tag")
loading = true
this.oldTagName = tag
this.tagName = tag ?: ""
this.page = if (tag == null) EditTagSheetPage.CreateTag else EditTagSheetPage.CustomizeTag
this.taggedItems = emptyList()
viewModelScope.launch(Dispatchers.Default) {
allTags = tagService.getAllTags().first().toSet()
val items = if (tag != null) tagService.getTaggedItems(tag).first() else emptyList()
val apps = appRepository.getAllInstalledApps().first().sorted()
taggedItems = items
taggableApps = apps.map { app -> TaggableItem(app, items.any { app.key == it.key }) }
taggableOther = items.mapNotNull { item ->
if (apps.any { item.key == it.key }) null
else TaggableItem(item, true)
}.sortedBy { it.item }
loading = false
}
}
fun save() {
val oldName = oldTagName
val newName = tagName
if (taggedItems.isEmpty() && oldName != null) tagService.deleteTag(oldName)
else if (oldName != null) tagService.updateTag(oldName, newName = newName, items = taggedItems)
else tagService.createTag(tagName, taggedItems)
loading = true
}
fun onClickContinue() {
if (page == EditTagSheetPage.CreateTag && tagNameExists) return
page = if (page == EditTagSheetPage.CreateTag) EditTagSheetPage.PickItems else EditTagSheetPage.CustomizeTag
oldTagName = tagName
}
fun getIcon(item: SavableSearchable, size: Int): Flow<LauncherIcon> {
return iconRepository.getIcon(item, size)
}
fun openItemPicker() {
page = EditTagSheetPage.PickItems
}
fun closeItemPicker() {
page = EditTagSheetPage.CustomizeTag
}
fun tagItem(item: SavableSearchable) {
taggedItems = taggedItems + item
taggableApps =
taggableApps.map { app -> app.copy(isTagged = taggedItems.any { it.key == app.item.key }) }
taggableOther =
taggableOther.map { oth -> oth.copy(isTagged = taggedItems.any { it.key == oth.item.key }) }
}
fun untagItem(item: SavableSearchable) {
taggedItems = taggedItems.filter { it.key != item.key }
taggableApps =
taggableApps.map { app -> app.copy(isTagged = taggedItems.any { it.key == app.item.key }) }
taggableOther =
taggableOther.map { oth -> oth.copy(isTagged = taggedItems.any { it.key == oth.item.key }) }
}
}
enum class EditTagSheetPage {
CreateTag,
PickItems,
CustomizeTag,
}
@Stable
data class TaggableItem(val item: SavableSearchable, val isTagged: Boolean)
@@ -2,12 +2,26 @@ package de.mm20.launcher2.ui.settings.tags
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
@@ -16,27 +30,71 @@ import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
fun TagsSettingsScreen() {
val viewModel: TagsSettingsScreenVM = viewModel()
LaunchedEffect(null) {
viewModel.update()
}
val tags by remember { viewModel.tags }.collectAsState(emptyList())
PreferenceScreen(
title = "Tags",
floatingActionButton = {
FloatingActionButton(onClick = { /*TODO*/ }) {
FloatingActionButton(onClick = { viewModel.createTag.value = true }) {
Icon(Icons.Rounded.Add, null)
}
}
) {
item {
PreferenceCategory {
for (tag in viewModel.tags.value) {
for (tag in tags) {
var showMenu by remember { mutableStateOf(false) }
Preference(
icon = Icons.Rounded.Tag,
title = tag,
onClick = {
viewModel.editTag.value = tag
},
controls = {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Rounded.MoreVert, null)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.duplicate)) },
leadingIcon = { Icon(Icons.Rounded.ContentCopy, null) },
onClick = {
viewModel.duplicateTag(tag)
showMenu = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_delete)) },
leadingIcon = { Icon(Icons.Rounded.Delete, null) },
onClick = {
viewModel.deleteTag(tag)
showMenu = false
}
)
}
}
)
}
}
}
}
if (viewModel.editTag.value != null) {
EditTagSheet(
tag = viewModel.editTag.value,
onDismiss = {
viewModel.editTag.value = null
viewModel.createTag.value = false
}
)
} else if(viewModel.createTag.value) {
EditTagSheet(
tag = null,
onDismiss = {
viewModel.createTag.value = false
viewModel.editTag.value = null
}
)
}
}
@@ -4,17 +4,35 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.services.tags.TagsService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class TagsSettingsScreenVM: ViewModel(), KoinComponent {
private val customAttributesRepository: CustomAttributesRepository by inject()
private val tagsService: TagsService by inject()
val tags = mutableStateOf(emptyList<String>())
val tags = tagsService.getAllTags()
suspend fun update() {
tags.value = customAttributesRepository.getAllTags()
var editTag = mutableStateOf<String?>(null)
var createTag = mutableStateOf(false)
fun duplicateTag(tag: String) {
viewModelScope.launch {
val allTags = tags.first()
var i = 2
var newName = "$tag ($i)"
while(allTags.contains(newName)) {
i++
newName = "$tag ($i)"
}
tagsService.cloneTag(tag, newName)
}
}
fun deleteTag(tag: String) {
tagsService.deleteTag(tag)
}
}