Add settings page to manage tags

This commit is contained in:
MM20 2022-12-21 19:24:32 +01:00
parent 85f7a740e0
commit ddc157741a
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
16 changed files with 586 additions and 38 deletions

View File

@ -128,6 +128,7 @@ dependencies {
implementation(project(":core:permissions")) implementation(project(":core:permissions"))
implementation(project(":core:preferences")) implementation(project(":core:preferences"))
implementation(project(":services:search")) implementation(project(":services:search"))
implementation(project(":services:tags"))
implementation(project(":data:unitconverter")) implementation(project(":data:unitconverter"))
implementation(project(":app:ui")) implementation(project(":app:ui"))
implementation(project(":data:weather")) implementation(project(":data:weather"))

View File

@ -28,6 +28,7 @@ import de.mm20.launcher2.notifications.notificationsModule
import de.mm20.launcher2.permissions.permissionsModule import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.preferences.preferencesModule import de.mm20.launcher2.preferences.preferencesModule
import de.mm20.launcher2.searchactions.searchActionsModule import de.mm20.launcher2.searchactions.searchActionsModule
import de.mm20.launcher2.services.tags.servicesTagsModule
import de.mm20.launcher2.weather.weatherModule import de.mm20.launcher2.weather.weatherModule
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@ -74,7 +75,8 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
weatherModule, weatherModule,
websitesModule, websitesModule,
widgetsModule, widgetsModule,
wikipediaModule wikipediaModule,
servicesTagsModule,
) )
) )
} }

View File

@ -117,6 +117,7 @@ dependencies {
implementation(project(":core:ktx")) implementation(project(":core:ktx"))
implementation(project(":services:icons")) implementation(project(":services:icons"))
implementation(project(":services:music")) implementation(project(":services:music"))
implementation(project(":services:tags"))
implementation(project(":data:weather")) implementation(project(":data:weather"))
implementation(project(":data:calendar")) implementation(project(":data:calendar"))
implementation(project(":services:search")) implementation(project(":services:search"))

View File

@ -10,6 +10,7 @@ import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@ -85,6 +86,6 @@ class CustomizeSearchableSheetVM(
} }
suspend fun autocompleteTags(query: String): List<String> { suspend fun autocompleteTags(query: String): List<String> {
return customAttributesRepository.getAllTags(startsWith = query) return customAttributesRepository.getAllTags(startsWith = query).first()
} }
} }

View File

@ -78,6 +78,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
availableTags.value = availableTags.value =
customAttributesRepository customAttributesRepository
.getAllTags() .getAllTags()
.first()
.filter {t -> pinnedTags.none { it.tag == t } } .filter {t -> pinnedTags.none { it.tag == t } }
.sortedBy { it.normalize() } .sortedBy { it.normalize() }
.map { Tag(it) } .map { Tag(it) }

View File

@ -1,11 +1,265 @@
package de.mm20.launcher2.ui.settings.tags 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.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 @Composable
fun EditTagSheet( fun EditTagSheet(
tag: String?, tag: String?,
onDismiss: () -> Unit, 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
)
)
}
}
} }

View File

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

View File

