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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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>
|
||||
@ -53,6 +53,6 @@ dependencies {
|
||||
implementation(project(":search"))
|
||||
implementation(project(":applications"))
|
||||
implementation(project(":crashreporter"))
|
||||
implementation(project(":customattrs"))
|
||||
api(project(":customattrs"))
|
||||
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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