Implement basic icon picker

This commit is contained in:
MM20 2022-07-26 17:20:34 +02:00
parent 70bef9ac14
commit 32c82d74c3
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
8 changed files with 244 additions and 11 deletions

View File

@ -2,21 +2,38 @@ package de.mm20.launcher2.customattrs
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.search.data.Searchable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
interface CustomAttributesRepository {
fun getCustomIcon(searchable: Searchable): Flow<CustomIcon?>
fun setCustomIcon(searchable: Searchable, icon: CustomIcon?)
}
internal class CustomAttributesRepositoryImpl(
private val appDatabase: AppDatabase,
) : CustomAttributesRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getCustomIcon(searchable: Searchable): Flow<CustomIcon?> {
val dao = appDatabase.customAttrsDao()
return dao.getCustomIcon(searchable.key)
return dao.getCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
.map {
CustomAttribute.fromDatabaseEntity(it) as? CustomIcon
}
}
override fun setCustomIcon(searchable: Searchable, icon: CustomIcon?) {
val dao = appDatabase.customAttrsDao()
scope.launch {
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
if (icon != null) {
dao.setCustomAttribute(icon.toDatabaseEntity(searchable.key))
}
}
}
}

View File

@ -1,12 +1,19 @@
package de.mm20.launcher2.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import de.mm20.launcher2.database.entities.CustomAttributeEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CustomAttrsDao {
@Query("SELECT * FROM CustomAttributes WHERE type = 'icon' AND key = :key LIMIT 1")
fun getCustomIcon(key: String) : Flow<CustomAttributeEntity?>
@Query("SELECT * FROM CustomAttributes WHERE type = :type AND key = :key LIMIT 1")
fun getCustomAttribute(key: String, type: String) : Flow<CustomAttributeEntity?>
@Query("DELETE FROM CustomAttributes WHERE type = :type AND key = :key")
fun clearCustomAttribute(key: String, type: String)
@Insert
fun setCustomAttribute(entity: CustomAttributeEntity)
}

View File

@ -17,6 +17,7 @@
<string name="menu_app_info">App info</string>
<!-- Launch an app -->
<string name="menu_launch">Launch</string>
<string name="menu_customize">Customize</string>
<!-- Open a file in the corresponding app -->
<string name="menu_open_file">Open</string>
<string name="menu_hide">Hide</string>
@ -619,4 +620,6 @@
<string name="restore_meta">Created %1$s on %2$s with %3$s.</string>
<string name="restore_select_components">Select what to restore. Existing data will be overwritten!</string>
<string name="restore_complete">The backup has been restored.</string>
<string name="icon_picker_title">Pick icon</string>
</resources>

View File

@ -53,6 +53,6 @@ dependencies {
implementation(project(":search"))
implementation(project(":applications"))
implementation(project(":crashreporter"))
implementation(project(":customattrs"))
api(project(":customattrs"))
}

View File

