Implement basic icon picker
This commit is contained in:
parent
70bef9ac14
commit
32c82d74c3
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
@ -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"))
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user