diff --git a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesSearchFragment.kt b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesSearchFragment.kt index 8756ffa8..3af03af9 100644 --- a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesSearchFragment.kt +++ b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesSearchFragment.kt @@ -54,10 +54,6 @@ class PreferencesSearchFragment : PreferenceFragmentCompat() { } true } - findPreference("search_edit_websearch")?.setOnPreferenceClickListener { - setSettingsScreen(PreferencesWebSearchesFragment()) - true - } } private suspend fun updateGoogleDrive() { diff --git a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWebSearchesFragment.kt b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWebSearchesFragment.kt deleted file mode 100644 index b311f9b6..00000000 --- a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWebSearchesFragment.kt +++ /dev/null @@ -1,233 +0,0 @@ -package de.mm20.launcher2.fragment - -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Intent -import android.content.res.ColorStateList -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.PorterDuff -import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.widget.EditText -import android.widget.ImageView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.scale -import androidx.lifecycle.Observer -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.bottomsheets.BottomSheet -import com.afollestad.materialdialogs.color.colorChooser -import com.afollestad.materialdialogs.customview.customView -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.SimpleTarget -import com.bumptech.glide.request.transition.Transition -import de.mm20.launcher2.R -import de.mm20.launcher2.ktx.dp -import de.mm20.launcher2.search.WebsearchViewModel -import de.mm20.launcher2.search.data.Websearch -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.io.File -import java.io.FileOutputStream -import java.lang.ref.WeakReference - -class PreferencesWebSearchesFragment : PreferenceFragmentCompat() { - - private lateinit var rootView: View - - private var sheetIcon: WeakReference? = null - - private val viewModel: WebsearchViewModel by viewModel() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceScreen = preferenceManager.createPreferenceScreen(activity) - val searches = viewModel.allWebsearches - searches.observe(context as AppCompatActivity, Observer { - updatePreferenceScreen(it) - }) - } - - private fun updatePreferenceScreen(searches: List) { - preferenceScreen.removeAll() - - for (search in searches) { - val pref = Preference(context) - pref.title = search.label - if (search.icon == null) { - val drawable = - resources.getDrawable(R.drawable.ic_search, requireActivity().theme).mutate() - drawable.setTintMode(PorterDuff.Mode.SRC_ATOP) - drawable.setTint(search.color) - pref.icon = drawable - } else { - Glide.with(requireContext()) - .asDrawable() - .load(search.icon) - .into(object : SimpleTarget() { - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - pref.icon = resource - } - }) - } - pref.setOnPreferenceClickListener { - editSearch(search) - true - } - preferenceScreen.addPreference(pref) - } - - - val newPref = Preference(activity) - newPref.setTitle(R.string.preference_websearch_new) - newPref.setIcon(R.drawable.ic_preference_websearch_new) - newPref.setOnPreferenceClickListener { - editSearch(null) - true - } - preferenceScreen.addPreference(newPref) - } - - private fun editSearch(search: Websearch?) { - - val websearch = search ?: Websearch("", "", 0xFF555555.toInt(), null, null) - - val dialogView = LayoutInflater.from(activity).inflate(R.layout.dialog_websearch, null) - val nameEdit = dialogView.findViewById(R.id.websearchName) - nameEdit.setText(websearch.label) - val urlEdit = dialogView.findViewById(R.id.websearchUrl) - urlEdit.setText(websearch.urlTemplate) - val iconView = dialogView.findViewById(R.id.websearchIcon) - iconView.apply { - if (websearch.icon == null) { - setImageResource(R.drawable.ic_search) - imageTintList = ColorStateList.valueOf(websearch.color) - } else { - Glide.with(this) - .load(websearch.icon) - .into(this) - } - sheetIcon = WeakReference(this) - } - - val sheet = MaterialDialog(requireContext(), BottomSheet()) - .cornerRadius(8f) - .customView(view = dialogView) - - val radius = 8 * dialogView.dp - dialogView.background = GradientDrawable().apply { - cornerRadii = floatArrayOf( - radius, radius, // top left - radius, radius, // top right - 0f, 0f, // bottom left - 0f, 0f // bottom right - ) - } - - var newColor = websearch.color - var newIcon: String? = websearch.icon - - - sheet.noAutoDismiss() - .positiveButton(android.R.string.ok) { - val newUrl = urlEdit.text.toString() - val newName = nameEdit.text.toString() - if (!newUrl.contains("\${1}")) { - urlEdit.error = getString(R.string.websearch_dialog_url_error) - return@positiveButton - } - File(requireContext().cacheDir, "websearch-tmp").takeIf { it.exists() }?.let { - websearch.icon?.let { File(it).takeIf { it.exists() }?.delete() } - val newFile = - File(requireContext().filesDir, "websearch-${System.currentTimeMillis()}") - it.copyTo(newFile, true) - it.delete() - newIcon = newFile.absolutePath - } - if (newIcon == null) { - websearch.icon?.let { File(it).takeIf { it.exists() }?.delete() } - } - websearch.urlTemplate = newUrl - websearch.label = newName - websearch.icon = newIcon - websearch.color = newColor - viewModel.insertWebsearch(websearch) - sheet.dismiss() - } - - sheet.negativeButton(android.R.string.cancel) { - sheet.cancel() - } - - @Suppress("DEPRECATION") - sheet.neutralButton(R.string.menu_delete) { - sheet.dismiss() - websearch.icon?.let { File(it).takeIf { it.exists() }?.delete() } - viewModel.deleteWebsearch(websearch) - } - - sheet.setOnCancelListener { - File(requireContext().cacheDir, "websearch-tmp").takeIf { it.exists() }?.delete() - } - - dialogView.findViewById(R.id.websearchIcon).setOnClickListener { - MaterialDialog(requireContext()).show { - @Suppress("DEPRECATION") - neutralButton(R.string.custom_icon) { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "image/*" - try { - startActivityForResult(intent, 24) - } catch (e: ActivityNotFoundException) { - } - dismiss() - } - title(R.string.websearch_dialog_choose_icon_color) - colorChooser( - colors = context.resources.getIntArray(R.array.color_chooser_presets), - allowCustomArgb = true, - showAlphaSelector = false - ) { _, color -> - iconView.setImageResource(R.drawable.ic_search) - iconView.imageTintList = ColorStateList.valueOf(color) - newColor = color - newIcon = null - File(requireContext().cacheDir, "websearch-tmp").takeIf { it.exists() } - ?.delete() - dismiss() - } - } - } - sheet.show() - } - - override fun onResume() { - super.onResume() - (activity as AppCompatActivity).supportActionBar - ?.setTitle(R.string.preference_search_edit_websearch) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - val dataUri = data?.data - if (requestCode == 24 && resultCode == Activity.RESULT_OK && dataUri != null) { - val stream = requireActivity().contentResolver.openInputStream(dataUri) - val icon = BitmapFactory.decodeStream(stream) - val scaledIcon = - icon.scale((32 * requireContext().dp).toInt(), (32 * requireContext().dp).toInt()) - val out = FileOutputStream(File(requireContext().cacheDir, "websearch-tmp")) - scaledIcon.compress(Bitmap.CompressFormat.PNG, 100, out) - out.close() - sheetIcon?.get()?.apply { - imageTintList = null - setImageBitmap(scaledIcon) - } - } - } -} \ No newline at end of file diff --git a/i18n/src/main/res/values-de/strings.xml b/i18n/src/main/res/values-de/strings.xml index cfcd587f..2cfeae02 100644 --- a/i18n/src/main/res/values-de/strings.xml +++ b/i18n/src/main/res/values-de/strings.xml @@ -137,6 +137,8 @@ URL „${1}“ wird durch den eigentlichen Suchbegriff ersetzt. In dieser URL fehlt der Platzhalter „${1}“ + Symbol ersetzen + Symbol löschen Wetterdienst OpenWeatherMap Deutscher Wetterdienst (nur Deutschland) @@ -467,6 +469,9 @@ Speicher-Berechtigung wird benötigt um lokale Dateien zu durchsuchen Alle Dateien verwalten-Berechtigung wird benötigt um lokale Dateien zu durchsuchen + Websuche hinzufügen + Websuche bearbeiten + Sie haben noch kein Nextcloud-Konto verbunden Sie haben noch kein Owncloud-Konto verbunden Sie haben noch kein Microsoft-Konto verbunden diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index fa5132b7..711dc517 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -181,6 +181,8 @@ URL \'${1}\' will be replaced by the actual search term. The placeholder \'${1}\' is missing in this URL + Replace icon + Delete icon Provider MET Norway OpenWeatherMap @@ -506,6 +508,9 @@ No media has been played yet + Add web search + Edit web search + You haven\'t connected a Nextcloud account yet You haven\'t connected an Owncloud account yet You haven\'t connected a Microsoft account yet diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 5c24d149..0021a28d 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -24,8 +24,6 @@ android { } } compileOptions { - isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } @@ -50,8 +48,6 @@ android { } dependencies { - coreLibraryDesugaring(libs.desugar) - implementation(libs.bundles.kotlin) implementation(libs.androidx.compose.runtime) @@ -68,6 +64,8 @@ dependencies { implementation(libs.androidx.navigation.compose) + implementation(libs.composecolorpicker) + // Legacy dependencies implementation(libs.androidx.transition) @@ -104,6 +102,9 @@ dependencies { implementation(libs.koin.android) implementation(libs.koin.androidxcompose) + implementation(libs.coil.core) + implementation(libs.coil.compose) + implementation(project(":base")) implementation(project(":i18n")) implementation(project(":compat")) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt index 1196bdb4..ba06c3ee 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt @@ -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 + ) } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt index e2869075..5963fd31 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt @@ -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 = { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 2640234f..7c5f6a42 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -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() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index 3be613ca..1e7142b6 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -149,6 +149,9 @@ fun SearchSettingsScreen() { switchValue = webSearch == true, onSwitchChanged = { viewModel.setWebSearch(it) + }, + onClick = { + navController?.navigate("settings/search/websearch") } ) } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt new file mode 100644 index 00000000..3d1c134e --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreen.kt @@ -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), +) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt new file mode 100644 index 00000000..e36b87cf --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/websearch/WebSearchSettingsScreenVM.kt @@ -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() + } + } + } +} \ No newline at end of file