@ -175,15 +175,24 @@ class IconRepository(
val defaultTransformedIcon = applyTransformations(rawIcon, defaultTransformations)
suggestions.add(CustomIconSuggestion(
defaultTransformedIcon,
null,
))
suggestions.add(
CustomIconSuggestion(
defaultTransformedIcon,
null,
)
)
if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) {
val adaptifyOptions = listOf(
// Legacy icons that simply fill the entire canvas
AdaptifiedLegacyIcon(
fgScale = 1.25f,
fgScale = 1f,
bgColor = 1
),
// 48x48 with 5px padding used to be the default icon size for icons generated by
// the Android Studio asset generator. Upscale these icons to remove that padding.
AdaptifiedLegacyIcon(
fgScale = 48f / 38f,
bgColor = 1
),
AdaptifiedLegacyIcon(
@ -197,7 +206,8 @@ class IconRepository(
)
suggestions.addAll(
adaptifyOptions.mapNotNull {
val transformation = getTransformations(it)?.firstOrNull() ?: return@mapNotNull null
val transformation =
getTransformations(it)?.firstOrNull() ?: return@mapNotNull null
CustomIconSuggestion(
icon = transformation.transform(rawIcon),
data = it,
@ -211,7 +221,10 @@ class IconRepository(
}
private suspend fun applyTransformations(icon: LauncherIcon, transformations: List<LauncherIconTransformation>): LauncherIcon {
private suspend fun applyTransformations(
icon: LauncherIcon,
transformations: List<LauncherIconTransformation>
): LauncherIcon {
var icon = icon
if (icon is StaticLauncherIcon) {
for (transformation in transformations) {
@ -221,6 +234,10 @@ class IconRepository(
return icon
}
fun setCustomIcon(searchable: Searchable, icon: CustomIcon?) {
customAttributesRepository.setCustomIcon(searchable, icon)
}
}
data class CustomIconSuggestion(

View File

@ -28,6 +28,7 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.common.customattrs.CustomizeSearchableSheet
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridIconSize
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
@ -48,6 +49,8 @@ fun AppItem(
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
var edit by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
modifier = modifier
@ -217,6 +220,12 @@ fun AppItem(
)
)
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { edit = true }
))
val storeDetails = remember(app) { app.getStoreDetails(context) }
val shareAction = if (storeDetails == null) {
DefaultToolbarAction(
@ -308,6 +317,13 @@ fun AppItem(
rightActions = toolbarActions
)
}
if (edit) {
CustomizeSearchableSheet(
searchable = app,
onDismiss = { edit = false }
)
}
}
@OptIn(ExperimentalAnimationApi::class)

View File

@ -0,0 +1,130 @@
package de.mm20.launcher2.ui.launcher.search.common.customattrs
import android.graphics.drawable.InsetDrawable
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.search.data.Searchable
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.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalGridColumns
@Composable
fun CustomizeSearchableSheet(
searchable: Searchable,
onDismiss: () -> Unit,
) {
val viewModel: CustomizeSearchableSheetVM =
remember(searchable) { CustomizeSearchableSheetVM(searchable) }
val context = LocalContext.current
val pickIcon by viewModel.isIconPickerOpen.observeAsState(false)
BottomSheetDialog(
onDismissRequest = onDismiss,
title = {
Text(stringResource(if (pickIcon) R.string.icon_picker_title else R.string.menu_customize))
},
confirmButton = {
if (pickIcon) {
OutlinedButton(onClick = { viewModel.closeIconPicker() }) {
Text(stringResource(id = android.R.string.cancel))
}
} else {
OutlinedButton(onClick = onDismiss) {
Text(stringResource(id = R.string.close))
}
}
}
) {
if (!pickIcon) {
Column(
modifier = Modifier
.padding(top = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val iconSize = 64.dp
val iconSizePx = iconSize.toPixels()
val icon by remember { viewModel.getIcon(iconSizePx.toInt()) }.collectAsState(null)
val primaryColor = MaterialTheme.colorScheme.onSecondary
val badgeDrawable = remember {
InsetDrawable(
AppCompatResources.getDrawable(context, R.drawable.ic_edit),
8
).also {
it.setTint(primaryColor.toArgb())
}
}
ShapedLauncherIcon(
size = iconSize,
icon = icon,
badge = Badge(
icon = badgeDrawable
),
onClick = {
viewModel.openIconPicker()
}
)
OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 24.dp),
value = "",
onValueChange = {},
placeholder = {
Text(searchable.label)
},
)
}
} else {
val iconSize = 48.dp
val iconSizePx = iconSize.toPixels()
val suggestions by
remember { viewModel.getIconSuggestions(iconSizePx.toInt()) }
.observeAsState(emptyList())
LazyVerticalGrid(columns = GridCells.Fixed(LocalGridColumns.current)) {
items(suggestions) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(vertical = 8.dp)
) {
ShapedLauncherIcon(
size = iconSize,
icon = it.icon,
onClick = {
viewModel.pickIcon(it.data)
}
)
}
}
}
}
}
}

View File

@ -0,0 +1,43 @@
package de.mm20.launcher2.ui.launcher.search.common.customattrs
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import de.mm20.launcher2.customattrs.CustomIcon
import de.mm20.launcher2.icons.CustomIconSuggestion
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.data.Searchable
import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.inject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class CustomizeSearchableSheetVM(
private val searchable: Searchable
) : KoinComponent {
private val iconRepository: IconRepository by inject()
val isIconPickerOpen = MutableLiveData(false)
fun getIcon(size: Int): Flow<LauncherIcon> {
return iconRepository.getIcon(searchable, size)
}
fun getIconSuggestions(size: Int) = liveData {
emit(iconRepository.getCustomIconSuggestions(searchable, size))
}
fun openIconPicker() {
isIconPickerOpen.value = true
}
fun closeIconPicker() {
isIconPickerOpen.value = false
}
fun pickIcon(icon: CustomIcon?) {
iconRepository.setCustomIcon(searchable, icon)
closeIconPicker()
}
}