diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index ecda4797..5ea75999 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -128,6 +128,7 @@ dependencies { implementation(project(":core:permissions")) implementation(project(":core:preferences")) implementation(project(":services:search")) + implementation(project(":services:tags")) implementation(project(":data:unitconverter")) implementation(project(":app:ui")) implementation(project(":data:weather")) diff --git a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index 19a66e5b..7d2ac72d 100644 --- a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -28,6 +28,7 @@ import de.mm20.launcher2.notifications.notificationsModule import de.mm20.launcher2.permissions.permissionsModule import de.mm20.launcher2.preferences.preferencesModule import de.mm20.launcher2.searchactions.searchActionsModule +import de.mm20.launcher2.services.tags.servicesTagsModule import de.mm20.launcher2.weather.weatherModule import kotlinx.coroutines.* import org.koin.android.ext.koin.androidContext @@ -74,7 +75,8 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory { weatherModule, websitesModule, widgetsModule, - wikipediaModule + wikipediaModule, + servicesTagsModule, ) ) } diff --git a/app/ui/build.gradle.kts b/app/ui/build.gradle.kts index b1fd242d..649ccada 100644 --- a/app/ui/build.gradle.kts +++ b/app/ui/build.gradle.kts @@ -117,6 +117,7 @@ dependencies { implementation(project(":core:ktx")) implementation(project(":services:icons")) implementation(project(":services:music")) + implementation(project(":services:tags")) implementation(project(":data:weather")) implementation(project(":data:calendar")) implementation(project(":services:search")) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/CustomizeSearchableSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/CustomizeSearchableSheetVM.kt index 16e6e9b6..0d53619e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/CustomizeSearchableSheetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/CustomizeSearchableSheetVM.kt @@ -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 { - return customAttributesRepository.getAllTags(startsWith = query) + return customAttributesRepository.getAllTags(startsWith = query).first() } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt index 15b701b1..b2e46281 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt @@ -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) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheet.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheet.kt index c7d689c3..81a645a6 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheet.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheet.kt @@ -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 + ) + ) + } + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheetVM.kt new file mode 100644 index 00000000..90d1baa3 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/EditTagSheetVM.kt @@ -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(null) + private var allTags by mutableStateOf(emptySet()) + var tagName by mutableStateOf("") + + var loading by mutableStateOf(true) + + var page by mutableStateOf(EditTagSheetPage.CreateTag) + + var taggedItems by mutableStateOf(emptyList()) + var taggableApps by mutableStateOf(emptyList()) + var taggableOther by mutableStateOf(emptyList()) + + 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 { + 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) \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreen.kt index c55980a3..687e812b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreen.kt @@ -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 + } + ) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreenVM.kt index 33549219..1363ede6 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/tags/TagsSettingsScreenVM.kt @@ -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()) + val tags = tagsService.getAllTags() - suspend fun update() { - tags.value = customAttributesRepository.getAllTags() + var editTag = mutableStateOf(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) + } + } \ No newline at end of file diff --git a/core/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt b/core/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt index ec601900..82cb6317 100644 --- a/core/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt +++ b/core/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt @@ -9,10 +9,10 @@ import kotlinx.coroutines.flow.Flow @Dao 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 - @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) @Insert @@ -21,10 +21,10 @@ interface CustomAttrsDao { @Insert suspend fun insertCustomAttributes(entities: List) - @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, type: String) : Flow> - @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> @Transaction @@ -34,24 +34,30 @@ interface CustomAttrsDao { } @Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' AND value LIKE :like ORDER BY value") - suspend fun getAllTagsLike(like: String): List + fun getAllTagsLike(like: String): Flow> @Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value") - suspend fun getAllTags(): List + fun getAllTags(): Flow> - @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> + @Transaction + suspend fun setItemsWithTag(tag: String, items: List) { + deleteTag(tag) + insertCustomAttributes(items.map { CustomAttributeEntity(it, "tag", tag) }) + } + @Transaction suspend fun addTag(key: String, tag: String) { removeTag(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) - @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) @Query("UPDATE CustomAttributes SET value = :newName WHERE value = :oldName AND type = 'tag'") diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 3cfd95c4..0ad3026a 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -710,4 +710,16 @@ 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}. More information Experimental + New tag + Edit tag + A tag with this name already exists. + A tag with this name already exists. If you continue, the two tags will be merged. + No items are assigned to this tag. If you continue, the tag will be deleted. + Tag name must not be blank. + Select items: + Tag name + + %1$d item selected + %1$d items selected + \ No newline at end of file diff --git a/data/customattrs/src/main/java/de/mm20/launcher2/data/customattrs/CustomAttributesRepository.kt b/data/customattrs/src/main/java/de/mm20/launcher2/data/customattrs/CustomAttributesRepository.kt index c992cdeb..e8dac3ed 100644 --- a/data/customattrs/src/main/java/de/mm20/launcher2/data/customattrs/CustomAttributesRepository.kt +++ b/data/customattrs/src/main/java/de/mm20/launcher2/data/customattrs/CustomAttributesRepository.kt @@ -34,11 +34,13 @@ interface CustomAttributesRepository { suspend fun export(toDir: File) suspend fun import(fromDir: File) - suspend fun getAllTags(startsWith: String? = null): List + fun getAllTags(startsWith: String? = null): Flow> fun getItemsForTag(tag: String): Flow> + fun setItemsForTag(tag: String, items: List): Job 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 } @@ -114,7 +116,7 @@ internal class CustomAttributesRepositoryImpl( } } - override suspend fun getAllTags(startsWith: String?): List { + override fun getAllTags(startsWith: String?): Flow> { val dao = appDatabase.customAttrsDao() return if (startsWith != null) { dao.getAllTagsLike("$startsWith%") @@ -130,6 +132,17 @@ internal class CustomAttributesRepositoryImpl( } } + override fun setItemsForTag(tag: String, items: List): 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) { val dao = appDatabase.customAttrsDao() 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() - scope.launch { + return scope.launch { 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> { if (query.isBlank()) { return flow { diff --git a/services/tags/build.gradle.kts b/services/tags/build.gradle.kts index a86be48b..872bb6d1 100644 --- a/services/tags/build.gradle.kts +++ b/services/tags/build.gradle.kts @@ -47,5 +47,7 @@ dependencies { implementation(project(":core:base")) implementation(project(":core:ktx")) implementation(project(":core:crashreporter")) + implementation(project(":data:customattrs")) + implementation(project(":data:favorites")) } \ No newline at end of file diff --git a/services/tags/src/main/java/de/mm20/launcher2/services/tags/Module.kt b/services/tags/src/main/java/de/mm20/launcher2/services/tags/Module.kt index 67d8b35c..9f5bd07a 100644 --- a/services/tags/src/main/java/de/mm20/launcher2/services/tags/Module.kt +++ b/services/tags/src/main/java/de/mm20/launcher2/services/tags/Module.kt @@ -4,5 +4,5 @@ import de.mm20.launcher2.services.tags.impl.TagsServiceImpl import org.koin.dsl.module val servicesTagsModule = module { - single { TagsServiceImpl() } + single { TagsServiceImpl(get(), get()) } } \ No newline at end of file diff --git a/services/tags/src/main/java/de/mm20/launcher2/services/tags/TagsService.kt b/services/tags/src/main/java/de/mm20/launcher2/services/tags/TagsService.kt index 97e9eb90..8f0dca28 100644 --- a/services/tags/src/main/java/de/mm20/launcher2/services/tags/TagsService.kt +++ b/services/tags/src/main/java/de/mm20/launcher2/services/tags/TagsService.kt @@ -1,8 +1,15 @@ package de.mm20.launcher2.services.tags +import de.mm20.launcher2.search.SavableSearchable +import kotlinx.coroutines.flow.Flow + interface TagsService { - fun getTags(startsWith: String? = null): List - fun renameTag(oldName: String, newName: String) + fun getAllTags(startsWith: String? = null): Flow> fun deleteTag(tag: String) fun cloneTag(tag: String, newTag: String) + fun getTaggedItems(tag: String): Flow> + + fun createTag(tag: String, items: List) + + fun updateTag(tag: String, newName: String? = null, items: List? = null) } \ No newline at end of file diff --git a/services/tags/src/main/java/de/mm20/launcher2/services/tags/impl/TagsServiceImpl.kt b/services/tags/src/main/java/de/mm20/launcher2/services/tags/impl/TagsServiceImpl.kt index 6f1aab54..acbfc037 100644 --- a/services/tags/src/main/java/de/mm20/launcher2/services/tags/impl/TagsServiceImpl.kt +++ b/services/tags/src/main/java/de/mm20/launcher2/services/tags/impl/TagsServiceImpl.kt @@ -1,22 +1,67 @@ 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 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( -): TagsService { - override fun getTags(startsWith: String?): List { - TODO("Not yet implemented") - } - - override fun renameTag(oldName: String, newName: String) { - TODO("Not yet implemented") + private val customAttributesRepository: CustomAttributesRepository, + private val favoritesRepository: FavoritesRepository, +) : TagsService { + private val scope = CoroutineScope(Job() + Dispatchers.Default) + override fun getAllTags(startsWith: String?): Flow> { + return customAttributesRepository.getAllTags(startsWith) } override fun deleteTag(tag: String) { - TODO("Not yet implemented") + favoritesRepository.remove(Tag(tag)) + customAttributesRepository.deleteTag(tag) } 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> { + return customAttributesRepository.getItemsForTag(tag) + } + + override fun updateTag(tag: String, newName: String?, items: List?) { + 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) { + scope.launch { + customAttributesRepository.setItemsForTag(tag, items) + } } } \ No newline at end of file