Migrate websearch settings

This commit is contained in:
MM20
2022-01-20 21:19:30 +01:00
parent c91576847b
commit 401a493864
11 changed files with 643 additions and 251 deletions
@@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun Preference(
title: String,
icon: ImageVector? = null,
icon: @Composable (() -> Unit),
summary: String? = null,
onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null,
@@ -30,17 +30,12 @@ fun Preference(
.alpha(if (enabled) 1f else 0.38f),
) {
Box(
modifier = Modifier.width(56.dp),
modifier = Modifier
.width(56.dp)
.padding(start = 4.dp),
contentAlignment = Alignment.CenterStart
) {
if (icon != null) {
Icon(
modifier = Modifier.padding(start = 4.dp),
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
icon()
}
Column(
modifier = Modifier.weight(1f)
@@ -62,4 +57,26 @@ fun Preference(
}
}
}
}
@Composable
fun Preference(
title: String,
icon: ImageVector? = null,
summary: String? = null,
onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null,
enabled: Boolean = true
) {
Preference(
title, icon = {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}, summary, onClick, controls, enabled
)
}
@@ -22,6 +22,7 @@ import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun PreferenceScreen(
title: String,
floatingActionButton: @Composable () -> Unit = {},
content: LazyListScope.() -> Unit
) {
val navController = LocalNavController.current
@@ -34,6 +35,7 @@ fun PreferenceScreen(
modifier = Modifier.systemBarsPadding()
) {
Scaffold(
floatingActionButton = floatingActionButton,
topBar = {
CenterAlignedTopAppBar(
title = {
@@ -35,6 +35,7 @@ import de.mm20.launcher2.ui.settings.accounts.AccountsSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsScreen
import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.websearch.WebSearchSettingsScreen
import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen
import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen
@@ -101,6 +102,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/files") {
FileSearchSettingsScreen()
}
composable("settings/search/websearch") {
WebSearchSettingsScreen()
}
composable("settings/widgets") {
WidgetsSettingsScreen()
}
@@ -149,6 +149,9 @@ fun SearchSettingsScreen() {
switchValue = webSearch == true,
onSwitchChanged = {
viewModel.setWebSearch(it)
},
onClick = {
navController?.navigate("settings/search/websearch")
}
)
}
@@ -0,0 +1,523 @@
package de.mm20.launcher2.ui.settings.websearch
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.TextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.rememberImagePainter
import com.godaddy.android.colorpicker.ClassicColorPicker
import de.mm20.launcher2.search.data.Websearch
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
import de.mm20.launcher2.ui.ktx.toHexString
import de.mm20.launcher2.ui.toPixels
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun WebSearchSettingsScreen() {
val viewModel: WebSearchSettingsScreenVM = viewModel()
val websearches by viewModel.websearches.observeAsState(emptyList())
var showNewDialog by remember { mutableStateOf(false) }
PreferenceScreen(
title = stringResource(R.string.preference_search_websearch),
floatingActionButton = {
FloatingActionButton(onClick = { showNewDialog = true }) {
Icon(imageVector = Icons.Rounded.Add, contentDescription = null)
}
}
) {
item {
PreferenceCategory {
for (websearch in websearches) {
WebsearchPreference(
value = websearch,
onValueChanged = {
viewModel.updateWebsearch(it)
},
onValueDeleted = { viewModel.deleteWebsearch(it) }
)
}
}
}
}
if (showNewDialog) {
EditWebsearchDialog(
title = stringResource(R.string.websearch_dialog_create_title),
value = Websearch(
label = "",
urlTemplate = "",
color = 0,
icon = null
),
onValueSaved = {
viewModel.createWebsearch(it)
showNewDialog = false
},
onCancel = {
showNewDialog = false
}
)
}
}
@Composable
fun WebsearchPreference(
value: Websearch,
onValueChanged: (Websearch) -> Unit,
onValueDeleted: (Websearch) -> Unit,
) {
var showDialog by remember { mutableStateOf(false) }
Preference(
title = value.label,
summary = value.urlTemplate,
onClick = {
showDialog = true
},
icon = {
val icon = value.icon
if (icon == null) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = value.color.takeIf { it != 0 }?.let { Color(it) }
?: MaterialTheme.colorScheme.primary,
)
} else {
Image(
painter = rememberImagePainter(File(icon)),
contentDescription = null,
modifier = Modifier.sizeIn(maxWidth = 24.dp, maxHeight = 24.dp),
contentScale = ContentScale.Inside
)
}
}
)
if (showDialog) {
EditWebsearchDialog(
title = stringResource(R.string.websearch_dialog_edit_title),
value = value,
onValueSaved = {
onValueChanged(it)
showDialog = false
},
onCancel = {
showDialog = false
},
onValueDeleted = onValueDeleted
)
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun EditWebsearchDialog(
title: String,
value: Websearch,
onValueSaved: (Websearch) -> Unit,
onValueDeleted: ((Websearch) -> Unit)? = null,
onCancel: () -> Unit
) {
val context = LocalContext.current
var showDropdown by remember { mutableStateOf(false) }
var label by remember { mutableStateOf(value.label) }
var showError by remember { mutableStateOf(false) }
var urlTemplate by remember { mutableStateOf(value.urlTemplate) }
var color by remember { mutableStateOf(value.color) }
var icon by remember { mutableStateOf(value.icon) }
val scope = rememberCoroutineScope()
val viewModel: WebSearchSettingsScreenVM = viewModel()
val iconSizePx = 32.dp.toPixels().toInt()
val chooseIconLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) {
if (it != null) {
scope.launch {
icon = viewModel.createIcon(context, it, iconSizePx)
}
}
}
Dialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RectangleShape,
modifier = Modifier.fillMaxSize()
) {
Scaffold(
topBar = {
SmallTopAppBar(
navigationIcon = {
IconButton(onClick = {
if (icon != value.icon) {
icon?.let { viewModel.deleteIcon(it) }
}
onCancel()
}) {
Icon(imageVector = Icons.Rounded.Close, contentDescription = null)
}
},
title = {
Text(
text = title
)
},
actions = {
IconButton(onClick = {
if (urlTemplate.contains("\${1}")) {
value.label = label
value.urlTemplate = urlTemplate
if (value.icon != icon) {
value.icon?.let {
viewModel.deleteIcon(it)
}
}
value.icon = icon
value.color = color
onValueSaved(value)
} else {
showError = true
}
}) {
Icon(imageVector = Icons.Rounded.Save, contentDescription = null)
}
if (onValueDeleted != null) {
Box {
IconButton(onClick = {
showDropdown = true
}) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = null
)
}
DropdownMenu(
expanded = showDropdown,
onDismissRequest = { showDropdown = false }) {
DropdownMenuItem(onClick = {
onValueDeleted(value)
onCancel()
}) {
Text(
text = stringResource(R.string.menu_delete),
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
}
)
}
) {
Column(modifier = Modifier.padding(16.dp)) {
if (icon != null) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberImagePainter(icon?.let { File(it) }),
contentDescription = null,
modifier = Modifier
.padding(end = 16.dp)
.size(48.dp)
)
TextButton(
onClick = {
chooseIconLauncher.launch("image/*")
},
modifier = Modifier.padding(4.dp)
) {
Text(stringResource(R.string.websearch_dialog_replace_icon), style = MaterialTheme.typography.labelLarge)
}
TextButton(
onClick = {
icon = null
},
modifier = Modifier.padding(4.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.websearch_dialog_delete_icon), style = MaterialTheme.typography.labelLarge)
}
}
} else {
ColorPicker(
value = color,
onColorSelected = { color = it }
)
TextButton(
onClick = {
chooseIconLauncher.launch("image/*")
},
modifier = Modifier
.padding(4.dp)
.align(Alignment.End)
) {
Text(
stringResource(R.string.custom_icon),
style = MaterialTheme.typography.labelLarge
)
}
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
value = label,
onValueChange = {
label = it
},
label = {
Text(text = stringResource(R.string.websearch_dialog_name_hint))
}
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
value = urlTemplate,
onValueChange = {
urlTemplate = it
},
label = {
Text(text = stringResource(R.string.websearch_dialog_url_hint))
},
)
AnimatedVisibility(showError) {
Text(
modifier = Modifier.padding(top = 8.dp),
text = stringResource(R.string.websearch_dialog_url_error),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
}
Text(
modifier = Modifier.padding(top = 8.dp),
text = stringResource(R.string.websearch_dialog_url_description),
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
@Composable
private fun ColorPicker(
value: Int,
onColorSelected: (Int) -> Unit
) {
var selectedColorIndex = -1
val isCustomColor = !ColorPresets.contains(Color(value)) && value != 0
val listState = rememberLazyListState()
var showCustomColorPicker by remember { mutableStateOf(false) }
Column {
AnimatedVisibility(!showCustomColorPicker) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(64.dp),
state = listState
) {
item {
if (value == 0) selectedColorIndex = 0
ColorSwatch(
color = MaterialTheme.colorScheme.primary,
checked = value == 0,
onClick = {
onColorSelected(0)
}
)
}
items(ColorPresets) {
ColorSwatch(
color = it,
checked = value == it.toArgb(),
onClick = {
onColorSelected(it.toArgb())
}
)
}
item {
CustomColorSwatch(
checked = isCustomColor,
onClick = {
showCustomColorPicker = true
}
)
}
}
LaunchedEffect(null) {
if (isCustomColor) listState.scrollToItem(ColorPresets.size + 1)
else if (value != 0) listState.scrollToItem(ColorPresets.indexOf(Color(value)) + 1)
}
}
AnimatedVisibility(showCustomColorPicker) {
Column {
ClassicColorPicker(
color = Color(value),
showAlphaBar = false,
modifier = Modifier.height(200.dp),
onColorChanged = {
onColorSelected(it.toColor().toArgb())
})
Row(
modifier = Modifier
.padding(bottom = 24.dp, top = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
var textFieldValue by remember(value) { mutableStateOf(Color(value).toHexString().substring(1)) }
TextField(
value = textFieldValue,
leadingIcon = {
Icon(imageVector = Icons.Rounded.Tag, contentDescription = null)
},
onValueChange = {
textFieldValue = it
if (it.length == 6) it.toLongOrNull(16)?.let {
onColorSelected((it or 0xFF000000).toInt())
}
},
singleLine = true,
modifier = Modifier.width(150.dp)
)
TextButton(onClick = { showCustomColorPicker = false }) {
Text(
stringResource(android.R.string.ok),
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
}
@Composable
private fun ColorSwatch(
color: Color,
checked: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.size(48.dp)
.clip(CircleShape)
.background(color)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
if (checked) {
Icon(
imageVector = Icons.Rounded.Check, contentDescription = null,
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
)
}
}
}
@Composable
private fun CustomColorSwatch(
checked: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.size(48.dp)
.clip(CircleShape)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val brush = Brush.sweepGradient(
listOf(
Color.Red,
Color.Magenta,
Color.Blue,
Color.Cyan,
Color.Green,
Color.Yellow,
Color.Red
)
)
drawRect(brush)
}
if (checked) {
Icon(
imageVector = Icons.Rounded.Check, contentDescription = null,
tint = Color.White
)
}
}
}
private val ColorPresets = listOf(
Color(0xFFEF5350),
Color(0xFFEC407A),
Color(0xFFAB47BC),
Color(0xFF7E57C2),
Color(0xFF5C6BC0),
Color(0xFF42A5F5),
Color(0xFF29B6F6),
Color(0xFF26C6DA),
Color(0xFF26A69A),
Color(0xFF66BB6A),
Color(0xFF9CCC65),
Color(0xFFD4E157),
Color(0xFFFFEE58),
Color(0xFFFFCA28),
Color(0xFFFFA726),
Color(0xFFFF7043),
Color(0xFF8D6E63),
Color(0xFFBDBDBD),
Color(0xFF78909C),
)
@@ -0,0 +1,69 @@
package de.mm20.launcher2.ui.settings.websearch
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.graphics.scale
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.search.WebsearchRepository
import de.mm20.launcher2.search.data.Websearch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
import java.io.FileOutputStream
class WebSearchSettingsScreenVM: ViewModel(), KoinComponent {
private val repository: WebsearchRepository by inject()
val websearches = repository.getWebsearches().asLiveData()
fun createWebsearch(websearch: Websearch) {
repository.insertWebsearch(websearch)
}
fun updateWebsearch(websearch: Websearch) {
repository.insertWebsearch(websearch)
}
fun deleteWebsearch(websearch: Websearch) {
websearch.icon?.let { deleteIcon(it) }
repository.deleteWebsearch(websearch)
}
/**
* Read a user-selected icon, scale it down and copy it to the app's data dir
* @return the absolute path of the copied file
*/
suspend fun createIcon(context: Context, uri: Uri, size: Int): String? = withContext(
Dispatchers.IO) {
val file = File(context.dataDir, System.currentTimeMillis().toString())
val stream = context.contentResolver.openInputStream(uri)
val icon = BitmapFactory.decodeStream(stream) ?: return@withContext null
val (scaledW, scaledH) = if (icon.width > icon.height) {
size * icon.width / icon.height to size
} else {
size to size * icon.height / icon.width
}
val scaledIcon = icon.scale(scaledW, scaledH)
val out = FileOutputStream(file)
scaledIcon.compress(Bitmap.CompressFormat.PNG, 100, out)
out.close()
return@withContext file.absolutePath
}
fun deleteIcon(path: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
File(path).delete()
}
}
}
}