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.database.AppDatabase
import de.mm20.launcher2.search.data.Searchable 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
interface CustomAttributesRepository { interface CustomAttributesRepository {
fun getCustomIcon(searchable: Searchable): Flow<CustomIcon?> fun getCustomIcon(searchable: Searchable): Flow<CustomIcon?>
fun setCustomIcon(searchable: Searchable, icon: CustomIcon?)
} }
internal class CustomAttributesRepositoryImpl( internal class CustomAttributesRepositoryImpl(
private val appDatabase: AppDatabase, private val appDatabase: AppDatabase,
) : CustomAttributesRepository { ) : CustomAttributesRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getCustomIcon(searchable: Searchable): Flow<CustomIcon?> { override fun getCustomIcon(searchable: Searchable): Flow<CustomIcon?> {
val dao = appDatabase.customAttrsDao() val dao = appDatabase.customAttrsDao()
return dao.getCustomIcon(searchable.key) return dao.getCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
.map { .map {
CustomAttribute.fromDatabaseEntity(it) as? CustomIcon 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 package de.mm20.launcher2.database
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import de.mm20.launcher2.database.entities.CustomAttributeEntity import de.mm20.launcher2.database.entities.CustomAttributeEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface CustomAttrsDao { interface CustomAttrsDao {
@Query("SELECT * FROM CustomAttributes WHERE type = 'icon' AND key = :key LIMIT 1") @Query("SELECT * FROM CustomAttributes WHERE type = :type AND key = :key LIMIT 1")
fun getCustomIcon(key: String) : Flow<CustomAttributeEntity?> 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> <string name="menu_app_info">App info</string>
<!-- Launch an app --> <!-- Launch an app -->
<string name="menu_launch">Launch</string> <string name="menu_launch">Launch</string>
<string name="menu_customize">Customize</string>
<!-- Open a file in the corresponding app --> <!-- Open a file in the corresponding app -->
<string name="menu_open_file">Open</string> <string name="menu_open_file">Open</string>
<string name="menu_hide">Hide</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_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_select_components">Select what to restore. Existing data will be overwritten!</string>
<string name="restore_complete">The backup has been restored.</string> <string name="restore_complete">The backup has been restored.</string>
<string name="icon_picker_title">Pick icon</string>
</resources> </resources>

View File

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

View File

@ -175,15 +175,24 @@ class IconRepository(
val defaultTransformedIcon = applyTransformations(rawIcon, defaultTransformations) val defaultTransformedIcon = applyTransformations(rawIcon, defaultTransformations)
suggestions.add(CustomIconSuggestion( suggestions.add(
defaultTransformedIcon, CustomIconSuggestion(
null, defaultTransformedIcon,
)) null,
)
)
if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) { if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) {
val adaptifyOptions = listOf( val adaptifyOptions = listOf(
// Legacy icons that simply fill the entire canvas
AdaptifiedLegacyIcon( 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 bgColor = 1
), ),
AdaptifiedLegacyIcon( AdaptifiedLegacyIcon(
@ -197,7 +206,8 @@ class IconRepository(
) )
suggestions.addAll( suggestions.addAll(
adaptifyOptions.mapNotNull { adaptifyOptions.mapNotNull {
val transformation = getTransformations(it)?.firstOrNull() ?: return@mapNotNull null val transformation =
getTransformations(it)?.firstOrNull() ?: return@mapNotNull null
CustomIconSuggestion( CustomIconSuggestion(
icon = transformation.transform(rawIcon), icon = transformation.transform(rawIcon),
data = it, 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 var icon = icon
if (icon is StaticLauncherIcon) { if (icon is StaticLauncherIcon) {
for (transformation in transformations) { for (transformation in transformations) {
@ -221,6 +234,10 @@ class IconRepository(
return icon return icon
} }
fun setCustomIcon(searchable: Searchable, icon: CustomIcon?) {
customAttributesRepository.setCustomIcon(searchable, icon)
}
} }
data class CustomIconSuggestion( 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.component.*
import de.mm20.launcher2.ui.ktx.toDp import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels 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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridIconSize import de.mm20.launcher2.ui.locals.LocalGridIconSize
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
@ -48,6 +49,8 @@ fun AppItem(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
var edit by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
modifier = modifier 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 storeDetails = remember(app) { app.getStoreDetails(context) }
val shareAction = if (storeDetails == null) { val shareAction = if (storeDetails == null) {
DefaultToolbarAction( DefaultToolbarAction(
@ -308,6 +317,13 @@ fun AppItem(
rightActions = toolbarActions rightActions = toolbarActions
) )
} }
if (edit) {
CustomizeSearchableSheet(
searchable = app,
onDismiss = { edit = false }
)
}
} }
@OptIn(ExperimentalAnimationApi::class) @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()
}
}