@ -2,12 +2,26 @@ package de.mm20.launcher2.ui.settings.tags
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add 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.material.icons.rounded.Tag
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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 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.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
@ -16,27 +30,71 @@ import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
fun TagsSettingsScreen() { fun TagsSettingsScreen() {
val viewModel: TagsSettingsScreenVM = viewModel() val viewModel: TagsSettingsScreenVM = viewModel()
LaunchedEffect(null) { val tags by remember { viewModel.tags }.collectAsState(emptyList())
viewModel.update()
}
PreferenceScreen( PreferenceScreen(
title = "Tags", title = "Tags",
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { /*TODO*/ }) { FloatingActionButton(onClick = { viewModel.createTag.value = true }) {
Icon(Icons.Rounded.Add, null) Icon(Icons.Rounded.Add, null)
} }
} }
) { ) {
item { item {
PreferenceCategory { PreferenceCategory {
for (tag in viewModel.tags.value) { for (tag in tags) {
var showMenu by remember { mutableStateOf(false) }
Preference( Preference(
icon = Icons.Rounded.Tag, icon = Icons.Rounded.Tag,
title = 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
}
)
}
} }

View File

@ -4,17 +4,35 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.services.tags.TagsService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
class TagsSettingsScreenVM: ViewModel(), KoinComponent { 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() { var editTag = mutableStateOf<String?>(null)
tags.value = customAttributesRepository.getAllTags() 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)
} }
} }

View File

@ -9,10 +9,10 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface CustomAttrsDao { interface CustomAttrsDao {
@Query("SELECT * FROM CustomAttributes WHERE type = :type AND key = :key LIMIT 1") @Query("SELECT * FROM CustomAttributes WHERE type = :type AND `key` = :key LIMIT 1")
fun getCustomAttribute(key: String, type: String) : Flow<CustomAttributeEntity?> fun getCustomAttribute(key: String, type: String) : Flow<CustomAttributeEntity?>
@Query("DELETE FROM CustomAttributes WHERE type = :type AND key = :key") @Query("DELETE FROM CustomAttributes WHERE type = :type AND `key` = :key")
fun clearCustomAttribute(key: String, type: String) fun clearCustomAttribute(key: String, type: String)
@Insert @Insert
@ -21,10 +21,10 @@ interface CustomAttrsDao {
@Insert @Insert
suspend fun insertCustomAttributes(entities: List<CustomAttributeEntity>) suspend fun insertCustomAttributes(entities: List<CustomAttributeEntity>)
@Query("SELECT * FROM CustomAttributes WHERE type = :type AND key IN (:keys)") @Query("SELECT * FROM CustomAttributes WHERE type = :type AND `key` IN (:keys)")
fun getCustomAttributes(keys: List<String>, type: String) : Flow<List<CustomAttributeEntity>> fun getCustomAttributes(keys: List<String>, type: String) : Flow<List<CustomAttributeEntity>>
@Query("SELECT DISTINCT key FROM CustomAttributes WHERE (type = 'label' OR type = 'tag') AND value LIKE :query") @Query("SELECT DISTINCT `key` FROM CustomAttributes WHERE (type = 'label' OR type = 'tag') AND value LIKE :query")
fun search(query: String): Flow<List<String>> fun search(query: String): Flow<List<String>>
@Transaction @Transaction
@ -34,24 +34,30 @@ interface CustomAttrsDao {
} }
@Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' AND value LIKE :like ORDER BY value") @Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' AND value LIKE :like ORDER BY value")
suspend fun getAllTagsLike(like: String): List<String> fun getAllTagsLike(like: String): Flow<List<String>>
@Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value") @Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value")
suspend fun getAllTags(): List<String> fun getAllTags(): Flow<List<String>>
@Query("SELECT key FROM CustomAttributes WHERE type = 'tag' AND value = :tag") @Query("SELECT `key` FROM CustomAttributes WHERE type = 'tag' AND value = :tag")
fun getItemsWithTag(tag: String): Flow<List<String>> fun getItemsWithTag(tag: String): Flow<List<String>>
@Transaction
suspend fun setItemsWithTag(tag: String, items: List<String>) {
deleteTag(tag)
insertCustomAttributes(items.map { CustomAttributeEntity(it, "tag", tag) })
}
@Transaction @Transaction
suspend fun addTag(key: String, tag: String) { suspend fun addTag(key: String, tag: String) {
removeTag(key, tag) removeTag(key, tag)
insertTag(key, tag) insertTag(key, tag)
} }
@Query("DELETE FROM CustomAttributes WHERE type = 'tag' AND key = :key AND value = :tag") @Query("DELETE FROM CustomAttributes WHERE type = 'tag' AND `key` = :key AND value = :tag")
suspend fun removeTag(key: String, tag: String) suspend fun removeTag(key: String, tag: String)
@Query("INSERT INTO CustomAttributes (key, value, type) VALUES (:key, :tag, 'tag')") @Query("INSERT INTO CustomAttributes (`key`, value, type) VALUES (:key, :tag, 'tag')")
suspend fun insertTag(key: String, tag: String) suspend fun insertTag(key: String, tag: String)
@Query("UPDATE CustomAttributes SET value = :newName WHERE value = :oldName AND type = 'tag'") @Query("UPDATE CustomAttributes SET value = :newName WHERE value = :oldName AND type = 'tag'")

View File

@ -710,4 +710,16 @@
<string name="search_action_websearch_url_hint">The URL template that is used to construct the web search URL. Use ${1} as a placeholder for the actual search term, e.g. https://google.com/search?q=${1}.</string> <string name="search_action_websearch_url_hint">The URL template that is used to construct the web search URL. Use ${1} as a placeholder for the actual search term, e.g. https://google.com/search?q=${1}.</string>
<string name="more_information">More information</string> <string name="more_information">More information</string>
<string name="experimental_feature">Experimental</string> <string name="experimental_feature">Experimental</string>
<string name="create_tag_title">New tag</string>
<string name="edit_tag_title">Edit tag</string>
<string name="tag_exists_error">A tag with this name already exists.</string>
<string name="tag_exists_message">A tag with this name already exists. If you continue, the two tags will be merged.</string>
<string name="tag_no_items_message">No items are assigned to this tag. If you continue, the tag will be deleted.</string>
<string name="tag_empty_message">Tag name must not be blank.</string>
<string name="tag_select_items">Select items:</string>
<string name="tag_name">Tag name</string>
<plurals name="tag_selected_items">
<item quantity="one">%1$d item selected</item>
<item quantity="other">%1$d items selected</item>
</plurals>
</resources> </resources>

View File

@ -34,11 +34,13 @@ interface CustomAttributesRepository {
suspend fun export(toDir: File) suspend fun export(toDir: File)
suspend fun import(fromDir: File) suspend fun import(fromDir: File)
suspend fun getAllTags(startsWith: String? = null): List<String> fun getAllTags(startsWith: String? = null): Flow<List<String>>
fun getItemsForTag(tag: String): Flow<List<SavableSearchable>> fun getItemsForTag(tag: String): Flow<List<SavableSearchable>>
fun setItemsForTag(tag: String, items: List<SavableSearchable>): Job
fun addTag(item: SavableSearchable, tag: String) fun addTag(item: SavableSearchable, tag: String)
fun renameTag(oldName: String, newName: String) fun renameTag(oldName: String, newName: String): Job
fun deleteTag(tag: String): Job
suspend fun cleanupDatabase(): Int suspend fun cleanupDatabase(): Int
} }
@ -114,7 +116,7 @@ internal class CustomAttributesRepositoryImpl(
} }
} }
override suspend fun getAllTags(startsWith: String?): List<String> { override fun getAllTags(startsWith: String?): Flow<List<String>> {
val dao = appDatabase.customAttrsDao() val dao = appDatabase.customAttrsDao()
return if (startsWith != null) { return if (startsWith != null) {
dao.getAllTagsLike("$startsWith%") dao.getAllTagsLike("$startsWith%")
@ -130,6 +132,17 @@ internal class CustomAttributesRepositoryImpl(
} }
} }
override fun setItemsForTag(tag: String, items: List<SavableSearchable>): Job {
val dao = appDatabase.customAttrsDao()
return scope.launch {
dao.setItemsWithTag(tag, items.map { it.key })
for (item in items) {
favoritesRepository.save(item)
}
}
}
override fun addTag(item: SavableSearchable, tag: String) { override fun addTag(item: SavableSearchable, tag: String) {
val dao = appDatabase.customAttrsDao() val dao = appDatabase.customAttrsDao()
scope.launch { scope.launch {
@ -137,13 +150,20 @@ internal class CustomAttributesRepositoryImpl(
} }
} }
override fun renameTag(oldName: String, newName: String) { override fun renameTag(oldName: String, newName: String): Job {
val dao = appDatabase.customAttrsDao() val dao = appDatabase.customAttrsDao()
scope.launch { return scope.launch {
dao.renameTag(oldName, newName) dao.renameTag(oldName, newName)
} }
} }
override fun deleteTag(tag: String): Job {
val dao = appDatabase.customAttrsDao()
return scope.launch {
dao.deleteTag(tag)
}
}
override fun search(query: String): Flow<ImmutableList<SavableSearchable>> { override fun search(query: String): Flow<ImmutableList<SavableSearchable>> {
if (query.isBlank()) { if (query.isBlank()) {
return flow { return flow {

View File

@ -47,5 +47,7 @@ dependencies {
implementation(project(":core:base")) implementation(project(":core:base"))
implementation(project(":core:ktx")) implementation(project(":core:ktx"))
implementation(project(":core:crashreporter")) implementation(project(":core:crashreporter"))
implementation(project(":data:customattrs"))
implementation(project(":data:favorites"))
} }

View File

@ -4,5 +4,5 @@ import de.mm20.launcher2.services.tags.impl.TagsServiceImpl
import org.koin.dsl.module import org.koin.dsl.module
val servicesTagsModule = module { val servicesTagsModule = module {
single<TagsService> { TagsServiceImpl() } single<TagsService> { TagsServiceImpl(get(), get()) }
} }

View File

@ -1,8 +1,15 @@
package de.mm20.launcher2.services.tags package de.mm20.launcher2.services.tags
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.flow.Flow
interface TagsService { interface TagsService {
fun getTags(startsWith: String? = null): List<String> fun getAllTags(startsWith: String? = null): Flow<List<String>>
fun renameTag(oldName: String, newName: String)
fun deleteTag(tag: String) fun deleteTag(tag: String)
fun cloneTag(tag: String, newTag: String) fun cloneTag(tag: String, newTag: String)
fun getTaggedItems(tag: String): Flow<List<SavableSearchable>>
fun createTag(tag: String, items: List<SavableSearchable>)
fun updateTag(tag: String, newName: String? = null, items: List<SavableSearchable>? = null)
} }

View File

@ -1,22 +1,67 @@
package de.mm20.launcher2.services.tags.impl package de.mm20.launcher2.services.tags.impl
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.Tag
import de.mm20.launcher2.services.tags.TagsService import de.mm20.launcher2.services.tags.TagsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
internal class TagsServiceImpl( internal class TagsServiceImpl(
): TagsService { private val customAttributesRepository: CustomAttributesRepository,
override fun getTags(startsWith: String?): List<String> { private val favoritesRepository: FavoritesRepository,
TODO("Not yet implemented") ) : TagsService {
} private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getAllTags(startsWith: String?): Flow<List<String>> {
override fun renameTag(oldName: String, newName: String) { return customAttributesRepository.getAllTags(startsWith)
TODO("Not yet implemented")
} }
override fun deleteTag(tag: String) { override fun deleteTag(tag: String) {
TODO("Not yet implemented") favoritesRepository.remove(Tag(tag))
customAttributesRepository.deleteTag(tag)
} }
override fun cloneTag(tag: String, newTag: String) { override fun cloneTag(tag: String, newTag: String) {
TODO("Not yet implemented") scope.launch {
val items = getTaggedItems(tag).first()
createTag(newTag, items)
}
}
override fun getTaggedItems(tag: String): Flow<List<SavableSearchable>> {
return customAttributesRepository.getItemsForTag(tag)
}
override fun updateTag(tag: String, newName: String?, items: List<SavableSearchable>?) {
scope.launch {
if (items != null) {
customAttributesRepository.setItemsForTag(tag, items).join()
}
if (newName != null && newName != tag) {
customAttributesRepository.renameTag(tag, newName).join()
val pinnedTags = favoritesRepository.getFavorites(
includeTypes = listOf(Tag.Domain),
manuallySorted = true,
automaticallySorted = true
).first()
val oldTag = Tag(tag)
if (pinnedTags.any { it.key == oldTag.key }) {
favoritesRepository.unpinItem(oldTag)
favoritesRepository.pinItem(Tag(newName))
}
}
}
}
override fun createTag(tag: String, items: List<SavableSearchable>) {
scope.launch {
customAttributesRepository.setItemsForTag(tag, items)
}
} }
} }