Reorganize and group modules

This commit is contained in:
MM20
2022-12-13 17:37:26 +01:00
parent bac24baad2
commit 3f8880a90a
995 changed files with 501 additions and 298 deletions
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application>
<activity
android:name=".launcher.LauncherActivity"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LauncherTheme"
android:stateNotNeeded="true"
android:resumeWhilePausing="true"
android:windowSoftInputMode="stateHidden|adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".assistant.AssistantActivity"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LauncherTheme"
android:stateNotNeeded="true"
android:resumeWhilePausing="true"
android:windowSoftInputMode="stateHidden|adjustResize">
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".settings.SettingsActivity"
android:exported="true"
android:label="@string/settings"
android:launchMode="singleTask"
android:parentActivityName=".launcher.SharedLauncherActivity"
android:taskAffinity="de.mm20.launcher2.settings"
android:theme="@style/SettingsTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="de.mm20.launcher2.ui.launcher.SharedLauncherActivity" />
</activity>
<activity
android:name=".launcher.widgets.picker.PickAppWidgetActivity"
android:exported="true"
android:launchMode="singleTask"
android:parentActivityName=".launcher.SharedLauncherActivity"
android:screenOrientation="portrait"
android:taskAffinity="de.mm20.launcher2.settings"
android:theme="@style/SettingsTheme.NoActionBar">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="de.mm20.launcher2.ui.launcher.SharedLauncherActivity" />
</activity>
</application>
</manifest>
@@ -0,0 +1,56 @@
package de.mm20.launcher2.ui.animation
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateInt
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextGeometricTransform
@Composable
fun animateTextStyleAsState(
textStyle: TextStyle
): State<TextStyle> {
val state = remember { mutableStateOf(textStyle) }
val transition = updateTransition(textStyle, label = "animateTextStyleAsState")
val color by transition.animateColor(label = "color") {
it.color
}
val fontWeight by transition.animateInt(label = "fontWeight") {
it.fontWeight?.weight ?: 400
}
val fontSize by transition.animateTextUnit {
it.fontSize
}
val letterSpacing by transition.animateTextUnit {
it.letterSpacing
}
val baselineShift by transition.animateFloat(label = "baselineShift") {
it.baselineShift?.multiplier ?: 0f
}
val background by transition.animateColor(label = "background") {
it.background
}
val lineHeight by transition.animateTextUnit {
it.lineHeight
}
state.value = textStyle.copy(
color = color,
fontSize = fontSize,
fontWeight = FontWeight(fontWeight),
letterSpacing = letterSpacing,
baselineShift = BaselineShift(baselineShift),
background = background,
lineHeight = lineHeight,
)
return state
}
@@ -0,0 +1,42 @@
package de.mm20.launcher2.ui.animation
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
object TextUnitConverter : TwoWayConverter<TextUnit, AnimationVector2D> {
override val convertFromVector: (AnimationVector2D) -> TextUnit
get() = {
TextUnit(
it.v1,
if (it.v2 > 0.5f) TextUnitType.Em else TextUnitType.Sp
)
}
override val convertToVector: (TextUnit) -> AnimationVector2D
get() = {
AnimationVector(
it.value,
if (it.isEm) 1f else 0f
)
}
}
@Composable
inline fun <S> Transition<S>.animateTextUnit(
noinline transitionSpec:
@Composable Transition.Segment<S>.() -> FiniteAnimationSpec<TextUnit> = { spring() },
label: String = "ValueAnimation",
targetValueByState: @Composable (state: S) -> TextUnit
): State<TextUnit> {
return animateValue(
typeConverter = TextUnitConverter,
label = label,
transitionSpec = transitionSpec,
targetValueByState = targetValueByState
)
}
@@ -0,0 +1,5 @@
package de.mm20.launcher2.ui.assistant
import de.mm20.launcher2.ui.launcher.SharedLauncherActivity
class AssistantActivity: SharedLauncherActivity(LauncherActivityMode.Assistant)
@@ -0,0 +1,219 @@
package de.mm20.launcher2.ui.assistant
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.*
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.launcher.LauncherScaffoldVM
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@Composable
fun AssistantScaffold(
modifier: Modifier = Modifier,
darkStatusBarIcons: Boolean = false,
darkNavBarIcons: Boolean = false,
) {
val viewModel: LauncherScaffoldVM = viewModel()
var searchBarFocused by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(null) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
searchBarFocused = false
if (!viewModel.autoFocusSearch.first()) return@repeatOnLifecycle
delay(100)
searchBarFocused = true
keyboardController?.show()
}
}
val bottomSearchBar by remember {
viewModel.dataStore.data.map { it.appearance.layout != Settings.AppearanceSettings.Layout.PullDown }
}.collectAsState(null)
val reverseResults by remember {
viewModel.dataStore.data.map { it.appearance.layout != Settings.AppearanceSettings.Layout.PullDown }
}.collectAsState(null)
val searchState = rememberLazyListState()
val isSearchAtStart by remember {
derivedStateOf {
searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0
}
}
val isSearchAtEnd by remember {
derivedStateOf {
val lastItem =
searchState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding
}
}
if (reverseResults == null || bottomSearchBar == null) return
val searchBarLevel by remember {
derivedStateOf {
when {
reverseResults == bottomSearchBar && isSearchAtStart -> SearchBarLevel.Active
reverseResults != bottomSearchBar && isSearchAtEnd -> SearchBarLevel.Active
else -> SearchBarLevel.Raised
}
}
}
val systemUiController = rememberSystemUiController()
val showStatusBarScrim by remember {
derivedStateOf {
if (reverseResults == true) {
!isSearchAtEnd
} else {
!isSearchAtStart
}
}
}
val showNavBarScrim by remember {
derivedStateOf {
if (reverseResults == true) {
!isSearchAtStart
} else {
!isSearchAtEnd
}
}
}
val colorSurface = MaterialTheme.colorScheme.surface
LaunchedEffect(darkStatusBarIcons, colorSurface, showStatusBarScrim) {
if (showStatusBarScrim) {
systemUiController.setStatusBarColor(
colorSurface.copy(0.7f),
)
} else {
systemUiController.setStatusBarColor(
Color.Transparent,
darkIcons = darkStatusBarIcons
)
}
}
WallpaperBlur {
true
}
LaunchedEffect(darkNavBarIcons, showNavBarScrim) {
if (showNavBarScrim) {
systemUiController.setNavigationBarColor(
colorSurface.copy(0.7f),
)
} else {
systemUiController.setNavigationBarColor(
Color.Transparent,
darkIcons = darkNavBarIcons,
navigationBarContrastEnforced = false
)
}
}
val density = LocalDensity.current
val maxSearchBarOffset = with(density) { 128.dp.toPx() }
var searchBarOffset by remember {
mutableStateOf(0f)
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val y = available.y * if (reverseResults == true) -1f else 1f
searchBarOffset = (searchBarOffset + y).coerceIn(-maxSearchBarOffset, 0f)
return super.onPreScroll(available, source)
}
}
}
val searchVM: SearchVM = viewModel()
val actions by searchVM.searchActionResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState(
if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
Box(
modifier = modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
SearchColumn(
modifier = Modifier.fillMaxSize(),
paddingValues = PaddingValues(
top = (if (bottomSearchBar == true) 0.dp else 56.dp + webSearchPadding) + 4.dp + windowInsets.calculateTopPadding(),
bottom = (if (bottomSearchBar == true) 56.dp + webSearchPadding else 0.dp) + 4.dp + windowInsets.calculateBottomPadding()
),
reverse = reverseResults == true,
state = searchState
)
val value by searchVM.searchQuery.observeAsState("")
val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
LauncherSearchBar(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.align(if (bottomSearchBar == true) Alignment.BottomCenter else Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp)
.offset {
if (searchBarFocused) IntOffset.Zero
else IntOffset(
0,
searchBarOffset.toInt() * if (bottomSearchBar == true) -1 else 1
)
},
level = { searchBarLevel },
focused = searchBarFocused,
onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
},
actions = actions,
showHiddenItemsButton = true,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == Settings.SearchBarSettings.SearchBarColors.Auto || searchBarColor == Settings.SearchBarSettings.SearchBarColors.Dark,
style = searchBarStyle,
reverse = bottomSearchBar == true
)
}
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.ui.base
import androidx.appcompat.app.AppCompatActivity
import de.mm20.launcher2.permissions.PermissionsManager
import org.koin.android.ext.android.inject
abstract class BaseActivity : AppCompatActivity() {
private val permissionsManager: PermissionsManager by inject()
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionsManager.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onResume() {
super.onResume()
permissionsManager.onResume()
}
}
@@ -0,0 +1,27 @@
package de.mm20.launcher2.ui.base
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class BaseActivityVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
val theme = dataStore.data.map { it.appearance.theme }.asLiveData()
fun getTheme(): Settings.AppearanceSettings.Theme = runBlocking {
dataStore.data.map { it.appearance.theme }.first()
}
val colorScheme = dataStore.data.map { it.appearance.colorScheme }.asLiveData()
fun getColorScheme(): Settings.AppearanceSettings.ColorScheme = runBlocking {
dataStore.data.map { it.appearance.colorScheme }.first()
}
}
@@ -0,0 +1,39 @@
package de.mm20.launcher2.ui.base
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
@Composable
fun ProvideCurrentTime(content: @Composable () -> Unit) {
val context = LocalContext.current
var time by remember { mutableStateOf(System.currentTimeMillis()) }
DisposableEffect(null) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
time = System.currentTimeMillis()
}
}
context.registerReceiver(receiver, IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
addAction(Intent.ACTION_TIME_CHANGED)
})
onDispose {
context.unregisterReceiver(receiver)
}
}
CompositionLocalProvider(
LocalTime provides time,
content = content
)
}
val LocalTime = compositionLocalOf { System.currentTimeMillis() }
@@ -0,0 +1,58 @@
package de.mm20.launcher2.ui.base
import androidx.compose.runtime.*
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.component.ProvideIconShape
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.androidx.compose.inject
@Composable
fun ProvideSettings(
content: @Composable () -> Unit
) {
val dataStore: LauncherDataStore by inject()
val widgetRepository: WidgetRepository by inject()
val cardStyle by remember {
dataStore.data.map { it.cards }.distinctUntilChanged()
}.collectAsState(
Settings.CardSettings.getDefaultInstance()
)
val iconShape by remember {
dataStore.data.map {
if (it.easterEgg) Settings.IconSettings.IconShape.EasterEgg
else it.icons.shape
}.distinctUntilChanged()
}.collectAsState(Settings.IconSettings.IconShape.Circle)
val favoritesEnabled by remember {
combine(
widgetRepository.isFavoritesWidgetEnabled(),
dataStore.data.map { it.favorites.enabled },
dataStore.data.map { it.clockWidget.favoritesPart },
) { a, b, c -> a || b || c }.distinctUntilChanged()
}.collectAsState(true)
val gridSettings by remember {
dataStore.data.map { it.grid }.distinctUntilChanged()
}.collectAsState(Settings.GridSettings.newBuilder().setColumnCount(5).setShowLabels(true).setIconSize(48).build())
CompositionLocalProvider(
LocalCardStyle provides cardStyle,
LocalFavoritesEnabled provides favoritesEnabled,
LocalGridSettings provides gridSettings,
) {
ProvideIconShape(iconShape) {
content()
}
}
}
@@ -0,0 +1,91 @@
package de.mm20.launcher2.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.utils.withCustomLabels
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.Tag
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
open class FavoritesVM : ViewModel(), KoinComponent {
private val favoritesRepository: FavoritesRepository by inject()
private val widgetRepository: WidgetRepository by inject()
private val customAttributesRepository: CustomAttributesRepository by inject()
private val dataStore: LauncherDataStore by inject()
val selectedTag = MutableStateFlow<String?>(null)
val showEditButton = dataStore.data.map { it.favorites.editButton }
val pinnedTags = favoritesRepository.getFavorites(
includeTypes = listOf("tag"),
manuallySorted = true,
automaticallySorted = true,
).map {
it.filterIsInstance<Tag>()
}
val favorites: Flow<List<SavableSearchable>> = selectedTag.flatMapLatest { tag ->
if (tag == null) {
val columns = dataStore.data.map { it.grid.columnCount }
val excludeCalendar = widgetRepository.isCalendarWidgetEnabled()
val includeFrequentlyUsed = dataStore.data.map { it.favorites.frequentlyUsed }
val frequentlyUsedRows = dataStore.data.map { it.favorites.frequentlyUsedRows }
combine(
listOf(
columns,
excludeCalendar,
includeFrequentlyUsed,
frequentlyUsedRows
)
) { it }.transformLatest {
val columns = it[0] as Int
val excludeCalendar = it[1] as Boolean
val includeFrequentlyUsed = it[2] as Boolean
val frequentlyUsedRows = it[3] as Int
val pinned = favoritesRepository.getFavorites(
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
manuallySorted = true,
automaticallySorted = true,
limit = 10 * columns,
)
if (includeFrequentlyUsed) {
emitAll(pinned.flatMapLatest { pinned ->
favoritesRepository.getFavorites(
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
frequentlyUsed = true,
limit = frequentlyUsedRows * columns - pinned.size % columns,
).map {
pinned + it
}
.withCustomLabels(customAttributesRepository)
})
} else {
emitAll(
pinned.withCustomLabels(customAttributesRepository)
)
}
}
} else {
customAttributesRepository
.getItemsForTag(tag)
.withCustomLabels(customAttributesRepository)
.map { it.sortedBy { it } }
}
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
fun selectTag(tag: String?) {
selectedTag.value = tag
}
}
@@ -0,0 +1,232 @@
package de.mm20.launcher2.ui.common
import android.net.Uri
import android.text.format.DateUtils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.backup.BackupCompatibility
import de.mm20.launcher2.backup.BackupComponent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.SmallMessage
@Composable
fun RestoreBackupSheet(
uri: Uri,
onDismissRequest: () -> Unit
) {
val viewModel: RestoreBackupSheetVM = viewModel()
LaunchedEffect(uri) {
viewModel.setInputUri(uri)
}
val state by viewModel.state.observeAsState(RestoreBackupState.Parsing)
val selectedComponents by viewModel.selectedComponents.observeAsState(emptySet())
val compatibility by viewModel.compatibility.observeAsState(null)
BottomSheetDialog(
onDismissRequest = onDismissRequest,
title = {
Text(
stringResource(id = R.string.preference_restore),
)
},
confirmButton = {
if (state == RestoreBackupState.Ready && compatibility != BackupCompatibility.Incompatible) {
Button(
enabled = selectedComponents.isNotEmpty(),
onClick = { viewModel.restore() }) {
Text(stringResource(R.string.preference_restore))
}
} else if (state == RestoreBackupState.InvalidFile || state == RestoreBackupState.Restored || state == RestoreBackupState.Ready) {
OutlinedButton(
onClick = onDismissRequest
) {
Text(stringResource(R.string.close))
}
}
},
dismissButton = if (state == RestoreBackupState.Ready && compatibility != BackupCompatibility.Incompatible) {
{
OutlinedButton(
onClick = onDismissRequest
) {
Text(stringResource(android.R.string.cancel))
}
}
} else null
) {
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.verticalScroll(rememberScrollState())
) {
when (state) {
RestoreBackupState.Parsing -> {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
}
RestoreBackupState.InvalidFile -> {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.ErrorOutline,
text = stringResource(id = R.string.restore_invalid_file)
)
}
RestoreBackupState.Ready -> {
val metadata by viewModel.metadata.observeAsState(null)
if (metadata != null) {
Column {
SmallMessage(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(bottom = 16.dp),
icon = Icons.Rounded.Info,
text = stringResource(
R.string.restore_meta,
DateUtils.formatDateTime(
LocalContext.current,
metadata!!.timestamp,
DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
),
metadata!!.deviceName,
stringResource(R.string.app_name) + " " + metadata!!.appVersionName,
)
)
if (compatibility == BackupCompatibility.Incompatible) {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.ErrorOutline,
text = stringResource(
id = R.string.restore_incompatible_file,
stringResource(R.string.app_name)
)
)
} else {
if (compatibility == BackupCompatibility.PartiallyCompatible) {
SmallMessage(
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
icon = Icons.Rounded.Warning,
text =
stringResource(
R.string.restore_different_minor_version,
stringResource(R.string.app_name)
)
)
}
Text(
stringResource(R.string.restore_select_components),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
val components by viewModel.availableComponents.observeAsState(
emptyList()
)
for (component in components) {
Row(
modifier = Modifier
.clickable {
viewModel.toggleComponent(
component
)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (component) {
BackupComponent.Favorites -> Icons.Rounded.Star
BackupComponent.Settings -> Icons.Rounded.Settings
BackupComponent.SearchActions -> Icons.Rounded.ArrowOutward
BackupComponent.Widgets -> Icons.Rounded.Widgets
BackupComponent.Customizations -> Icons.Rounded.Edit
},
contentDescription = null
)
Text(
text = stringResource(
when (component) {
BackupComponent.Favorites -> R.string.backup_component_favorites
BackupComponent.Settings -> R.string.backup_component_settings
BackupComponent.SearchActions -> R.string.backup_component_searchactions
BackupComponent.Widgets -> R.string.backup_component_widgets
BackupComponent.Customizations -> R.string.backup_component_customizations
}
),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
)
Checkbox(
checked = selectedComponents.contains(
component
),
onCheckedChange = {
viewModel.toggleComponent(component)
}
)
}
}
}
}
}
}
RestoreBackupState.Restoring -> {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
}
RestoreBackupState.Restored -> {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.CheckCircleOutline,
text = stringResource(
id = R.string.restore_complete
)
)
}
}
}
}
}
@@ -0,0 +1,73 @@
package de.mm20.launcher2.ui.common
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.backup.BackupCompatibility
import de.mm20.launcher2.backup.BackupComponent
import de.mm20.launcher2.backup.BackupManager
import de.mm20.launcher2.backup.BackupMetadata
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class RestoreBackupSheetVM : ViewModel(), KoinComponent {
private val backupManager: BackupManager by inject()
private var restoreUri: Uri? = null
val state = MutableLiveData(RestoreBackupState.Parsing)
val metadata = MutableLiveData<BackupMetadata?>(null)
val compatibility = MutableLiveData<BackupCompatibility?>(null)
val selectedComponents = MutableLiveData(setOf<BackupComponent>())
val availableComponents = MutableLiveData(emptyList<BackupComponent>())
fun setInputUri(uri: Uri) {
restoreUri = uri
state.value = RestoreBackupState.Parsing
viewModelScope.launch {
val metadata = backupManager.readBackupMeta(uri)
if (metadata == null) {
state.value = RestoreBackupState.InvalidFile
availableComponents.value = emptyList()
} else {
state.value = RestoreBackupState.Ready
compatibility.value = backupManager.checkCompatibility(metadata)
availableComponents.value = metadata.components.toList().sortedBy { it.ordinal }
}
selectedComponents.value = metadata?.components ?: emptySet()
this@RestoreBackupSheetVM.metadata.value = metadata
}
}
fun toggleComponent(component: BackupComponent) {
val components = selectedComponents.value ?: emptySet()
if (components.contains(component)) {
selectedComponents.value = components - component
} else {
selectedComponents.value = components + component
}
}
fun restore() {
val components = selectedComponents.value ?: return
val uri = restoreUri ?: return
viewModelScope.launch {
state.value = RestoreBackupState.Restoring
backupManager.restore(uri, components)
state.value = RestoreBackupState.Restored
}
}
}
enum class RestoreBackupState {
Parsing,
InvalidFile,
Ready,
Restoring,
Restored,
}
@@ -0,0 +1,111 @@
package de.mm20.launcher2.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.SmallMessage
import kotlinx.coroutines.launch
@Composable
fun WeatherLocationSearchDialog(
onDismissRequest: () -> Unit
) {
val scope = rememberCoroutineScope()
val viewModel: WeatherLocationSearchDialogVM = viewModel()
val isSearching by viewModel.isSearchingLocation.observeAsState(initial = false)
val locations by viewModel.locationResults.observeAsState(emptyList())
BottomSheetDialog(
onDismissRequest = onDismissRequest,
title = {
Text(
text = stringResource(R.string.preference_location),
)
},
confirmButton = {
TextButton(
onClick = onDismissRequest,
) {
Text(
text = stringResource(android.R.string.cancel),
)
}
}
) {
var query by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
Modifier.padding(bottom = 16.dp)
) {
OutlinedTextField(
singleLine = true,
value = query,
textStyle = MaterialTheme.typography.bodyLarge,
onValueChange = {
query = it
scope.launch {
viewModel.searchLocation(it)
}
},
leadingIcon = {
Icon(imageVector = Icons.Rounded.Search, contentDescription = null)
},
modifier = Modifier
.weight(1f)
)
}
if (isSearching) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
)
} else if (locations.isNotEmpty()) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
) {
items(locations) {
Text(
text = it.name,
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.setLocation(it)
onDismissRequest()
}
.padding(
vertical = 16.dp
)
)
}
}
} else if (query.isNotEmpty()) {
SmallMessage(
modifier = Modifier.fillMaxWidth(),
icon = Icons.Rounded.Error,
text = stringResource(R.string.weather_location_search_no_result)
)
}
}
}
}
@@ -0,0 +1,41 @@
package de.mm20.launcher2.ui.common
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherRepository
import kotlinx.coroutines.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext
class WeatherLocationSearchDialogVM: ViewModel(), KoinComponent {
private val repository: WeatherRepository by inject()
val isSearchingLocation = MutableLiveData(false)
val locationResults = MutableLiveData<List<WeatherLocation>>(emptyList())
private var debounceSearchJob: Job? = null
suspend fun searchLocation(query: String) {
debounceSearchJob?.cancelAndJoin()
if (query.isBlank()) {
locationResults.value = emptyList()
isSearchingLocation.value = false
return
}
withContext(coroutineContext) {
debounceSearchJob = launch {
delay(1000)
isSearchingLocation.value = true
locationResults.value = repository.lookupLocation(query)
isSearchingLocation.value = false
}
}
}
fun setLocation(location: WeatherLocation) {
locationResults.postValue(emptyList())
repository.setAutoLocation(false)
repository.setLocation(location)
}
}
@@ -0,0 +1,19 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
@Composable
fun ExperimentalBadge(modifier: Modifier = Modifier) {
Box(modifier = modifier.background(MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small).padding(4.dp)) {
Text(stringResource(id = R.string.experimental_feature), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall)
}
}
@@ -0,0 +1,68 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.flowlayout.MainAxisAlignment
@Composable
fun Banner(
modifier: Modifier = Modifier,
text: String,
icon: ImageVector,
primaryAction: (@Composable () -> Unit)? = null,
secondaryAction: (@Composable () -> Unit)? = null,
) {
Card(
modifier = modifier,
shape = MaterialTheme.shapes.small,
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(16.dp),
imageVector = icon,
contentDescription = null
)
Text(
text = text,
modifier = Modifier
.weight(1f)
.padding(vertical = 16.dp)
.padding(end = 16.dp),
style = MaterialTheme.typography.labelMedium
)
}
if (secondaryAction != null || primaryAction != null) {
FlowRow(
Modifier
.align(Alignment.End)
.padding(8.dp),
crossAxisSpacing = 8.dp,
crossAxisAlignment = FlowCrossAxisAlignment.End,
mainAxisAlignment = MainAxisAlignment.End
) {
Box {
secondaryAction?.invoke()
}
Box(modifier = Modifier.padding(start = 8.dp)) {
primaryAction?.invoke()
}
}
}
}
}
}
@@ -0,0 +1,249 @@
package de.mm20.launcher2.ui.component
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material.FixedThreshold
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.SwipeableState
import androidx.compose.material.swipeable
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import kotlin.math.roundToInt
@Composable
fun BottomSheetDialog(
onDismissRequest: () -> Unit,
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
confirmButton: @Composable (() -> Unit)? = null,
dismissButton: @Composable (() -> Unit)? = null,
swipeToDismiss: () -> Boolean = { true },
dismissOnBackPress: () -> Boolean = { true },
content: @Composable () -> Unit,
) {
val swipeState = remember {
SwipeableState(
initialValue = SwipeState.Dismiss,
confirmStateChange = {
if (it == SwipeState.Dismiss) {
if (swipeToDismiss()) onDismissRequest()
else return@SwipeableState false
}
return@SwipeableState true
}
)
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
swipeState.performDrag(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
swipeState.performDrag(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && swipeState.offset.value > 0) {
swipeState.performFling(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
swipeState.performFling(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
}
Dialog(
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = true,
dismissOnBackPress = dismissOnBackPress()
),
onDismissRequest = onDismissRequest,
) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
propagateMinConstraints = true,
contentAlignment = Alignment.BottomCenter
) {
val maxHeightPx = maxHeight.toPixels()
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
.clipToBounds(),
verticalArrangement = Arrangement.Bottom
) {
var height by remember {
mutableStateOf(maxHeightPx)
}
LaunchedEffect(null) {
swipeState.animateTo(SwipeState.Peek)
}
val heightDp = height.toDp()
val peekHeight = (height - maxHeightPx / 2).coerceAtLeast(0f)
val anchors = mutableMapOf(
peekHeight to SwipeState.Peek,
height to SwipeState.Dismiss,
).also {
if (peekHeight > 0f) {
it[0f] = SwipeState.Full
}
}
Surface(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.swipeable(
swipeState,
anchors = anchors,
orientation = Orientation.Vertical,
thresholds = { _, to ->
if (to == SwipeState.Dismiss) {
FixedThreshold(heightDp - 48.dp)
} else {
FractionalThreshold(0.5f)
}
},
resistance = null
)
.animateContentSize()
.onSizeChanged {
height = it.height.toFloat()
}
.offset { IntOffset(0, swipeState.offset.value.roundToInt()) }
.fillMaxWidth()
.weight(1f, false),
shape = MaterialTheme.shapes.extraLarge.copy(
bottomStart = CornerSize(0),
bottomEnd = CornerSize(0),
),
shadowElevation = 16.dp,
) {
Column {
CenterAlignedTopAppBar(
title = title,
actions = actions,
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
)
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 24.dp, vertical = 8.dp),
propagateMinConstraints = true,
contentAlignment = Alignment.Center
) {
content()
}
}
}
if (confirmButton != null || dismissButton != null) {
val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp)
Surface(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth(),
tonalElevation = elevation,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.End
) {
if (dismissButton != null) {
dismissButton()
}
if (confirmButton != null && dismissButton != null) {
Spacer(modifier = Modifier.width(16.dp))
}
if (confirmButton != null) {
confirmButton()
}
}
}
}
}
}
}
}
private enum class SwipeState {
Full, Peek, Dismiss
}
@@ -0,0 +1,99 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun Chip(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
content: @Composable RowScope.() -> Unit
) {
Surface(
shape = MaterialTheme.shapes.small,
modifier = modifier,
color = Color.Transparent,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)),
contentColor = MaterialTheme.colorScheme.onSurface,
onClick = onClick
) {
Row(
modifier = Modifier
.height(32.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.labelLarge,
LocalContentColor provides MaterialTheme.colorScheme.onSurface
) {
content()
}
}
}
}
@Composable
fun Chip(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit = {},
icon: Painter? = null,
rightIcon: ImageVector? = null,
rightAction: (() -> Unit)? = null
) {
Chip(
modifier = modifier.width(IntrinsicSize.Max),
onClick = onClick
) {
if (icon != null) {
Image(
modifier = Modifier.padding(horizontal = 6.dp).size(20.dp),
painter = icon,
contentDescription = null
)
}
Text(
modifier = Modifier
.weight(1f, false)
.padding(
start = if (icon == null) 12.dp else 4.dp,
end = if (rightIcon == null) 12.dp else 4.dp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = text)
if (rightIcon != null) {
if (rightAction != null) {
IconButton(
modifier = Modifier.size(32.dp),
onClick = rightAction
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = rightIcon,
contentDescription = null
)
}
} else {
Icon(
modifier = Modifier.padding(horizontal = 6.dp).size(20.dp),
imageVector = rightIcon,
contentDescription = null
)
}
}
}
}
@@ -0,0 +1,88 @@
package de.mm20.launcher2.ui.component
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.locals.LocalCardStyle
import kotlin.math.ln
@Composable
fun InnerCard(
modifier: Modifier = Modifier,
raised: Boolean = false,
content: @Composable () -> Unit
) {
val transition = updateTransition(raised, label = "InnerCard")
val absoluteTonalElevation = LocalAbsoluteTonalElevation.current
val elevation by transition.animateDp(label = "elevation", transitionSpec = {
tween(250, if (targetState) 250 else 0)
}) {
if(it) 4.dp else 0.dp
}
val borderWidth by transition.animateDp(label = "borderWidth", transitionSpec = { tween(500) }) {
if (it) 0.dp else 1.dp
}
val borderColor by transition.animateColor(label = "borderColor", transitionSpec = { tween(500) }) {
MaterialTheme.colorScheme.outline.copy(alpha = if (it) 0f else 0.7f)
}
val bgAlpha by transition.animateFloat(label = "bgAlpha", transitionSpec = {
tween(250, if (targetState) 0 else 250)
}) {
if (it) 1f else 0f
}
val shape = MaterialTheme.shapes.small
val bgColor = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteTonalElevation + elevation)
Box(
modifier = modifier
.border(BorderStroke(borderWidth, borderColor), shape)
.shadow(elevation, shape, clip = false)
.clip(shape)
.drawBehind {
drawRect(
bgColor.copy(alpha = bgAlpha)
)
}
,
) {
CompositionLocalProvider(
LocalAbsoluteTonalElevation provides absoluteTonalElevation
) {
content()
}
}
}
internal fun ColorScheme.surfaceColorAtElevation(
elevation: Dp,
): Color {
if (elevation == 0.dp) return surface
val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
return surfaceTint.copy(alpha = alpha).compositeOver(surface)
}
@@ -0,0 +1,41 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun LargeMessage(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(bottom = 24.dp)
.size(64.dp)
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,193 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.locals.LocalCardStyle
@Composable
fun LauncherCard(
modifier: Modifier = Modifier,
elevation: Dp = 2.dp,
backgroundOpacity: Float = LocalCardStyle.current.opacity,
shape: Shape = MaterialTheme.shapes.medium,
content: @Composable () -> Unit = {}
) {
Surface(
modifier = modifier,
shape = shape,
border = LocalCardStyle.current.borderWidth.takeIf { it > 0 }
?.let { BorderStroke(it.dp, MaterialTheme.colorScheme.surface) },
content = content,
contentColor = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.surface.copy(alpha = backgroundOpacity.coerceIn(0f, 1f)),
shadowElevation = if (backgroundOpacity == 1f) elevation else 0.dp,
tonalElevation = elevation,
)
}
@Composable
fun PartialLauncherCard(
modifier: Modifier = Modifier,
isTop: Boolean = false,
isBottom: Boolean = false,
elevation: Dp = 2.dp,
backgroundOpacity: Float = LocalCardStyle.current.opacity,
content: @Composable () -> Unit
) {
if (isTop && isBottom) {
LauncherCard(modifier = modifier, content = content)
} else if (!isTop && !isBottom) {
CardMiddlePiece(modifier = modifier, elevation = elevation, content = content)
} else {
CardEndPiece(
modifier = modifier,
isTop = isTop,
isBottom = isBottom,
elevation = elevation,
backgroundOpacity = backgroundOpacity,
content = content
)
}
}
@Composable
private fun CardMiddlePiece(
modifier: Modifier,
elevation: Dp,
backgroundOpacity: Float = LocalCardStyle.current.opacity,
content: @Composable () -> Unit
) {
val borderWidth = LocalCardStyle.current.borderWidth.dp
val borderColor = MaterialTheme.colorScheme.surface
val absoluteElevation = LocalAbsoluteTonalElevation.current + elevation
Box(
modifier = modifier
.shadow(if (backgroundOpacity < 1f) 0.dp else elevation, RectangleShape, true)
.background(
if (backgroundOpacity == 1f) {
MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation)
} else {
MaterialTheme.colorScheme.surface.copy(
alpha = backgroundOpacity.coerceIn(
0f,
1f
)
)
}
)
.drawBehind {
if (borderWidth == 0.dp) return@drawBehind
val border = borderWidth.toPx()
drawRect(
color = borderColor,
topLeft = Offset.Zero,
size = size.copy(width = border)
)
drawRect(
color = borderColor,
topLeft = Offset(size.width - border, 0f),
size = size.copy(width = border)
)
},
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurface,
LocalAbsoluteTonalElevation provides absoluteElevation,
) {
content()
}
}
}
@Composable
private fun CardEndPiece(
modifier: Modifier = Modifier,
isTop: Boolean,
isBottom: Boolean,
elevation: Dp,
backgroundOpacity: Float,
content: @Composable () -> Unit,
) {
val shape = when {
isTop -> MaterialTheme.shapes.medium.copy(
bottomEnd = CornerSize(0),
bottomStart = CornerSize(0),
)
isBottom -> MaterialTheme.shapes.medium.copy(
topEnd = CornerSize(0),
topStart = CornerSize(0),
)
else -> RectangleShape
}
val borderWidth = LocalCardStyle.current.borderWidth.dp
val borderColor = MaterialTheme.colorScheme.surface
val absoluteElevation = LocalAbsoluteTonalElevation.current + elevation
Box(
modifier = modifier
.shadow(if (backgroundOpacity < 1f) 0.dp else elevation, shape, true)
.background(
if (backgroundOpacity == 1f) {
MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation)
} else {
MaterialTheme.colorScheme.surface.copy(
alpha = backgroundOpacity.coerceIn(
0f,
1f
)
)
}
)
.drawWithCache {
val border = borderWidth.toPx()
val outline = shape.createOutline(
size.copy(height = size.height + border),
layoutDirection,
Density(density, fontScale)
)
onDrawBehind {
if (borderWidth == 0.dp) return@onDrawBehind
withTransform({
translate(0f, if (isBottom) -border else 0f)
}) {
drawOutline(
outline,
borderColor,
style = Stroke(width = border * 2)
)
}
}
},
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurface,
LocalAbsoluteTonalElevation provides absoluteElevation,
) {
content()
}
}
}
@@ -0,0 +1,37 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
@Composable
fun MissingPermissionBanner(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit,
secondaryAction: @Composable () -> Unit = {}
) {
Banner(
modifier = modifier,
text = text,
icon = Icons.Rounded.Lock,
primaryAction = {
Button(
modifier = Modifier.padding(start = 8.dp),
onClick = onClick
) {
Text(
stringResource(R.string.grant_permission),
)
}
},
secondaryAction = secondaryAction
)
}
@@ -0,0 +1,163 @@
package de.mm20.launcher2.ui.component
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.util.Log
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun NavBarEffects(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var intensity by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
DisposableEffect(null) {
suspend fun update(intent: Intent?, retryOnZeroCurrent: Boolean = false) {
if (intent == null) return
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging =
status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
if (charging) {
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val current = bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
if (current <= 0) {
intensity = 5
//Workaround for delayed current updates
if (retryOnZeroCurrent) {
delay(1000)
update(intent)
}
return
}
intensity = (current / 100000f).roundToInt().takeIf { it > 0 } ?: 1
} else {
intensity = 0
}
}
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
scope.launch {
update(intent, false)
}
}
}
val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val intent = context.registerReceiver(receiver, intentFilter)
scope.launch {
update(intent, true)
}
onDispose {
context.unregisterReceiver(receiver)
}
}
var bubbles by remember { mutableStateOf(arrayOf<Bubble>()) }
val dp = LocalDensity.current.density
LaunchedEffect(intensity) {
bubbles = Array(intensity) {
FloatArray(6) { 0f }
}
if (intensity == 0) return@LaunchedEffect
while (isActive) {
val newBubbles = Array(intensity) { FloatArray(6) { 0f } }
withInfiniteAnimationFrameMillis {}
for (i in 0 until intensity) {
val bubble = newBubbles[i]
val oldBubble = bubbles[i]
if (oldBubble.lifetime <= 0f) {
bubble.posX = (Math.random() * 48 - 24).toFloat() * dp
bubble.posY = 0f
bubble.deltaX = (Math.random() - 0.5).toFloat() * 1f * dp
bubble.deltaY = Math.random().toFloat() * 3f * dp
bubble.radius = (Math.random() * 2 + 2).toFloat() * dp
bubble.lifetime = (Math.random() * 80 + 40).toInt().toFloat()
} else {
bubble.posX = oldBubble.deltaX + oldBubble.posX
bubble.posY = oldBubble.deltaY + oldBubble.posY
bubble.lifetime = oldBubble.lifetime - 1
bubble.deltaX = oldBubble.deltaX
bubble.deltaY = oldBubble.deltaY
bubble.radius = oldBubble.radius
}
}
bubbles = newBubbles
}
}
Canvas(modifier = modifier) {
for (bubble in bubbles) {
val x = size.width / 2 + bubble.posX
val y = size.height - bubble.posY
drawCircle(
color = Color.White,
radius = bubble.radius,
alpha = bubble.lifetime / 255,
center = Offset(x, y)
)
}
}
}
typealias Bubble = FloatArray
inline var Bubble.posX: Float
get() = this[0]
set(value) {
this[0] = value
}
inline var Bubble.posY: Float
get() = this[1]
set(value) {
this[1] = value
}
inline var Bubble.deltaX: Float
get() = this[2]
set(value) {
this[2] = value
}
inline var Bubble.deltaY: Float
get() = this[3]
set(value) {
this[3] = value
}
inline var Bubble.radius: Float
get() = this[4]
set(value) {
this[4] = value
}
inline var Bubble.lifetime: Float
get() = this[5]
set(value) {
this[5] = value
}
@@ -0,0 +1,184 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Composable
fun OutlinedTagsInputField(
modifier: Modifier = Modifier,
tags: List<String>,
onTagsChange: (tags: List<String>) -> Unit,
placeholder: @Composable (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
textColor: Color = LocalContentColor.current,
onAutocomplete: (suspend (query: String) -> List<String>)? = null
) {
var value by remember { mutableStateOf("") }
var lastTagFocused by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
var completions by remember(onAutocomplete) { mutableStateOf<List<String>>(emptyList()) }
BasicTextField(
modifier = modifier
.onKeyEvent {
if (it.key == Key.Backspace && value.isEmpty() && tags.isNotEmpty()) {
if (!lastTagFocused) {
lastTagFocused = true
} else {
onTagsChange(tags.dropLast(1))
lastTagFocused = false
}
return@onKeyEvent true
}
lastTagFocused = false
false
}
.onFocusChanged {
if (!it.hasFocus && value.isNotBlank()) {
onTagsChange((tags + value).toImmutableList())
value = ""
} else if (it.hasFocus) {
scope.launch {
scrollState.animateScrollTo(scrollState.maxValue)
}
}
},
value = value, onValueChange = {
val newTags = it.split(",")
if (newTags.size > 1) {
onTagsChange(tags + newTags.dropLast(1).filter { it.isNotBlank() })
}
value = newTags.last()
if (value.isNotBlank()) {
onAutocomplete?.let {
scope.launch {
completions = it(value)
}
}
} else {
completions = emptyList()
}
lastTagFocused = false
},
textStyle = textStyle.copy(
color = textColor
),
interactionSource = interactionSource,
singleLine = true,
keyboardActions = KeyboardActions(onDone = {
if (value.isNotBlank()) {
onTagsChange(tags + value)
value = ""
}
}),
decorationBox = { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox(
contentPadding = PaddingValues(0.dp),
value = value,
innerTextField = {
Box {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
for ((i, tag) in tags.withIndex()) {
InputChip(
selected = i == tags.lastIndex && lastTagFocused,
modifier = Modifier.padding(end = 12.dp),
onClick = { },
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Tag,
contentDescription = null
)
},
label = { Text(tag) },
trailingIcon = {
Icon(
modifier = Modifier.clickable {
onTagsChange(tags.filterIndexed { index, _ -> index != i })
},
imageVector = Icons.Rounded.Clear,
contentDescription = null
)
},
)
}
Box(
modifier = Modifier
.height(56.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
CompositionLocalProvider(
LocalTextStyle provides textStyle,
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant,
) {
placeholder?.invoke()
}
}
innerTextField()
}
}
if (completions.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
DropdownMenu(
expanded = true,
onDismissRequest = { completions = emptyList() },
properties = PopupProperties(focusable = false)
) {
for (completion in completions) {
DropdownMenuItem(
text = { Text(completion) },
onClick = {
onTagsChange(tags + completion)
value = ""
completions = emptyList()
},
)
}
}
}
}
}
},
enabled = true,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
)
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary)
)
}
@@ -0,0 +1,151 @@
package de.mm20.launcher2.ui.component
import android.content.ComponentName
import android.graphics.drawable.Drawable
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Email
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material.icons.rounded.FindInPage
import androidx.compose.material.icons.rounded.Games
import androidx.compose.material.icons.rounded.ImageSearch
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.ManageSearch
import androidx.compose.material.icons.rounded.Movie
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PersonAdd
import androidx.compose.material.icons.rounded.PersonSearch
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.QueryStats
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Sms
import androidx.compose.material.icons.rounded.SportsEsports
import androidx.compose.material.icons.rounded.StickyNote2
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material.icons.rounded.Translate
import androidx.compose.material.icons.rounded.TravelExplore
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import de.mm20.launcher2.searchactions.actions.AppSearchAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.builders.AppSearchActionBuilder
import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun SearchActionIcon(
icon: SearchActionIcon,
color: Int = 0,
customIcon: String? = null,
componentName: ComponentName? = null,
size: Dp = 20.dp
) {
val tint = when(color) {
0 -> MaterialTheme.colorScheme.primary
1 -> Color.Unspecified
else -> Color(color)
}
if (icon != SearchActionIcon.Custom || customIcon == null && componentName == null) {
Icon(
modifier = Modifier.size(size),
imageVector = getSearchActionIconVector(icon),
contentDescription = null,
tint = tint,
)
} else if (customIcon == null && componentName != null) {
val context = LocalContext.current
var drawable by remember(componentName) { mutableStateOf<Drawable?>(null) }
LaunchedEffect(componentName) {
drawable = withContext(Dispatchers.IO) {
context.packageManager.getActivityIcon(componentName)
}
}
AsyncImage(
model = drawable,
contentDescription = null,
modifier = Modifier.size(size),
colorFilter = if (tint.isSpecified) ColorFilter.tint(tint) else null
)
} else {
AsyncImage(
model = customIcon,
contentDescription = null,
modifier = Modifier.size(size),
colorFilter = if (tint.isSpecified) ColorFilter.tint(tint) else null
)
}
}
@Composable
fun SearchActionIcon(action: SearchAction, size: Dp = 20.dp) {
SearchActionIcon(
icon = action.icon,
color = action.iconColor,
customIcon = action.customIcon,
componentName = (action as? AppSearchAction)?.baseIntent?.component,
size = size,
)
}
@Composable
fun SearchActionIcon(builder: CustomizableSearchActionBuilder, size: Dp = 20.dp) {
SearchActionIcon(
icon = builder.icon,
color = builder.iconColor,
customIcon = builder.customIcon,
componentName = (builder as? AppSearchActionBuilder)?.baseIntent?.component,
size = size,
)
}
fun getSearchActionIconVector(icon: SearchActionIcon): ImageVector {
return when (icon) {
SearchActionIcon.Phone -> Icons.Rounded.Call
SearchActionIcon.Website -> Icons.Rounded.Language
SearchActionIcon.Alarm -> Icons.Rounded.Alarm
SearchActionIcon.Timer -> Icons.Rounded.Timer
SearchActionIcon.Contact -> Icons.Rounded.PersonAdd
SearchActionIcon.Email -> Icons.Rounded.Email
SearchActionIcon.Message -> Icons.Rounded.Sms
SearchActionIcon.Calendar -> Icons.Rounded.Event
SearchActionIcon.Translate -> Icons.Rounded.Translate
SearchActionIcon.Search -> Icons.Rounded.Search
SearchActionIcon.Custom -> Icons.Rounded.Warning
SearchActionIcon.WebSearch -> Icons.Rounded.TravelExplore
SearchActionIcon.PersonSearch -> Icons.Rounded.PersonSearch
SearchActionIcon.StatsSearch -> Icons.Rounded.QueryStats
SearchActionIcon.SearchPage -> Icons.Rounded.FindInPage
SearchActionIcon.SearchList -> Icons.Rounded.ManageSearch
SearchActionIcon.ImageSearch -> Icons.Rounded.ImageSearch
SearchActionIcon.Location -> Icons.Rounded.Place
SearchActionIcon.Movie -> Icons.Rounded.Movie
SearchActionIcon.Music -> Icons.Rounded.MusicNote
SearchActionIcon.Game -> Icons.Rounded.SportsEsports
SearchActionIcon.Note -> Icons.Rounded.StickyNote2
}
}
@@ -0,0 +1,206 @@
package de.mm20.launcher2.ui.component
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalCardStyle
@Composable
fun SearchBar(
modifier: Modifier = Modifier,
style: Settings.SearchBarSettings.SearchBarStyle,
level: SearchBarLevel,
value: String,
onValueChange: (String) -> Unit,
focusRequester: FocusRequester = remember { FocusRequester() },
onFocus: () -> Unit = {},
onUnfocus: () -> Unit = {},
reverse: Boolean = false,
darkColors: Boolean = false,
menu: @Composable RowScope.() -> Unit = {},
actions: @Composable ColumnScope.() -> Unit = {},
) {
val transition = updateTransition(level, label = "Searchbar")
val elevation by transition.animateDp(
label = "elevation",
transitionSpec = {
when {
initialState == SearchBarLevel.Resting -> tween(
durationMillis = 200,
delayMillis = 200
)
targetState == SearchBarLevel.Resting -> tween(durationMillis = 200)
else -> tween(durationMillis = 500)
}
}
) {
when {
it == SearchBarLevel.Resting && style != Settings.SearchBarSettings.SearchBarStyle.Solid -> 0.dp
it == SearchBarLevel.Raised -> 8.dp
else -> 2.dp
}
}
val backgroundOpacity by transition.animateFloat(label = "backgroundOpacity",
transitionSpec = {
when {
initialState == SearchBarLevel.Resting -> tween(durationMillis = 200)
targetState == SearchBarLevel.Resting -> tween(
durationMillis = 200,
delayMillis = 200
)
else -> tween(durationMillis = 200)
}
}) {
when {
it == SearchBarLevel.Active -> LocalCardStyle.current.opacity
style != Settings.SearchBarSettings.SearchBarStyle.Transparent -> 1f
it == SearchBarLevel.Resting -> 0f
else -> 1f
}
}
val contentColor by transition.animateColor(label = "textColor",
transitionSpec = {
when {
initialState == SearchBarLevel.Resting -> tween(durationMillis = 200)
targetState == SearchBarLevel.Resting -> tween(
durationMillis = 200,
delayMillis = 200
)
else -> tween(durationMillis = 500)
}
}) {
when {
style != Settings.SearchBarSettings.SearchBarStyle.Transparent -> MaterialTheme.colorScheme.onSurface
it == SearchBarLevel.Resting -> if (darkColors) Color(0, 0, 0, 180) else Color.White
else -> MaterialTheme.colorScheme.onSurface
}
}
val opacity by transition.animateFloat(label = "opacity") {
if (style == Settings.SearchBarSettings.SearchBarStyle.Hidden && it == SearchBarLevel.Resting) 0f
else 1f
}
LauncherCard(
modifier = modifier
.alpha(opacity),
backgroundOpacity = backgroundOpacity,
elevation = elevation
) {
CompositionLocalProvider(
LocalContentColor provides contentColor
) {
Column(
verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top
) {
Row(
modifier = Modifier.height(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(12.dp),
imageVector = androidx.compose.material.icons.Icons.Rounded.Search,
contentDescription = null,
tint = contentColor
)
Box(
modifier = Modifier.weight(1f)
) {
if (value.isEmpty()) {
Text(
text = stringResource(R.string.search_bar_placeholder),
style = MaterialTheme.typography.titleMedium,
color = contentColor
)
}
LaunchedEffect(level) {
if (level == SearchBarLevel.Resting) onUnfocus()
}
BasicTextField(
modifier = Modifier
.onFocusChanged {
if (it.hasFocus) onFocus()
}
.focusRequester(focusRequester)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.titleMedium.copy(
color = contentColor
),
singleLine = true,
value = value,
onValueChange = onValueChange,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary)
)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
menu()
}
}
this.actions()
}
}
}
}
enum class SearchBarLevel {
/**
* The default, "hidden" state, when the launcher is in its initial state (scroll position is 0
* and search is closed)
*/
Resting,
/**
* When the search is open but there is no content behind the search bar (scroll position is 0)
*/
Active,
/**
* When there is content below the search bar which requires the search bar to be raised above
* this content (scroll position is not 0)
*/
Raised
}
@@ -0,0 +1,663 @@
package de.mm20.launcher2.ui.component
import android.graphics.Matrix
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.RectF
import android.graphics.drawable.AdaptiveIconDrawable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toAndroidRect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.ClockLayer
import de.mm20.launcher2.icons.ClockSublayer
import de.mm20.launcher2.icons.ClockSublayerRole
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.DynamicLauncherIcon
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.LauncherIconLayer
import de.mm20.launcher2.icons.StaticIconLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.icons.TintedClockLayer
import de.mm20.launcher2.icons.TintedIconLayer
import de.mm20.launcher2.icons.TransparentLayer
import de.mm20.launcher2.ktx.drawWithColorFilter
import de.mm20.launcher2.preferences.Settings.IconSettings.IconShape
import de.mm20.launcher2.ui.base.LocalTime
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import kotlinx.coroutines.launch
import palettes.TonalPalette
import java.time.Instant
import java.time.ZoneId
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.roundToInt
@Composable
fun ShapedLauncherIcon(
modifier: Modifier = Modifier,
size: Dp,
icon: () -> LauncherIcon? = { null },
badge: () -> Badge? = { null },
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
shape: Shape = LocalIconShape.current
) {
val _icon = icon()
var currentIcon by remember(_icon) {
mutableStateOf(
when (_icon) {
is DynamicLauncherIcon -> null
is StaticLauncherIcon -> _icon
else -> null
}
)
}
if (_icon is DynamicLauncherIcon) {
val time = LocalTime.current
LaunchedEffect(time) {
currentIcon = _icon.getIcon(time)
}
}
Box(
modifier = modifier
.size(size)
) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
clip = currentIcon?.backgroundLayer !is TransparentLayer
this.shape = shape
}
.then(
if (onClick != null || onLongClick != null) {
Modifier.pointerInput(onClick, onLongClick) {
detectTapGestures(
onLongPress = { onLongClick?.invoke() },
onTap = { onClick?.invoke() },
)
}
} else Modifier
),
contentAlignment = Alignment.Center
) {
currentIcon?.let {
IconLayer(
it.backgroundLayer,
size,
colorTone = if (LocalDarkTheme.current) 30 else 90,
MaterialTheme.colorScheme.primaryContainer
)
IconLayer(
it.foregroundLayer,
size,
colorTone = if (LocalDarkTheme.current) 90 else 10,
MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
val _badge = badge()
if (_badge != null) {
Surface(
shadowElevation = 1.dp,
tonalElevation = 1.dp,
modifier = Modifier
.size(size * 0.33f)
.align(Alignment.BottomEnd)
.then(
if (onClick != null || onLongClick != null) {
Modifier.pointerInput(onClick, onLongClick) {
detectTapGestures(
onLongPress = { onLongClick?.invoke() },
onTap = { onClick?.invoke() },
)
}
} else Modifier
),
color = MaterialTheme.colorScheme.secondary,
shape = CircleShape
) {
Box(
contentAlignment = Alignment.Center
) {
_badge.progress?.let {
val progress by animateFloatAsState(it)
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(),
progress = progress,
strokeWidth = size / 48,
color = MaterialTheme.colorScheme.secondaryContainer
)
}
val badgeIconRes = _badge.iconRes
val badgeIcon = _badge.icon
val number = _badge.number
if (badgeIconRes != null) {
Image(
modifier = Modifier
.fillMaxSize()
.padding(size / 48),
painter = painterResource(badgeIconRes),
contentDescription = null
)
} else if (badgeIcon != null) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(size / 48)
) {
badgeIcon.setBounds(
0,
0,
this.size.width.roundToInt(),
this.size.height.roundToInt()
)
drawIntoCanvas {
badgeIcon.draw(it.nativeCanvas)
}
}
} else if (number != null && number > 0 && number < 100) {
Text(
number.toString(),
color = MaterialTheme.colorScheme.secondaryContainer,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = with(LocalDensity.current) {
size.toSp() * 0.2f
}
),
)
}
}
}
}
}
}
@Composable
private fun Badge(
badge: () -> Badge?
) {
}
@Composable
private fun IconLayer(
layer: LauncherIconLayer,
size: Dp,
colorTone: Int,
defaultTintColor: Color
) {
when (layer) {
is ClockLayer -> {
ClockLayer(layer.sublayers, scale = layer.scale, tintColor = null)
}
is TintedClockLayer -> {
ClockLayer(
layer.sublayers,
scale = layer.scale,
tintColor = if (layer.color == 0) defaultTintColor
else Color(getTone(layer.color, colorTone))
)
}
is ColorLayer -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (layer.color == 0) {
defaultTintColor
} else {
Color(getTone(layer.color, colorTone))
}
)
)
}
is StaticIconLayer -> {
Canvas(modifier = Modifier.fillMaxSize()) {
withTransform({
this.scale(layer.scale)
}) {
drawIntoCanvas {
layer.icon.bounds = this.size.toRect().toAndroidRect()
layer.icon.draw(it.nativeCanvas)
}
}
}
}
is TextLayer -> {
Text(
text = layer.text,
style = MaterialTheme.typography.headlineSmall.copy(
fontSize = 20.sp * (size / 48.dp)
),
color = if (layer.color == 0) {
defaultTintColor
} else {
Color(getTone(layer.color, colorTone))
},
)
}
is TintedIconLayer -> {
val color =
if (layer.color == 0) defaultTintColor.toArgb()
else getTone(layer.color, colorTone)
Canvas(modifier = Modifier.fillMaxSize()) {
withTransform({
this.scale(layer.scale)
}) {
drawIntoCanvas {
layer.icon.bounds = this.size.toRect().toAndroidRect()
layer.icon.drawWithColorFilter(
it.nativeCanvas,
PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
)
}
}
}
}
is TransparentLayer -> {}
}
}
private fun getTone(argb: Int, tone: Int): Int {
return TonalPalette
.fromInt(argb)
.tone(tone)
}
@Composable
private fun ClockLayer(
sublayers: List<ClockSublayer>,
scale: Float,
tintColor: Color?,
) {
val time = remember(LocalTime.current) {
Instant.ofEpochMilli(System.currentTimeMillis()).atZone(ZoneId.systemDefault())
}
val second = remember {
Animatable(time.second.toFloat())
}
val minute = remember {
Animatable(time.minute.toFloat() + time.second.toFloat() / 60f)
}
val hour = remember {
Animatable(time.hour.toFloat() + time.minute.toFloat() / 60f)
}
LaunchedEffect(time) {
val h = time.hour.toFloat() + time.minute.toFloat() / 60f
val m = time.minute.toFloat() + time.second.toFloat() / 60f
val s = time.second.toFloat() + (time.nano / 1000000f) / 1000f
second.snapTo(s)
hour.snapTo(h)
minute.snapTo(m)
launch {
hour.animateTo(h + 1.5f / 60f, tween(90000, easing = LinearEasing))
}
launch {
minute.animateTo(m + 1.5f, tween(90000, easing = LinearEasing))
}
launch {
second.animateTo(s + 90f, tween(90000, easing = LinearEasing))
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val colorFilter = tintColor?.let {
PorterDuffColorFilter(tintColor.toArgb(), PorterDuff.Mode.SRC_IN)
}
withTransform({
this.scale(scale)
}) {
for (sublayer in sublayers) {
withTransform({
when (sublayer.role) {
ClockSublayerRole.Hour -> {
rotate(hour.value / 12f * 360f)
}
ClockSublayerRole.Minute -> {
rotate(minute.value / 60f * 360f)
}
ClockSublayerRole.Second -> {
rotate(second.value / 60f * 360f)
}
ClockSublayerRole.Static -> {}
}
}) {
drawIntoCanvas {
sublayer.drawable.bounds = this.size.toRect().toAndroidRect()
sublayer.drawable.drawWithColorFilter(it.nativeCanvas, colorFilter)
}
}
}
}
}
}
val LocalIconShape = compositionLocalOf<Shape> { CircleShape }
fun getShape(iconShape: IconShape): Shape {
return when (iconShape) {
IconShape.PlatformDefault -> PlatformShape
IconShape.Circle -> CircleShape
IconShape.Square -> RectangleShape
IconShape.RoundedSquare -> RoundedCornerShape(25)
IconShape.Triangle -> TriangleShape
IconShape.Squircle -> SquircleShape
IconShape.Hexagon -> HexagonShape
IconShape.Pentagon -> PentagonShape
IconShape.Teardrop -> TeardropShape
IconShape.Pebble -> PebbleShape
IconShape.EasterEgg -> EasterEggShape
IconShape.UNRECOGNIZED -> CircleShape
}
}
@Composable
fun ProvideIconShape(iconShape: IconShape, content: @Composable () -> Unit) {
val shape = getShape(iconShape)
CompositionLocalProvider(
LocalIconShape provides shape,
content = content
)
}
private val TriangleShape: Shape
get() = GenericShape { size, _ ->
var cx = 0f
var cy = size.height * 0.86f
val r = size.width
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 300f, 60f, false)
cx = size.width
cy = size.height * 0.86f
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 180f, 60f, false)
cx = size.width * 0.5f
cy = 0f
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 60f, 60f, false)
close()
}
private val SquircleShape: Shape
get() = GenericShape { size, _ ->
val radius = size.width / 2f
val radiusToPow = radius.pow(3f).toDouble()
moveTo(-radius, 0f)
for (x in -radius.roundToInt()..radius.roundToInt())
lineTo(
x.toFloat(),
Math.cbrt(radiusToPow - abs(x * x * x)).toFloat()
)
for (x in radius.roundToInt() downTo -radius.roundToInt())
lineTo(
x.toFloat(),
(-Math.cbrt(radiusToPow - abs(x * x * x))).toFloat()
)
translate(Offset(size.width / 2f, size.height / 2f))
}
private val HexagonShape: Shape
get() = GenericShape { size, _ ->
moveTo(
size.width * 0.25f,
size.height * 0.933f
)
lineTo(
size.width * 0.75f,
size.height * 0.933f
)
lineTo(
size.width * 1.0f,
size.height * 0.5f
)
lineTo(
size.width * 0.75f,
size.height * 0.067f
)
lineTo(
size.width * 0.25f,
size.height * 0.067f
)
lineTo(0f, size.height * 0.5f)
close()
}
private val PentagonShape: Shape
get() = GenericShape { size, _ ->
moveTo(
0.49997027f * size.width,
0.0060308f * size.height
)
lineTo(
0.99994053f * size.width,
0.36928048f * size.height
)
lineTo(
0.80896887f * size.width,
0.95703078f * size.height
)
lineTo(
0.19097162f * size.width,
0.95703076f * size.height
)
lineTo(
0f,
0.36928045f * size.height
)
close()
}
private val TeardropShape: Shape
get() = GenericShape { size, _ ->
moveTo(0.5f * size.width, 0f)
cubicTo(
0.776f * size.width, 0f,
size.width, 0.224f * size.height,
size.width, 0.5f * size.height,
)
lineTo(
size.width, 0.88f * size.height,
)
cubicTo(
size.width, 0.946f * size.height,
0.946f * size.width, size.height,
0.88f * size.width, size.height,
)
lineTo(0.5f * size.width, size.height)
cubicTo(
0.224f * size.width, size.height,
0f, 0.776f * size.height,
0f, 0.5f * size.height,
)
cubicTo(
0f, 0.224f * size.height,
0.224f * size.width, 0f,
0.5f * size.width, 0f,
)
close()
}
private val PebbleShape: Shape
get() = GenericShape { size, _ ->
moveTo(0.55f * size.width, 0f * size.height)
cubicTo(
0.25f * size.width,
0f * size.height,
0f * size.width,
0.25f * size.height,
0f * size.width,
0.5f * size.height
)
cubicTo(
0f * size.width,
0.78f * size.height,
0.28f * size.width,
1f * size.height,
0.55f * size.width,
1f * size.height
)
cubicTo(
0.85f * size.width,
1f * size.height,
1f * size.width,
0.85f * size.height,
1f * size.width,
0.58f * size.height
)
cubicTo(
1f * size.width,
0.3f * size.height,
0.86f * size.width,
0f * size.height,
0.55f * size.width,
0f * size.height
)
close()
}
private val EasterEggShape: Shape
get() = GenericShape { size, _ ->
moveTo(
0.49999999f * size.width,
1f * size.height
)
lineTo(
0.42749999f * size.width,
0.9339999999999999f * size.height
)
cubicTo(
0.16999998f * size.width,
0.7005004f * size.height,
0f,
0.5460004f * size.height,
0f,
0.3575003f * size.height
)
cubicTo(
0f,
0.2030004f * size.height,
0.12100002f * size.width,
0.0825004f * size.height,
0.275f * size.width,
0.0825004f * size.height
)
cubicTo(
0.362f * size.width,
0.0825004f * size.height,
0.4455f * size.width,
0.123f * size.height,
0.5f * size.width,
0.1865003f * size.height
)
cubicTo(
0.55449999f * size.width,
0.123f * size.height,
0.638f * size.width,
0.0825f * size.height,
0.725f * size.width,
0.0825f * size.height
)
cubicTo(
0.87900006f * size.width,
0.0825004f * size.height,
1f * size.width,
0.2030004f * size.height,
1f * size.width,
0.3575003f * size.height
)
cubicTo(
1f * size.width,
0.5460004f * size.height,
0.82999999f * size.width,
0.7005004f * size.height,
0.57250001f * size.width,
0.9340004f * size.height
)
close()
}
private val PlatformShape: Shape
get() {
val drawable = AdaptiveIconDrawable(null, null)
val pathBounds = RectF()
drawable.iconMask.computeBounds(pathBounds, true)
return GenericShape { size, _ ->
val path = Path(drawable.iconMask)
val transformMatrix = Matrix()
transformMatrix.setScale(
size.width / pathBounds.width(),
size.height / pathBounds.height()
)
path.transform(transformMatrix)
addPath(path.asComposePath())
}
}
@@ -0,0 +1,40 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SmallMessage(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String,
color: Color = MaterialTheme.colorScheme.surfaceVariant
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = color
)
) {
Row(
modifier = Modifier
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = icon, contentDescription = null)
Text(
text = text,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
}
@@ -0,0 +1,160 @@
package de.mm20.launcher2.ui.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.integerResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.locals.LocalWindowPosition
import kotlin.math.min
@Composable
fun Toolbar(
modifier: Modifier = Modifier,
leftActions: List<ToolbarAction> = emptyList(),
rightActions: List<ToolbarAction> = emptyList()
) {
val slots = integerResource(R.integer.config_toolbarSlots)
Row(
modifier = modifier
.padding(4.dp)
) {
Icons(leftActions, slots)
Spacer(modifier = Modifier.weight(1f, fill = true))
Icons(rightActions, slots)
}
}
@Composable
fun Icons(actions: List<ToolbarAction>, slots: Int) {
for (i in 0 until min(slots, actions.size)) {
if (i == slots - 1 && slots != actions.size) {
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Rounded.MoreVert, contentDescription = "")
}
Box(
modifier = Modifier
.size(48.dp)
.offset(0.dp, LocalWindowPosition.current.toDp())
) {
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.animateContentSize()
) {
OverflowMenuItems(items = actions.subList(slots - 1, actions.size)) {
showMenu = false
}
}
}
}
} else {
val action = actions[i]
when (action) {
is DefaultToolbarAction -> {
IconButton(action.action) {
Icon(action.icon, contentDescription = action.label)
}
}
is SubmenuToolbarAction -> {
Box {
var showMenu by remember { mutableStateOf(false) }
IconButton({
showMenu = true
}) {
Icon(action.icon, contentDescription = action.label)
}
Box(
modifier = Modifier
.size(48.dp)
.offset(0.dp, LocalWindowPosition.current.toDp())
) {
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.animateContentSize()
) {
OverflowMenuItems(items = action.children) {
showMenu = false
}
}
}
}
}
}
}
}
}
@Composable
fun ColumnScope.OverflowMenuItems(items: List<ToolbarAction>, onDismiss: () -> Unit) {
var selectedSubMenu by remember { mutableStateOf(-1) }
if (selectedSubMenu == -1) {
items.forEachIndexed { i, action ->
when (action) {
is SubmenuToolbarAction -> {
DropdownMenuItem(
onClick = { selectedSubMenu = i },
text = {
Text(
action.label, modifier = Modifier.weight(1f),
)
},
leadingIcon = {
Icon(action.icon, null)
},
trailingIcon = {
Icon(Icons.Rounded.ChevronRight, null)
}
)
}
is DefaultToolbarAction -> {
DropdownMenuItem(
onClick = {
action.action()
onDismiss()
},
text = {
Text(
action.label,
)
},
leadingIcon = {
Icon(action.icon, null)
}
)
}
}
}
} else {
val submenu = items[selectedSubMenu] as SubmenuToolbarAction
OverflowMenuItems(items = submenu.children, onDismiss)
}
}
interface ToolbarAction {
val label: String
val icon: ImageVector
}
data class DefaultToolbarAction(
override val label: String,
override val icon: ImageVector,
val action: () -> Unit
) : ToolbarAction
data class SubmenuToolbarAction(
override val label: String,
override val icon: ImageVector,
val children: List<ToolbarAction>
) : ToolbarAction
@@ -0,0 +1,95 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.godaddy.android.colorpicker.ClassicColorPicker
import de.mm20.launcher2.ui.ktx.toHexString
@Composable
fun ColorPreference(
title: String,
summary: String? = null,
value: Color?,
onValueChanged: (Color?) -> Unit = {}
) {
var showDialog by remember { mutableStateOf(false) }
Preference(
title = title,
summary = summary,
controls = {
value?.let {
Surface(
color = it,
shape = CircleShape,
modifier = Modifier
.padding(vertical = 12.dp)
.size(36.dp)
) {}
}
},
onClick = {
showDialog = true
}
)
if (showDialog) {
var color by remember(value) { mutableStateOf(value) }
AlertDialog(
onDismissRequest = { showDialog = false },
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth()
) {
ClassicColorPicker(
color = value ?: Color.Black,
onColorChanged = {
color = it.toColor()
},
showAlphaBar = false,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
var hexValue by remember(color) {
mutableStateOf(
color?.toHexString() ?: "#000000"
)
}
TextField(
modifier = Modifier.padding(top = 16.dp),
value = hexValue,
onValueChange = {
hexValue = it
if (Regex("#[0-9a-fA-F]{6}").matches(it)) {
val hex = it.substring(1).toIntOrNull(16) ?: return@TextField
color = Color(hex).copy(alpha = 1f)
}
}
)
}
},
confirmButton = {
TextButton(onClick = {
onValueChanged(color)
showDialog = false
}) {
Text(stringResource(android.R.string.ok))
}
}
)
}
}
@@ -0,0 +1,111 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun <T> ListPreference(
title: String,
icon: ImageVector? = null,
iconPadding: Boolean = true,
items: List<ListPreferenceItem<T>>,
value: T,
summary: String? = items.firstOrNull { value == it.value }?.label,
onValueChanged: (T) -> Unit,
enabled: Boolean = true,
itemLabel: @Composable RowScope.(item: ListPreferenceItem<T>) -> Unit = {
Text(
text = it.label,
)
}
) {
var showDialog by remember { mutableStateOf(false) }
Preference(
title = title,
summary = summary,
icon = icon,
iconPadding = iconPadding,
enabled = enabled,
onClick = {
showDialog = true
}
)
if (showDialog) {
Dialog(onDismissRequest = { showDialog = false }) {
Surface(
tonalElevation = 16.dp,
shadowElevation = 16.dp,
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(
start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp
)
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
items(items) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
onValueChanged(it.value)
showDialog = false
}
.padding(
start = 16.dp,
top = 16.dp,
bottom = 16.dp,
end = 24.dp
)
) {
RadioButton(
selected = it.value == value,
onClick = null,
modifier = Modifier
.padding(end = 16.dp)
)
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.titleMedium
) {
itemLabel(it)
}
}
}
}
}
}
}
}
}
typealias ListPreferenceItem<T> = Pair<String, T>
inline val <T>ListPreferenceItem<T>.label: String
get() = this.first
inline val <T>ListPreferenceItem<T>.value: T
get() = this.second
@@ -0,0 +1,89 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun Preference(
title: String,
icon: @Composable (() -> Unit),
iconPadding: Boolean = true,
summary: String? = null,
onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp)
.alpha(if (enabled) 1f else 0.38f),
) {
if (iconPadding) {
Box(
modifier = Modifier
.width(56.dp)
.padding(start = 4.dp),
contentAlignment = Alignment.CenterStart
) {
icon()
}
} else {
Box(modifier = Modifier.size(0.dp))
}
Column(
modifier = Modifier.weight(1f).padding(vertical = 16.dp)
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
if (summary != null) {
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp)
)
}
}
if (controls != null) {
Box(
modifier = Modifier.padding(start = 24.dp)
) {
controls()
}
}
}
}
@Composable
fun Preference(
title: String,
icon: ImageVector? = null,
iconPadding: Boolean = true,
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,
)
}
}, iconPadding, summary, onClick, controls, enabled
)
}
@@ -0,0 +1,38 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PreferenceCategory(
title: String? = null,
content: @Composable ColumnScope.() -> Unit
) {
Column {
if (title != null) {
Row(
modifier = Modifier
.padding(start = 16.dp, top = 16.dp, end = 16.dp)
) {
Text(
modifier = Modifier.padding(start = 56.dp),
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
content()
Box(
modifier = Modifier.fillMaxWidth().height(0.5.dp).background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
)
}
}
@@ -0,0 +1,100 @@
package de.mm20.launcher2.ui.component.preferences
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun PreferenceScreen(
title: String,
floatingActionButton: @Composable () -> Unit = {},
topBarActions: @Composable RowScope.() -> Unit = {},
helpUrl: String? = null,
content: LazyListScope.() -> Unit,
) {
val navController = LocalNavController.current
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.surface)
systemUiController.setNavigationBarColor(Color.Black)
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
val activity = LocalContext.current as? AppCompatActivity
Scaffold(
floatingActionButton = floatingActionButton,
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
title,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1
)
},
navigationIcon = {
IconButton(onClick = {
if (navController?.navigateUp() != true) {
activity?.onBackPressed()
}
}) {
Icon(imageVector = Icons.Rounded.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (helpUrl != null) {
IconButton(onClick = {
CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(CustomTabColorSchemeParams.Builder()
.setToolbarColor(colorScheme.primaryContainer.toArgb())
.setSecondaryToolbarColor(colorScheme.secondaryContainer.toArgb())
.build()
)
.build().launchUrl(context, Uri.parse(helpUrl))
}) {
Icon(
imageVector = Icons.Rounded.HelpOutline,
contentDescription = "Help"
)
}
}
topBarActions()
}
)
}) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it),
content = content,
)
}
}
@@ -0,0 +1,52 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun PreferenceWithSwitch(
title: String,
summary: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true,
onClick: () -> Unit = {},
switchValue: Boolean,
onSwitchChanged: (Boolean) -> Unit
) {
Row(
verticalAlignment = (Alignment.CenterVertically)
) {
Box(
modifier = Modifier.weight(1f)
) {
Preference(
title = title,
summary = summary,
icon = icon,
enabled = enabled,
onClick = onClick
)
}
Box(
modifier = Modifier
.height(36.dp)
.width(1.dp)
.alpha(0.38f)
.background(LocalContentColor.current)
)
Switch(
modifier = Modifier.padding(horizontal = 16.dp),
checked = switchValue,
enabled = enabled,
onCheckedChange = onSwitchChanged,
)
}
}
@@ -0,0 +1,113 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Slider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import java.text.DecimalFormat
import kotlin.math.floor
import kotlin.math.log
import kotlin.math.roundToInt
@Composable
fun SliderPreference(
title: String,
icon: ImageVector? = null,
value: Float,
min: Float = 0f,
max: Float = 1f,
step: Float? = null,
onValueChanged: (Float) -> Unit,
enabled: Boolean = true
) {
var sliderValue by remember(value) { mutableStateOf(value) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
.alpha(if (enabled) 1f else 0.38f),
) {
Box(
modifier = Modifier
.width(48.dp)
.padding(start = 4.dp),
contentAlignment = Alignment.CenterStart
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
modifier = Modifier.padding(start = 8.dp),
text = title,
style = MaterialTheme.typography.titleMedium
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Slider(
modifier = Modifier.weight(1f),
value = sliderValue,
onValueChange = {
sliderValue = it
},
valueRange = min..max,
steps = step?.let { ((max - min) / it).toInt() - 1 } ?: 0,
onValueChangeFinished = {
onValueChanged(sliderValue)
}
)
val decimalPlaces = -log(step ?: 0.01f, 10f)
val format = remember { DecimalFormat().apply {
maximumFractionDigits = floor(decimalPlaces).toInt()
minimumFractionDigits = 0
} }
Text(
modifier = Modifier.width(56.dp).padding(start = 24.dp),
text = format.format(sliderValue),
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
@Composable
fun SliderPreference(
title: String,
icon: ImageVector? = null,
value: Int,
min: Int = 0,
max: Int = 100,
step: Int = 1,
onValueChanged: (Int) -> Unit,
enabled: Boolean = true
) {
SliderPreference(
title = title,
icon = icon,
value = value.toFloat(),
enabled = enabled,
min = min.toFloat(),
max = max.toFloat(),
step = step.toFloat(),
onValueChanged = {
onValueChanged(it.roundToInt())
}
)
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun SwitchPreference(
title: String,
icon: ImageVector? = null,
summary: String? = null,
value: Boolean,
onValueChanged: (Boolean) -> Unit,
enabled: Boolean = true
) {
Preference(
title = title,
icon = icon,
summary = summary,
enabled = enabled,
onClick = {
onValueChanged(!value)
},
controls = {
Switch(
enabled = enabled, checked = value, onCheckedChange = onValueChanged,
)
}
)
}
@@ -0,0 +1,64 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.res.stringResource
@Composable
fun TextPreference(
title: String,
value: String,
summary: String? = value,
onValueChanged: (String) -> Unit,
placeholder: String? = null
) {
var showDialog by remember { mutableStateOf(false) }
Preference(
title = title,
summary = summary,
onClick = { showDialog = true }
)
if (showDialog) {
var textFieldValue by remember { mutableStateOf(value) }
AlertDialog(
onDismissRequest = { showDialog = false },
title = {
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
text = {
OutlinedTextField(
value = textFieldValue,
textStyle = MaterialTheme.typography.bodyLarge,
onValueChange = { textFieldValue = it },
placeholder = placeholder?.let {
{
Text(
text = it,
)
}
},
)
},
confirmButton = {
TextButton(onClick = {
onValueChanged(textFieldValue)
showDialog = false
}) {
Text(
text = stringResource(android.R.string.ok),
)
}
},
dismissButton = {
TextButton(onClick = {
showDialog = false
}) {
Text(
text = stringResource(android.R.string.cancel),
)
}
}
)
}
}
@@ -0,0 +1,658 @@
package de.mm20.launcher2.ui.component.weather
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColor
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AcUnit
import androidx.compose.material.icons.rounded.Air
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Thermostat
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.icons.*
import kotlin.math.PI
import kotlin.math.sin
@Composable
fun AnimatedWeatherIcon(
modifier: Modifier = Modifier,
icon: WeatherIcon,
night: Boolean = false
) {
Box(
modifier = modifier
.padding(8.dp)
.size(64.dp)
.clipToBounds(),
contentAlignment = Alignment.Center
) {
SunMoon(icon, night)
Precipitation(icon)
LightningBolt(icon)
LightningBolt2(icon)
Cloud1(icon)
Wind2(icon)
Cloud2(icon)
Cloud3(icon)
Fog(icon)
Hot(icon)
Cold(icon)
Wind1(icon)
}
}
@Composable
private fun SunMoon(icon: WeatherIcon, night: Boolean) {
val sunMoonIcon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_weather_sun_moon)
val transition = updateTransition(targetState = icon, "AnimatedWeatherIcon")
val color by animateColorAsState(
if (night) colorResource(id = R.color.weather_moon) else colorResource(id = R.color.weather_sun)
)
val scale by transition.animateFloat(label = "sunScale") {
when (it) {
WeatherIcon.Clear,
WeatherIcon.PartlyCloudy,
WeatherIcon.BrokenClouds,
WeatherIcon.Haze -> 1f
WeatherIcon.MostlyCloudy -> 0.8f
else -> 0f
}
}
val offset by transition.animateOffset(label = "sunOffset") {
when (it) {
WeatherIcon.Clear,
WeatherIcon.BrokenClouds,
WeatherIcon.Haze -> Offset.Zero
WeatherIcon.MostlyCloudy -> Offset(6f, -4f)
WeatherIcon.PartlyCloudy -> Offset(3f, -2f)
else -> Offset.Zero
}
}
Icon(
rememberAnimatedVectorPainter(sunMoonIcon, atEnd = night),
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x.dp, offset.y.dp)
.scale(scale),
tint = color
)
}
@Composable
private fun LightningBolt(icon: WeatherIcon) {
val transition = updateTransition(targetState = icon, "AnimatedWeatherIconBolt")
val scale by transition.animateFloat(label = "scale") {
when (it) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.HeavyThunderstorm -> 0.6f
else -> 0f
}
}
val offset by transition.animateOffset(label = "offset") {
when (it) {
WeatherIcon.ThunderstormWithRain,
WeatherIcon.Thunderstorm -> Offset(1f, 8f)
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.HeavyThunderstorm -> Offset(6f, 8f)
else -> Offset.Zero
}
}
Icon(
Icons.Rounded.Bolt,
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x.dp, offset.y.dp)
.scale(scale),
tint = colorResource(id = R.color.weather_lightning_bolt)
)
}
@Composable
private fun LightningBolt2(icon: WeatherIcon) {
val transition = updateTransition(targetState = icon, "AnimatedWeatherIconBolt2")
val scale by transition.animateFloat(label = "scale") {
when (it) {
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.HeavyThunderstorm -> 0.5f
else -> 0f
}
}
val offset by transition.animateOffset(label = "offset") {
when (it) {
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.HeavyThunderstorm -> Offset(-9f, 5f)
else -> Offset.Zero
}
}
Icon(
Icons.Rounded.Bolt,
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x.dp, offset.y.dp)
.scale(scale),
tint = colorResource(id = R.color.weather_lightning_bolt)
)
}
@Composable
private fun Cloud1(icon: WeatherIcon) {
val transition = updateTransition(targetState = icon, "AnimatedWeatherIconCloud1")
val scale by transition.animateFloat(label = "scale") {
when (it) {
WeatherIcon.Clear -> 0f
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Wind,
WeatherIcon.Cloudy,
WeatherIcon.Storm,
WeatherIcon.Fog -> 1.4f
WeatherIcon.MostlyCloudy,
WeatherIcon.PartlyCloudy -> 1f
WeatherIcon.BrokenClouds -> 0.9f
else -> 0f
}
}
val offset by transition.animateOffset(label = "offset") {
when (it) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> Offset(0f, -14f)
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Storm,
WeatherIcon.Fog -> Offset(0f, -5f)
WeatherIcon.MostlyCloudy -> Offset(-5f, 0f)
WeatherIcon.PartlyCloudy -> Offset(-3f, 4f)
WeatherIcon.BrokenClouds -> Offset(-5f, 7f)
else -> Offset.Zero
}
}
val color by transition.animateColor(label = "color") {
when (it) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Storm -> colorResource(id = R.color.weather_cloud_dark_2)
WeatherIcon.Showers,
WeatherIcon.Sleet,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Fog -> colorResource(id = R.color.weather_cloud_dark_1)
WeatherIcon.Drizzle,
WeatherIcon.Snow -> colorResource(id = R.color.weather_cloud_medium_2)
WeatherIcon.MostlyCloudy -> colorResource(id = R.color.weather_cloud_light_2)
WeatherIcon.PartlyCloudy,
WeatherIcon.BrokenClouds -> colorResource(id = R.color.weather_cloud_light_1)
else -> colorResource(id = R.color.weather_cloud_medium_2)
}
}
Icon(
Icons.Rounded.WeatherCloud,
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x.dp, offset.y.dp)
.scale(scale),
tint = color
)
}
@Composable
private fun Cloud2(icon: WeatherIcon) {
val transition = updateTransition(targetState = icon, "AnimatedWeatherIconCloud2")
val scale by transition.animateFloat(label = "scale") {
when (it) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.MostlyCloudy,
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Storm,
WeatherIcon.Fog -> 1.1f
WeatherIcon.BrokenClouds -> 0.7f
else -> 0f
}
}
val offset by transition.animateOffset(label = "offset") {
when (it) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> Offset(-6f, -6f)
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Storm,
WeatherIcon.Fog -> Offset(-6f, 3f)
WeatherIcon.BrokenClouds -> Offset(9f, -9f)
WeatherIcon.MostlyCloudy -> Offset(4f, 5f)
else -> Offset.Zero
}
}
val color by transition.animateColor(label = "color") {
when (it) {
WeatherIcon.BrokenClouds -> colorResource(id = R.color.weather_cloud_light_2)
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Storm -> colorResource(id = R.color.weather_cloud_medium_2)
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Fog -> colorResource(id = R.color.weather_cloud_medium_1)
WeatherIcon.MostlyCloudy -> colorResource(id = R.color.weather_cloud_medium_1)
else -> colorResource(id = R.color.weather_cloud_medium_2)
}
}
Icon(
Icons.Rounded.WeatherCloud,
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x.dp, offset.y.dp)
.scale(scale),
tint = color
)
}
@Composable
private fun Cloud3(icon: WeatherIcon) {
val transition = updateTransition(targetState = icon, "AnimatedWeatherIconCloud3")
val scale by transition.animateFloat(label = "scale") {
when (it) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Storm -> 1f
else -> 0f
}
}
val offset by transition.animateOffset(label = "offset") {
when (it) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> Offset(6f, -4f)
WeatherIcon.Cloudy,
WeatherIcon.Wind,
WeatherIcon.Storm -> Offset(6f, 5f)
else -> Offset.Zero
}
}
val color by transition.animateColor(label = "color") {
when (it) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Storm -> colorResource(id = R.color.weather_cloud_dark_1)
WeatherIcon.Showers,
WeatherIcon.Sleet,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Wind -> colorResource(id = R.color.weather_cloud_medium_2)
WeatherIcon.Drizzle,
WeatherIcon.Snow -> colorResource(id = R.color.weather_cloud_light_2)
else -> colorResource(id = R.color.weather_cloud_medium_2)
}
}
Icon(
Icons.Rounded.WeatherCloud,
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x.dp, offset.y.dp)
.scale(scale),
tint = color
)
}
@Composable
private fun Hot(icon: WeatherIcon) {
val scale by animateFloatAsState(
if (icon == WeatherIcon.Hot) {
1f
} else {
0f
}
)
Icon(
Icons.Rounded.Thermostat,
null,
modifier = Modifier
.scale(scale)
.size(32.dp),
tint = colorResource(id = R.color.weather_hot)
)
}
@Composable
private fun Cold(icon: WeatherIcon) {
val scale by animateFloatAsState(
if (icon == WeatherIcon.Cold) {
1f
} else {
0f
}
)
Icon(
Icons.Rounded.AcUnit,
null,
modifier = Modifier
.scale(scale)
.size(32.dp),
tint = colorResource(id = R.color.weather_cold)
)
}
@Composable
private fun Wind1(icon: WeatherIcon) {
val scale by animateFloatAsState(
if (icon == WeatherIcon.Wind || icon == WeatherIcon.Storm) {
0.6f
} else {
0f
}
)
Icon(
Icons.Rounded.Air,
null,
modifier = Modifier
.scale(scale)
.offset(12.dp, 11.dp)
.size(32.dp),
tint = colorResource(id = R.color.weather_wind)
)
}
@Composable
private fun Wind2(icon: WeatherIcon) {
val scale by animateFloatAsState(
if (icon == WeatherIcon.Storm) {
0.6f
} else {
0f
}
)
Icon(
Icons.Rounded.Air,
null,
modifier = Modifier
.scale(scale)
.offset(8.dp, -1.dp)
.size(32.dp),
tint = colorResource(id = R.color.weather_wind_dark)
)
}
@Preview
@Composable
fun AnimatedWeatherIconTestPanel() {
var icon by remember { mutableStateOf(WeatherIcon.MostlyCloudy) }
var night by remember { mutableStateOf(false) }
Row {
val icons = WeatherIcon.values()
Column(
modifier = Modifier.weight(1f)
) {
AnimatedWeatherIcon(icon = icon, night = night)
WeatherIcon(icon = icon, night = night)
}
Column {
Box {
var menu by remember { mutableStateOf(false) }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
for (ic in icons) {
DropdownMenuItem(
onClick = {
icon = ic
menu = false
},
text = {
Text(text = ic.name)
}
)
}
}
Button(onClick = { menu = true }) {
Text(text = icon.name)
}
}
Button(onClick = { night = !night }) {
Text(text = if (night) "Night" else "Day")
}
}
}
}
@Composable
private fun Precipitation(icon: WeatherIcon) {
Box(
modifier = Modifier
.size(32.dp)
.clipToBounds()
.rotate(10f)
) {
Crossfade(icon) {
when (it) {
WeatherIcon.Drizzle -> {
val anim = rememberInfiniteTransition()
val animProgress by anim.animateFloat(
initialValue = 0f, targetValue = 1f, animationSpec = InfiniteRepeatableSpec(
tween(300, easing = LinearEasing)
)
)
Icon(
modifier = Modifier
.size(32.dp)
.offset(y = 8.dp + 11.dp * animProgress),
imageVector = Icons.Rounded.WeatherLightRainAnimatable,
contentDescription = null,
tint = colorResource(id = R.color.weather_rain)
)
}
WeatherIcon.Hail -> {
val anim = rememberInfiniteTransition()
val animProgress by anim.animateFloat(
initialValue = 0f, targetValue = 1f, animationSpec = InfiniteRepeatableSpec(
tween(300, easing = LinearEasing)
)
)
Icon(
modifier = Modifier
.size(32.dp)
.offset(y = 8.dp + 11.dp * animProgress),
imageVector = Icons.Rounded.WeatherHailAnimatable,
contentDescription = null,
tint = colorResource(id = R.color.weather_hail)
)
}
WeatherIcon.Snow -> {
val anim = rememberInfiniteTransition()
val animProgress by anim.animateFloat(
initialValue = 0f, targetValue = 1f, animationSpec = InfiniteRepeatableSpec(
tween(1000, easing = LinearEasing)
)
)
Icon(
modifier = Modifier
.size(32.dp)
.offset(
x = sin(animProgress * 2 * PI).dp,
y = 8.dp + 11.dp * animProgress
),
imageVector = Icons.Rounded.WeatherHailAnimatable,
contentDescription = null,
tint = colorResource(id = R.color.weather_snow)
)
}
WeatherIcon.Showers,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstormWithRain -> {
val anim = rememberInfiniteTransition()
val animProgress by anim.animateFloat(
initialValue = 0f, targetValue = 1f, animationSpec = InfiniteRepeatableSpec(
tween(300, easing = LinearEasing)
)
)
Icon(
modifier = Modifier
.size(32.dp)
.offset(y = 8.dp + 11.dp * animProgress),
imageVector = Icons.Rounded.WeatherRainAnimatable,
contentDescription = null,
tint = colorResource(id = R.color.weather_rain)
)
}
WeatherIcon.Sleet -> {
val anim = rememberInfiniteTransition()
val animProgress by anim.animateFloat(
initialValue = 0f, targetValue = 1f, animationSpec = InfiniteRepeatableSpec(
tween(300, easing = LinearEasing)
)
)
Icon(
modifier = Modifier
.size(32.dp)
.offset(y = 8.dp + 11.dp * animProgress),
imageVector = Icons.Rounded.WeatherSleetRainAnimatable,
contentDescription = null,
tint = colorResource(id = R.color.weather_rain)
)
Icon(
modifier = Modifier
.size(32.dp)
.offset(y = 8.dp + 11.dp * animProgress),
imageVector = Icons.Rounded.WeatherSleetSnowAnimatable,
contentDescription = null,
tint = colorResource(id = R.color.weather_snow)
)
}
else -> {}
}
}
}
}
@Composable
private fun Fog(icon: WeatherIcon) {
val scale by animateFloatAsState(if (icon == WeatherIcon.Fog || icon == WeatherIcon.Haze) 1f else 0f)
Icon(
modifier = Modifier
.size(32.dp)
.scale(scale)
.offset(x = 6.dp, y = 7.dp),
imageVector = Icons.Rounded.WeatherFog,
contentDescription = null,
tint = colorResource(id = R.color.weather_fog)
)
}
enum class WeatherIcon {
None,
Clear,
Cloudy,
Cold,
Drizzle,
Haze,
Fog,
Hail,
HeavyThunderstorm,
HeavyThunderstormWithRain,
Hot,
MostlyCloudy,
PartlyCloudy,
Showers,
Sleet,
Snow,
Storm,
Thunderstorm,
ThunderstormWithRain,
Wind,
BrokenClouds
}
@@ -0,0 +1,456 @@
package de.mm20.launcher2.ui.component.weather
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.*
import de.mm20.launcher2.ui.icons.*
@Composable
fun WeatherIcon(
icon: WeatherIcon,
night: Boolean = false
) {
Box(
modifier = Modifier
.size(24.dp),
contentAlignment = Alignment.Center
) {
SunMoon(icon, night)
Precipitation(icon)
LightningBolts(icon)
Cloud1(icon)
Cloud2(icon)
Cloud3(icon)
HotCold(icon)
Wind(icon)
Fog(icon)
}
}
@Composable
private fun SunMoon(icon: WeatherIcon, night: Boolean) {
when (icon) {
WeatherIcon.Clear,
WeatherIcon.PartlyCloudy,
WeatherIcon.BrokenClouds,
WeatherIcon.Haze,
WeatherIcon.MostlyCloudy -> {
}
else -> return
}
val color = if (night) colorResource(id = R.color.weather_moon) else colorResource(id = R.color.weather_sun)
val scale = when (icon) {
WeatherIcon.Clear,
WeatherIcon.PartlyCloudy,
WeatherIcon.BrokenClouds,
WeatherIcon.Haze -> 1f
WeatherIcon.MostlyCloudy -> 0.8f
else -> 0f
}
val offset = when (icon) {
WeatherIcon.MostlyCloudy -> DpOffset(2.dp, -3.dp)
WeatherIcon.PartlyCloudy -> DpOffset(2.dp, -2.dp)
else -> DpOffset.Zero
}
Icon(
if (night) Icons.Rounded.DarkMode else Icons.Rounded.LightMode,
null,
modifier = Modifier
.size(24.dp)
.offset(offset.x, offset.y)
.scale(scale),
tint = color
)
}
@Composable
private fun Cloud1(icon: WeatherIcon) {
when (icon) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Fog,
WeatherIcon.MostlyCloudy,
WeatherIcon.PartlyCloudy,
WeatherIcon.BrokenClouds -> {
}
else -> return
}
val scale = when (icon) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Fog -> 1.4f
WeatherIcon.MostlyCloudy,
WeatherIcon.PartlyCloudy -> 1f
WeatherIcon.BrokenClouds -> 0.9f
else -> 0f
}
val offset = when (icon) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> DpOffset(0.dp, -7.dp)
WeatherIcon.Cloudy,
WeatherIcon.Fog -> DpOffset(0.dp, -2.5.dp)
WeatherIcon.MostlyCloudy -> DpOffset(-2.5.dp, 0.dp)
WeatherIcon.PartlyCloudy -> DpOffset(-1.5.dp, 2.dp)
WeatherIcon.BrokenClouds -> DpOffset(-2.5.dp, 3.5.dp)
else -> DpOffset.Zero
}
val color = when (icon) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> colorResource(id = R.color.weather_cloud_dark_2)
WeatherIcon.Showers,
WeatherIcon.Sleet,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Fog -> colorResource(id = R.color.weather_cloud_dark_1)
WeatherIcon.Drizzle,
WeatherIcon.Snow -> colorResource(id = R.color.weather_cloud_medium_2)
WeatherIcon.MostlyCloudy -> colorResource(id = R.color.weather_cloud_light_2)
WeatherIcon.PartlyCloudy,
WeatherIcon.BrokenClouds -> colorResource(id = R.color.weather_cloud_light_1)
else -> colorResource(id = R.color.weather_cloud_medium_2)
}
Icon(
Icons.Rounded.WeatherCloud,
null,
modifier = Modifier
.size(20.dp)
.offset(offset.x, offset.y)
.scale(scale),
tint = color
)
}
@Composable
private fun Cloud2(icon: WeatherIcon) {
when (icon) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.MostlyCloudy,
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Fog,
WeatherIcon.BrokenClouds -> {
}
else -> return
}
val scale = when (icon) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.MostlyCloudy,
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Fog -> 1.1f
WeatherIcon.BrokenClouds -> 0.7f
else -> 0f
}
val offset = when (icon) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> DpOffset(-3.dp, -3.dp)
WeatherIcon.Cloudy,
WeatherIcon.Fog -> DpOffset(-3.dp, 1.5.dp)
WeatherIcon.BrokenClouds -> DpOffset(4.5.dp, -4.5.dp)
WeatherIcon.MostlyCloudy -> DpOffset(2.dp, 2.5.dp)
else -> DpOffset.Zero
}
val color = when (icon) {
WeatherIcon.BrokenClouds -> colorResource(id = R.color.weather_cloud_light_2)
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> colorResource(id = R.color.weather_cloud_medium_2)
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Cloudy,
WeatherIcon.Fog -> colorResource(id = R.color.weather_cloud_medium_1)
WeatherIcon.MostlyCloudy -> colorResource(id = R.color.weather_cloud_medium_1)
else -> colorResource(id = R.color.weather_cloud_medium_2)
}
Icon(
Icons.Rounded.WeatherCloud,
null,
modifier = Modifier
.size(20.dp)
.offset(offset.x, offset.y)
.scale(scale),
tint = color
)
}
@Composable
private fun Cloud3(icon: WeatherIcon) {
when (icon) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain,
WeatherIcon.Cloudy -> {
}
else -> return
}
val offset = when (icon) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Sleet,
WeatherIcon.Snow,
WeatherIcon.Hail,
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> DpOffset(3.dp, -2.dp)
WeatherIcon.Cloudy,
WeatherIcon.Fog -> DpOffset(3.dp, 2.5.dp)
else -> DpOffset.Zero
}
val color = when (icon) {
WeatherIcon.Thunderstorm,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstorm,
WeatherIcon.HeavyThunderstormWithRain -> colorResource(id = R.color.weather_cloud_dark_1)
WeatherIcon.Showers,
WeatherIcon.Sleet,
WeatherIcon.Hail,
WeatherIcon.Cloudy -> colorResource(id = R.color.weather_cloud_medium_2)
WeatherIcon.Drizzle,
WeatherIcon.Snow -> colorResource(id = R.color.weather_cloud_light_2)
else -> colorResource(id = R.color.weather_cloud_medium_2)
}
Icon(
Icons.Rounded.WeatherCloud,
null,
modifier = Modifier
.size(32.dp)
.offset(offset.x, offset.y),
tint = color
)
}
@Composable
private fun Precipitation(icon: WeatherIcon) {
if (icon == WeatherIcon.Sleet) {
Icon(
modifier = Modifier
.offset(y = 2.dp)
.size(32.dp),
imageVector = Icons.Rounded.WeatherSleetSnow,
contentDescription = null,
tint = colorResource(id = R.color.weather_snow)
)
Icon(
modifier = Modifier
.offset(y = 2.dp)
.size(32.dp),
imageVector = Icons.Rounded.WeatherSleetRain,
contentDescription = null,
tint = colorResource(id = R.color.weather_rain)
)
return
}
when (icon) {
WeatherIcon.Showers,
WeatherIcon.Drizzle,
WeatherIcon.Hail,
WeatherIcon.Snow,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstormWithRain -> {
}
else -> return
}
val vector = when (icon) {
WeatherIcon.Drizzle -> Icons.Rounded.WeatherLightRain
WeatherIcon.Showers,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstormWithRain -> Icons.Rounded.WeatherRain
WeatherIcon.Hail, WeatherIcon.Snow -> Icons.Rounded.WeatherHail
else -> Icons.Rounded.WeatherLightRain
}
val color = when (icon) {
WeatherIcon.Drizzle,
WeatherIcon.Showers,
WeatherIcon.ThunderstormWithRain,
WeatherIcon.HeavyThunderstormWithRain -> colorResource(id = R.color.weather_rain)
WeatherIcon.Hail -> colorResource(id = R.color.weather_hail)
WeatherIcon.Snow -> colorResource(id = R.color.weather_snow)
else -> Color.Unspecified
}
Icon(
modifier = Modifier
.offset(y = 2.dp)
.size(32.dp),
imageVector = vector,
contentDescription = null,
tint = color
)
}
@Composable
private fun HotCold(icon: WeatherIcon) {
if (icon == WeatherIcon.Hot) {
Icon(
modifier = Modifier
.size(32.dp),
imageVector = Icons.Rounded.Thermostat,
contentDescription = null,
tint = colorResource(id = R.color.weather_hot)
)
}
if (icon == WeatherIcon.Cold) {
Icon(
modifier = Modifier
.size(32.dp),
imageVector = Icons.Rounded.AcUnit,
contentDescription = null,
tint = colorResource(id = R.color.weather_cold)
)
}
}
@Composable
private fun Wind(icon: WeatherIcon) {
if (icon == WeatherIcon.Storm) {
Icon(
modifier = Modifier
.offset(x = 4.dp, y = 0.dp)
.size(24.dp),
imageVector = Icons.Rounded.Air,
contentDescription = null,
tint = colorResource(id = R.color.weather_wind_dark)
)
}
if (icon == WeatherIcon.Wind || icon == WeatherIcon.Storm) {
Icon(
modifier = Modifier
.size(if(icon == WeatherIcon.Wind) 32.dp else 24.dp),
imageVector = Icons.Rounded.Air,
contentDescription = null,
tint = colorResource(id = R.color.weather_wind)
)
}
}
@Composable
private fun Fog(icon: WeatherIcon) {
if (icon != WeatherIcon.Haze && icon != WeatherIcon.Fog) {
return
}
Icon(
modifier = Modifier
.size(24.dp)
.offset(x = 3.dp, y = 4.dp),
imageVector = Icons.Rounded.WeatherFog,
contentDescription = null,
tint = colorResource(id = R.color.weather_fog)
)
}
@Composable
private fun LightningBolts(icon: WeatherIcon) {
if (icon != WeatherIcon.ThunderstormWithRain
&& icon != WeatherIcon.HeavyThunderstormWithRain
&& icon != WeatherIcon.Thunderstorm
&& icon != WeatherIcon.HeavyThunderstorm
) {
return
}
val isHeavy = icon == WeatherIcon.HeavyThunderstormWithRain || icon == WeatherIcon.HeavyThunderstorm
Icon(
modifier = Modifier
.size(12.dp)
.offset(x = if (isHeavy) 4.dp else 1.dp, y = 6.dp),
imageVector = Icons.Rounded.Bolt,
contentDescription = null,
tint = colorResource(id = R.color.weather_lightning_bolt)
)
if (icon == WeatherIcon.HeavyThunderstorm || icon == WeatherIcon.HeavyThunderstormWithRain) {
Icon(
modifier = Modifier
.size(10.dp)
.offset(x = -3.dp, y = 6.dp),
imageVector = Icons.Rounded.Bolt,
contentDescription = null,
tint = colorResource(id = R.color.weather_lightning_bolt)
)
}
}
@@ -0,0 +1,538 @@
package de.mm20.launcher2.ui.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.group
val Icons.Rounded.Battery0Bar
get() = materialIcon("Icons.Rounded.Battery0Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(14f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.Battery1Bar
get() = materialIcon("Icons.Rounded.Battery1Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(12f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.Battery2Bar
get() = materialIcon("Icons.Rounded.Battery2Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(10f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.Battery3Bar
get() = materialIcon("Icons.Rounded.Battery3Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(8f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.Battery4Bar
get() = materialIcon("Icons.Rounded.Battery4Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(6f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.Battery5Bar
get() = materialIcon("Icons.Rounded.Battery5Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(4f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.Battery6Bar
get() = materialIcon("Icons.Rounded.Battery6Bar") {
materialPath {
moveTo(17f, 5f)
verticalLineToRelative(16f)
curveToRelative(0f, 0.55f, -0.45f, 1f, -1f, 1f)
horizontalLineTo(8f)
curveToRelative(-0.55f, 0f, -1f, -0.45f, -1f, -1f)
verticalLineTo(5f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
verticalLineTo(3f)
curveToRelative(0f, -0.55f, 0.45f, -1f, 1f, -1f)
horizontalLineToRelative(2f)
curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f)
verticalLineToRelative(1f)
horizontalLineToRelative(2f)
curveTo(16.55f, 4f, 17f, 4.45f, 17f, 5f)
close()
moveTo(15f, 6f)
horizontalLineTo(9f)
verticalLineToRelative(2f)
horizontalLineToRelative(6f)
verticalLineTo(6f)
close()
}
}
val Icons.Rounded.BatteryCharging0Bar
get() = materialIcon("Icons.Rounded.BatteryCharging0Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 75.589844f)
lineTo(34.015625f, 75.589844f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(47.253906f, 32.126953f)
curveTo(46.605486f, 32.112843f, 45.950535f, 32.41843f, 45.582031f, 33.109375f)
lineTo(35.490234f, 52.005859f)
curveTo(34.809919f, 53.290899f, 35.716123f, 54.802734f, 37.152344f, 54.802734f)
lineTo(41.574219f, 54.802734f)
lineTo(41.574219f, 68.03125f)
curveTo(41.574219f, 69.996604f, 44.219867f, 70.640241f, 45.126953f, 68.939453f)
lineTo(55.21875f, 50.041016f)
curveTo(55.899065f, 48.755976f, 54.955066f, 47.244141f, 53.556641f, 47.244141f)
lineTo(49.134766f, 47.244141f)
lineTo(49.134766f, 34.015625f)
curveTo(49.134766f, 32.848696f, 48.201598f, 32.147575f, 47.253906f, 32.126953f)
close()
}
}
}
val Icons.Rounded.BatteryCharging1Bar
get() = materialIcon("Icons.Rounded.BatteryCharging1Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 68.03125f)
lineTo(45.611328f, 68.03125f)
lineTo(45.126953f, 68.939453f)
curveTo(44.219867f, 70.640241f, 41.574219f, 69.996604f, 41.574219f, 68.03125f)
lineTo(34.015625f, 68.03125f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(41.574219f, 68.03125f)
lineTo(45.611328f, 68.03125f)
lineTo(55.21875f, 50.041016f)
curveTo(55.899065f, 48.755976f, 54.955066f, 47.244141f, 53.556641f, 47.244141f)
lineTo(49.134766f, 47.244141f)
lineTo(49.134766f, 34.015625f)
curveTo(49.134766f, 32.050271f, 46.489118f, 31.408588f, 45.582031f, 33.109375f)
lineTo(35.490234f, 52.005859f)
curveTo(34.809919f, 53.290899f, 35.716123f, 54.802734f, 37.152344f, 54.802734f)
lineTo(41.574219f, 54.802734f)
lineTo(41.574219f, 68.03125f)
close()
}
}
}
val Icons.Rounded.BatteryCharging2Bar
get() = materialIcon("Icons.Rounded.BatteryCharging2Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 60.472656f)
lineTo(49.648438f, 60.472656f)
lineTo(45.126953f, 68.939453f)
curveTo(44.219867f, 70.640241f, 41.574219f, 69.996604f, 41.574219f, 68.03125f)
lineTo(41.574219f, 60.472656f)
lineTo(34.015625f, 60.472656f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(41.574219f, 60.472656f)
lineTo(49.648438f, 60.472656f)
lineTo(55.21875f, 50.041016f)
curveTo(55.899065f, 48.755976f, 54.955066f, 47.244141f, 53.556641f, 47.244141f)
lineTo(49.134766f, 47.244141f)
lineTo(49.134766f, 34.015625f)
curveTo(49.134766f, 32.050271f, 46.489118f, 31.408588f, 45.582031f, 33.109375f)
lineTo(35.490234f, 52.005859f)
curveTo(34.809919f, 53.290899f, 35.716123f, 54.802734f, 37.152344f, 54.802734f)
lineTo(41.574219f, 54.802734f)
lineTo(41.574219f, 60.472656f)
close()
}
}
}
val Icons.Rounded.BatteryCharging3Bar
get() = materialIcon("Icons.Rounded.BatteryCharging3Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 52.914062f)
lineTo(53.683594f, 52.914062f)
lineTo(45.126953f, 68.939453f)
curveTo(44.219867f, 70.640241f, 41.574219f, 69.996604f, 41.574219f, 68.03125f)
lineTo(41.574219f, 54.802734f)
lineTo(37.152344f, 54.802734f)
curveTo(36.048819f, 54.802734f, 35.258483f, 53.910469f, 35.259766f, 52.914062f)
lineTo(34.015625f, 52.914062f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(35.259766f, 52.914062f)
lineTo(53.683594f, 52.914062f)
lineTo(55.21875f, 50.041016f)
curveTo(55.899065f, 48.755976f, 54.955066f, 47.244141f, 53.556641f, 47.244141f)
lineTo(49.134766f, 47.244141f)
lineTo(49.134766f, 34.015625f)
curveTo(49.134766f, 32.050271f, 46.489118f, 31.408588f, 45.582031f, 33.109375f)
lineTo(35.490234f, 52.005859f)
curveTo(35.332642f, 52.303535f, 35.260152f, 52.613661f, 35.259766f, 52.914062f)
close()
}
}
}
val Icons.Rounded.BatteryCharging4Bar
get() = materialIcon("Icons.Rounded.BatteryCharging4Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 45.353516f)
lineTo(49.134766f, 45.353516f)
lineTo(49.134766f, 47.244141f)
lineTo(53.556641f, 47.244141f)
curveTo(54.955066f, 47.244141f, 55.899065f, 48.755976f, 55.21875f, 50.041016f)
lineTo(45.126953f, 68.939453f)
curveTo(44.219867f, 70.640241f, 41.574219f, 69.996604f, 41.574219f, 68.03125f)
lineTo(41.574219f, 54.802734f)
lineTo(37.152344f, 54.802734f)
curveTo(35.716123f, 54.802734f, 34.809919f, 53.290899f, 35.490234f, 52.005859f)
lineTo(39.042969f, 45.353516f)
lineTo(34.015625f, 45.353516f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(39.042969f, 45.353516f)
lineTo(49.134766f, 45.353516f)
lineTo(49.134766f, 34.015625f)
curveTo(49.134766f, 32.050271f, 46.489118f, 31.408588f, 45.582031f, 33.109375f)
lineTo(39.042969f, 45.353516f)
close()
}
}
}
val Icons.Rounded.BatteryCharging5Bar
get() = materialIcon("Icons.Rounded.BatteryCharging5Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 37.794922f)
lineTo(49.134766f, 37.794922f)
lineTo(49.134766f, 47.244141f)
lineTo(53.556641f, 47.244141f)
curveTo(54.955066f, 47.244141f, 55.899065f, 48.755976f, 55.21875f, 50.041016f)
lineTo(45.126953f, 68.939453f)
curveTo(44.219867f, 70.640241f, 41.574219f, 69.996604f, 41.574219f, 68.03125f)
lineTo(41.574219f, 54.802734f)
lineTo(37.152344f, 54.802734f)
curveTo(35.716123f, 54.802734f, 34.809919f, 53.290899f, 35.490234f, 52.005859f)
lineTo(43.080078f, 37.794922f)
lineTo(34.015625f, 37.794922f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(43.080078f, 37.794922f)
lineTo(49.134766f, 37.794922f)
lineTo(49.134766f, 34.015625f)
curveTo(49.134766f, 32.050271f, 46.489118f, 31.408588f, 45.582031f, 33.109375f)
lineTo(43.080078f, 37.794922f)
close()
}
}
}
val Icons.Rounded.BatteryCharging6Bar
get() = materialIcon("Icons.Rounded.BatteryCharging6Bar") {
group(
scaleX = 0.26458333f,
scaleY = 0.26458333f,
) {
materialPath {
moveTo(41.574219f, 7.5585938f)
curveTo(39.495479f, 7.5585937f, 37.794922f, 9.2591505f, 37.794922f, 11.337891f)
lineTo(37.794922f, 15.117188f)
lineTo(30.236328f, 15.117188f)
curveTo(28.157588f, 15.117187f, 26.457031f, 16.819697f, 26.457031f, 18.898438f)
lineTo(26.457031f, 79.369141f)
curveTo(26.457031f, 81.447881f, 28.157588f, 83.150391f, 30.236328f, 83.150391f)
lineTo(60.472656f, 83.150391f)
curveTo(62.551396f, 83.150391f, 64.251953f, 81.447881f, 64.251953f, 79.369141f)
lineTo(64.251953f, 18.898438f)
curveTo(64.251953f, 16.819697f, 62.551396f, 15.117188f, 60.472656f, 15.117188f)
lineTo(52.914062f, 15.117188f)
lineTo(52.914062f, 11.337891f)
curveTo(52.914063f, 9.2591505f, 51.213506f, 7.5585938f, 49.134766f, 7.5585938f)
lineTo(41.574219f, 7.5585938f)
close()
moveTo(34.015625f, 22.677734f)
lineTo(56.693359f, 22.677734f)
lineTo(56.693359f, 30.236328f)
lineTo(34.015625f, 30.236328f)
lineTo(34.015625f, 22.677734f)
close()
moveTo(47.253906f, 32.126953f)
curveTo(48.201598f, 32.147575f, 49.134766f, 32.848696f, 49.134766f, 34.015625f)
lineTo(49.134766f, 47.244141f)
lineTo(53.556641f, 47.244141f)
curveTo(54.955066f, 47.244141f, 55.899065f, 48.755976f, 55.21875f, 50.041016f)
lineTo(45.126953f, 68.939453f)
curveTo(44.219867f, 70.640241f, 41.574219f, 69.996604f, 41.574219f, 68.03125f)
lineTo(41.574219f, 54.802734f)
lineTo(37.152344f, 54.802734f)
curveTo(35.716123f, 54.802734f, 34.809919f, 53.290899f, 35.490234f, 52.005859f)
lineTo(45.582031f, 33.109375f)
curveTo(45.950535f, 32.41843f, 46.605486f, 32.112843f, 47.253906f, 32.126953f)
close()
}
}
}
@@ -0,0 +1,859 @@
package de.mm20.launcher2.ui.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
val Icons.Rounded.Pdf: ImageVector
get() = materialIcon("Icons.Rounded.Pdf") {
materialPath {
moveTo(11.129145f, 3.328324f)
curveTo(8.6213132f, 3.3999763f, 9.5656688f, 7.7995412f, 9.9955824f, 9.1752658f)
curveTo(9.9669215f, 9.2469181f, 9.5802228f, 11.696644f, 7.000739f, 15.89547f)
curveTo(7.000739f, 15.89547f, 1.985188f, 18.489173f, 3.1746167f, 20.438116f)
curveTo(4.1347578f, 21.957146f, 6.4999564f, 20.380906f, 8.5205521f, 16.611993f)
curveTo(8.5205521f, 16.611993f, 11.126568f, 15.694396f, 14.580212f, 15.436448f)
curveTo(14.580212f, 15.436448f, 20.113003f, 17.930845f, 20.858187f, 15.279709f)
curveTo(21.603372f, 12.642903f, 16.487507f, 13.216122f, 15.599018f, 13.488401f)
curveTo(15.599018f, 13.488401f, 12.675603f, 11.569238f, 12.016402f, 8.9037707f)
curveTo(12.016402f, 8.9037707f, 13.636976f, 3.2566717f, 11.129145f, 3.328324f)
close()
moveTo(11.157134f, 4.2015868f)
curveTo(11.93098f, 4.2875695f, 11.157134f, 7.2115443f, 11.157134f, 7.2831966f)
curveTo(11.142804f, 7.2115443f, 10.626907f, 4.1442649f, 11.157134f, 4.2015868f)
moveTo(11.157134f, 10.681084f)
curveTo(11.200126f, 10.681084f, 11.828763f, 12.39962f, 13.863687f, 14.190928f)
curveTo(13.863687f, 14.190928f, 10.526593f, 14.850019f, 9.007564f, 15.50922f)
curveTo(9.007564f, 15.50922f, 10.440611f, 13.00262f, 11.157134f, 10.681084f)
close()
moveTo(17.642229f, 14.515604f)
curveTo(18.690144f, 14.571358f, 20.077401f, 14.907787f, 20.012913f, 15.251718f)
curveTo(19.89827f, 15.724625f, 16.774564f, 14.563186f, 16.774564f, 14.563186f)
curveTo(16.982356f, 14.509446f, 17.292924f, 14.497019f, 17.642229f, 14.515604f)
close()
moveTo(6.6564719f, 17.227756f)
curveTo(5.8969573f, 18.961742f, 4.6069915f, 20.066196f, 4.2773909f, 20.051866f)
curveTo(3.9477902f, 20.037535f, 5.2807473f, 17.757984f, 6.6564719f, 17.227756f)
close()
}
}
val Icons.Rounded.WeatherCloud
get() = materialIcon("Icons.Rounded.WeatherCloud") {
materialPath {
moveTo(15.240234f, 6.953125f)
arcTo(4.2818656f, 4.2818656f, 0f, false, false, 11.722656f, 8.7949219f)
arcTo(3.8614116f, 3.8614116f, 0f, false, false, 9.2871094f, 7.9296875f)
arcTo(3.8614116f, 3.8614116f, 0f, false, false, 5.4257812f, 11.791016f)
arcTo(3.8614116f, 3.8614116f, 0f, false, false, 5.4335938f, 12.035156f)
arcTo(3.0096498f, 3.0096498f, 0f, false, false, 4.8671875f, 11.980469f)
arcTo(3.0096498f, 3.0096498f, 0f, false, false, 1.8574219f, 14.990234f)
arcTo(3.0096498f, 3.0096498f, 0f, false, false, 4.8671875f, 18f)
arcTo(3.0096498f, 3.0096498f, 0f, false, false, 4.9316406f, 18f)
lineTo(19.220703f, 18f)
lineTo(19.220703f, 17.998047f)
arcTo(2.5873528f, 2.5873528f, 0f, false, false, 21.742188f, 15.412109f)
arcTo(2.5873528f, 2.5873528f, 0f, false, false, 19.214844f, 12.826172f)
arcTo(4.2818656f, 4.2818656f, 0f, false, false, 19.523438f, 11.234375f)
arcTo(4.2818656f, 4.2818656f, 0f, false, false, 15.240234f, 6.953125f)
close()
}
}
val Icons.Rounded.WeatherLightRain
get() = materialIcon("Icons.Rounded.WeatherLightRain") {
materialPath {
moveTo(7.7747158f, 16.298471f)
curveTo(7.7747158f, 16.298471f, 6.3149844f, 17.454229f, 6.1717047f, 18.266809f)
arcTo(1.237673f, 1.237673f, 0f, false, false, 7.1769077f, 19.699334f)
arcTo(1.237673f, 1.237673f, 0f, false, false, 8.6094328f, 18.696345f)
curveTo(8.7527126f, 17.883765f, 7.7747158f, 16.298471f, 7.7747158f, 16.298471f)
close()
moveTo(12.3092f, 16.298471f)
curveTo(12.3092f, 16.298471f, 10.849468f, 17.454229f, 10.706189f, 18.266809f)
arcTo(1.237673f, 1.237673f, 0f, false, false, 11.711392f, 19.699334f)
arcTo(1.237673f, 1.237673f, 0f, false, false, 13.143917f, 18.696345f)
curveTo(13.287198f, 17.883765f, 12.3092f, 16.298471f, 12.3092f, 16.298471f)
close()
moveTo(16.843685f, 16.298471f)
curveTo(16.843685f, 16.298471f, 15.383952f, 17.454229f, 15.240673f, 18.266809f)
arcTo(1.237673f, 1.237673f, 0f, false, false, 16.245876f, 19.699334f)
arcTo(1.237673f, 1.237673f, 0f, false, false, 17.678401f, 18.696345f)
curveTo(17.821682f, 17.883765f, 16.843685f, 16.298471f, 16.843685f, 16.298471f)
close()
}
}
val Icons.Rounded.WeatherHail
get() = materialIcon("Icons.Rounded.WeatherHail") {
materialPath {
moveTo(7.4093667f, 16.258438f)
curveTo(6.6975174f, 16.235656f, 6.09006f, 16.947847f, 6.2243877f, 17.647106f)
curveTo(6.3138584f, 18.353739f, 7.1127111f, 18.841403f, 7.7821731f, 18.599057f)
curveTo(8.4660776f, 18.399808f, 8.8227568f, 17.533933f, 8.4775745f, 16.910825f)
curveTo(8.2792236f, 16.515419f, 7.8517895f, 16.254332f, 7.4093667f, 16.258438f)
close()
moveTo(11.935705f, 16.258438f)
curveTo(11.223856f, 16.235656f, 10.616399f, 16.947847f, 10.750726f, 17.647106f)
curveTo(10.840183f, 18.35367f, 11.639079f, 18.841563f, 12.308486f, 18.598897f)
curveTo(12.992113f, 18.399396f, 13.348471f, 17.533766f, 13.003396f, 16.910825f)
curveTo(12.805121f, 16.51558f, 12.377952f, 16.254528f, 11.935705f, 16.258438f)
close()
moveTo(16.462044f, 16.258438f)
curveTo(15.750195f, 16.235656f, 15.142737f, 16.947847f, 15.277065f, 17.647106f)
curveTo(15.366522f, 18.35367f, 16.165418f, 18.841563f, 16.834825f, 18.598897f)
curveTo(17.518452f, 18.399396f, 17.87481f, 17.533766f, 17.529735f, 16.910825f)
curveTo(17.331459f, 16.51558f, 16.904291f, 16.254528f, 16.462044f, 16.258438f)
close()
}
}
val Icons.Rounded.WeatherSleetSnow
get() = materialIcon("Icons.Rounded.WeatherSleetSnow") {
materialPath {
moveTo(11.935705f, 16.258438f)
curveTo(11.223856f, 16.235656f, 10.616399f, 16.947847f, 10.750726f, 17.647106f)
curveTo(10.840183f, 18.35367f, 11.639079f, 18.841563f, 12.308486f, 18.598897f)
curveTo(12.992113f, 18.399396f, 13.348471f, 17.533766f, 13.003396f, 16.910825f)
curveTo(12.805121f, 16.51558f, 12.377952f, 16.254528f, 11.935705f, 16.258438f)
close()
}
}
val Icons.Rounded.WeatherSleetRain
get() = materialIcon("Icons.Rounded.WeatherSleetRain") {
materialPath {
moveTo(6.347656f, 15.96875f)
curveTo(5.938469f, 15.896599f, 5.550666f, 16.166984f, 5.478515f, 16.576172f)
lineTo(5.044922f, 19.039062f)
curveTo(4.972771f, 19.44825f, 5.243156f, 19.836052f, 5.652344f, 19.908203f)
curveTo(6.061531f, 19.980354f, 6.449333f, 19.708016f, 6.521484f, 19.298828f)
lineTo(6.955078f, 16.837891f)
curveTo(7.0272288f, 16.428703f, 6.7568437f, 16.040901f, 6.347656f, 15.96875f)
close()
moveTo(18.347656f, 15.96875f)
curveTo(17.938469f, 15.896599f, 17.550666f, 16.166984f, 17.478516f, 16.576172f)
lineTo(17.044922f, 19.039062f)
curveTo(16.972771f, 19.44825f, 17.243156f, 19.836052f, 17.652344f, 19.908203f)
curveTo(18.061531f, 19.980354f, 18.449333f, 19.708016f, 18.521484f, 19.298828f)
lineTo(18.955078f, 16.837891f)
curveTo(19.027229f, 16.428703f, 18.756844f, 16.040901f, 18.347656f, 15.96875f)
close()
}
}
val Icons.Rounded.WeatherRain
get() = materialIcon("Icons.Rounded.WeatherRain") {
materialPath {
moveTo(6.347656f, 15.96875f)
curveTo(5.938469f, 15.896599f, 5.550666f, 16.166984f, 5.478515f, 16.576172f)
lineTo(5.044922f, 19.039062f)
curveTo(4.972771f, 19.44825f, 5.243156f, 19.836052f, 5.652344f, 19.908203f)
curveTo(6.061531f, 19.980354f, 6.449333f, 19.708016f, 6.521484f, 19.298828f)
lineTo(6.955078f, 16.837891f)
curveTo(7.0272288f, 16.428703f, 6.7568437f, 16.040901f, 6.347656f, 15.96875f)
close()
moveTo(9.3476561f, 15.96875f)
curveTo(8.9384685f, 15.896599f, 8.5506663f, 16.166984f, 8.4785155f, 16.576172f)
lineTo(8.0449218f, 19.039062f)
curveTo(7.9727709f, 19.44825f, 8.243156f, 19.836052f, 8.6523436f, 19.908203f)
curveTo(9.0615312f, 19.980354f, 9.4493334f, 19.708016f, 9.5214842f, 19.298828f)
lineTo(9.955078f, 16.837891f)
curveTo(10.027229f, 16.428703f, 9.7568437f, 16.040901f, 9.3476561f, 15.96875f)
close()
moveTo(12.347656f, 15.96875f)
curveTo(11.938469f, 15.896599f, 11.550666f, 16.166984f, 11.478516f, 16.576172f)
lineTo(11.044922f, 19.039062f)
curveTo(10.972771f, 19.44825f, 11.243156f, 19.836052f, 11.652344f, 19.908203f)
curveTo(12.061531f, 19.980354f, 12.449333f, 19.708016f, 12.521484f, 19.298828f)
lineTo(12.955078f, 16.837891f)
curveTo(13.027229f, 16.428703f, 12.756844f, 16.040901f, 12.347656f, 15.96875f)
close()
moveTo(15.347656f, 15.96875f)
curveTo(14.938469f, 15.896599f, 14.550666f, 16.166984f, 14.478516f, 16.576172f)
lineTo(14.044922f, 19.039062f)
curveTo(13.972771f, 19.44825f, 14.243156f, 19.836052f, 14.652344f, 19.908203f)
curveTo(15.061531f, 19.980354f, 15.449333f, 19.708016f, 15.521484f, 19.298828f)
lineTo(15.955078f, 16.837891f)
curveTo(16.027229f, 16.428703f, 15.756844f, 16.040901f, 15.347656f, 15.96875f)
close()
moveTo(18.347656f, 15.96875f)
curveTo(17.938469f, 15.896599f, 17.550666f, 16.166984f, 17.478516f, 16.576172f)
lineTo(17.044922f, 19.039062f)
curveTo(16.972771f, 19.44825f, 17.243156f, 19.836052f, 17.652344f, 19.908203f)
curveTo(18.061531f, 19.980354f, 18.449333f, 19.708016f, 18.521484f, 19.298828f)
lineTo(18.955078f, 16.837891f)
curveTo(19.027229f, 16.428703f, 18.756844f, 16.040901f, 18.347656f, 15.96875f)
close()
}
}
val Icons.Rounded.WeatherLightRainAnimatable
get() = materialIcon("Icons.Rounded.WeatherLightRainAnimatable") {
materialPath {
moveTo(4.828129f, 0f)
curveTo(4.828129f, 0f, 3.6375041f, 1.3394488f, 3.6375041f, 2.1332031f)
curveTo(3.6375041f, 2.7907671f, 4.1705651f, 3.3238281f, 4.828129f, 3.3238281f)
curveTo(5.485693f, 3.3238281f, 6.018754f, 2.7907671f, 6.018754f, 2.1332031f)
curveTo(6.018754f, 1.3394488f, 4.828129f, 0f, 4.828129f, 0f)
close()
moveTo(12.765629f, 0f)
curveTo(12.765629f, 0f, 11.575004f, 1.3394488f, 11.575004f, 2.1332031f)
curveTo(11.575004f, 2.7907671f, 12.108065f, 3.3238281f, 12.765629f, 3.3238281f)
curveTo(13.423193f, 3.3238281f, 13.956254f, 2.7907671f, 13.956254f, 2.1332031f)
curveTo(13.956254f, 1.3394488f, 12.765629f, 0f, 12.765629f, 0f)
close()
moveTo(20.703129f, 0f)
curveTo(20.703129f, 0f, 19.512504f, 1.3394488f, 19.512504f, 2.1332031f)
curveTo(19.512504f, 2.7907671f, 20.045565f, 3.3238281f, 20.703129f, 3.3238281f)
curveTo(21.360693f, 3.3238281f, 21.893754f, 2.7907671f, 21.893754f, 2.1332031f)
curveTo(21.893754f, 1.3394488f, 20.703129f, 0f, 20.703129f, 0f)
close()
moveTo(8.796879f, 3.96875f)
curveTo(8.796879f, 3.96875f, 7.606254f, 5.3081983f, 7.606254f, 6.101953f)
curveTo(7.606254f, 6.759517f, 8.1393151f, 7.292578f, 8.796879f, 7.292578f)
curveTo(9.4544429f, 7.292578f, 9.987504f, 6.759517f, 9.987504f, 6.101953f)
curveTo(9.987504f, 5.3081983f, 8.796879f, 3.96875f, 8.796879f, 3.96875f)
close()
moveTo(16.734379f, 3.96875f)
curveTo(16.734379f, 3.96875f, 16.659965f, 4.0524664f, 16.548344f, 4.1904419f)
curveTo(16.21348f, 4.6043688f, 15.543754f, 5.5066369f, 15.543754f, 6.101953f)
curveTo(15.54381f, 6.7411813f, 16.048624f, 7.2662972f, 16.687353f, 7.2915446f)
curveTo(16.70302f, 7.2921986f, 16.718698f, 7.2925434f, 16.734379f, 7.292578f)
curveTo(17.391943f, 7.292578f, 17.925004f, 6.759517f, 17.925004f, 6.101953f)
curveTo(17.925004f, 5.3081983f, 16.734379f, 3.96875f, 16.734379f, 3.96875f)
close()
moveTo(4.828129f, 7.9374999f)
curveTo(4.828129f, 7.9374999f, 3.6375041f, 9.2769485f, 3.6375041f, 10.070703f)
curveTo(3.6375041f, 10.728267f, 4.1705651f, 11.261328f, 4.828129f, 11.261328f)
curveTo(5.485693f, 11.261328f, 6.018754f, 10.728267f, 6.018754f, 10.070703f)
curveTo(6.018754f, 9.2769485f, 4.828129f, 7.9374999f, 4.828129f, 7.9374999f)
close()
moveTo(12.765629f, 7.9374999f)
curveTo(12.765629f, 7.9374999f, 12.691215f, 8.0212162f, 12.579594f, 8.1591919f)
curveTo(12.24473f, 8.5731188f, 11.575004f, 9.4753868f, 11.575004f, 10.070703f)
curveTo(11.57496f, 10.567853f, 11.883816f, 11.012697f, 12.349634f, 11.186397f)
curveTo(12.364344f, 11.191857f, 12.379161f, 11.197026f, 12.394075f, 11.2019f)
curveTo(12.499019f, 11.236323f, 12.608242f, 11.255976f, 12.718603f, 11.260295f)
curveTo(12.73427f, 11.260949f, 12.749948f, 11.261293f, 12.765629f, 11.261328f)
curveTo(13.423193f, 11.261328f, 13.956254f, 10.728267f, 13.956254f, 10.070703f)
curveTo(13.956254f, 9.2769485f, 12.765629f, 7.9374999f, 12.765629f, 7.9374999f)
close()
moveTo(20.703129f, 7.9374999f)
curveTo(20.703129f, 7.9374999f, 20.628715f, 8.0212162f, 20.517094f, 8.1591919f)
curveTo(20.18223f, 8.5731188f, 19.512504f, 9.4753868f, 19.512504f, 10.070703f)
curveTo(19.51256f, 10.709931f, 20.017374f, 11.235047f, 20.656103f, 11.260295f)
curveTo(20.67177f, 11.260949f, 20.687448f, 11.261293f, 20.703129f, 11.261328f)
curveTo(21.360693f, 11.261328f, 21.893754f, 10.728267f, 21.893754f, 10.070703f)
curveTo(21.893754f, 9.2769485f, 20.703129f, 7.9374999f, 20.703129f, 7.9374999f)
close()
moveTo(8.796879f, 11.90625f)
curveTo(8.796879f, 11.90625f, 7.606254f, 13.245698f, 7.606254f, 14.039453f)
curveTo(7.606254f, 14.697017f, 8.1393151f, 15.230078f, 8.796879f, 15.230078f)
curveTo(9.4544429f, 15.230078f, 9.987504f, 14.697017f, 9.987504f, 14.039453f)
curveTo(9.987504f, 13.245698f, 8.796879f, 11.90625f, 8.796879f, 11.90625f)
close()
moveTo(16.734379f, 11.90625f)
curveTo(16.734379f, 11.90625f, 15.543754f, 13.245698f, 15.543754f, 14.039453f)
curveTo(15.543754f, 14.697017f, 16.076815f, 15.230078f, 16.734379f, 15.230078f)
curveTo(17.391943f, 15.230078f, 17.925004f, 14.697017f, 17.925004f, 14.039453f)
curveTo(17.925004f, 13.245698f, 16.734379f, 11.90625f, 16.734379f, 11.90625f)
close()
moveTo(4.828129f, 15.875f)
curveTo(4.828129f, 15.875f, 3.6375041f, 17.214448f, 3.6375041f, 18.008203f)
curveTo(3.6375041f, 18.665767f, 4.1705651f, 19.198828f, 4.828129f, 19.198828f)
curveTo(5.485693f, 19.198828f, 6.018754f, 18.665767f, 6.018754f, 18.008203f)
curveTo(6.018754f, 17.214448f, 4.828129f, 15.875f, 4.828129f, 15.875f)
close()
moveTo(12.765629f, 15.875f)
curveTo(12.765629f, 15.875f, 11.575004f, 17.214448f, 11.575004f, 18.008203f)
curveTo(11.575004f, 18.665767f, 12.108065f, 19.198828f, 12.765629f, 19.198828f)
curveTo(13.423193f, 19.198828f, 13.956254f, 18.665767f, 13.956254f, 18.008203f)
curveTo(13.956254f, 17.214448f, 12.765629f, 15.875f, 12.765629f, 15.875f)
close()
moveTo(20.703129f, 15.875f)
curveTo(20.703129f, 15.875f, 19.512504f, 17.214448f, 19.512504f, 18.008203f)
curveTo(19.512504f, 18.665767f, 20.045565f, 19.198828f, 20.703129f, 19.198828f)
curveTo(21.360693f, 19.198828f, 21.893754f, 18.665767f, 21.893754f, 18.008203f)
curveTo(21.893754f, 17.214448f, 20.703129f, 15.875f, 20.703129f, 15.875f)
close()
}
}
val Icons.Rounded.WeatherHailAnimatable
get() = materialIcon("Icons.Rounded.WeatherHailAnimatable") {
materialPath {
moveTo(4.8281292f, 1.0583334f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 3.5971949f, 2.2892661f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 4.8281292f, 3.5201987f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 6.0585449f, 2.2892661f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 4.8281292f, 1.0583334f)
close()
moveTo(12.765629f, 1.0583334f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 11.534695f, 2.2892661f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 12.765629f, 3.5201987f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 13.996045f, 2.2892661f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 12.765629f, 1.0583334f)
close()
moveTo(20.703129f, 1.0583334f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 19.472195f, 2.2892661f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 20.703129f, 3.5201987f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 21.933545f, 2.2892661f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 20.703129f, 1.0583334f)
close()
moveTo(8.7968792f, 5.0270833f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 7.5659449f, 6.258016f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 8.7968792f, 7.4889487f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 10.027295f, 6.258016f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 8.7968792f, 5.0270833f)
close()
moveTo(16.734379f, 5.0270833f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 15.503445f, 6.258016f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 16.734379f, 7.4889487f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 17.964795f, 6.258016f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 16.734379f, 5.0270833f)
close()
moveTo(4.8281292f, 8.9958333f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 3.5971949f, 10.226766f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 4.8281292f, 11.457699f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 6.0585449f, 10.226766f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 4.8281292f, 8.9958333f)
close()
moveTo(12.765629f, 8.9958333f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 11.534695f, 10.226766f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 12.765629f, 11.457699f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 13.996045f, 10.226766f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 12.765629f, 8.9958333f)
close()
moveTo(20.703129f, 8.9958333f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 19.472195f, 10.226766f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 20.703129f, 11.457699f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 21.933545f, 10.226766f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 20.703129f, 8.9958333f)
close()
moveTo(8.7968792f, 12.964583f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 7.5659449f, 14.195516f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 8.7968792f, 15.426449f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 10.027295f, 14.195516f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 8.7968792f, 12.964583f)
close()
moveTo(16.734379f, 12.964583f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 15.503445f, 14.195516f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 16.734379f, 15.426449f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 17.964795f, 14.195516f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 16.734379f, 12.964583f)
close()
moveTo(4.8281292f, 16.933333f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 3.5971949f, 18.164266f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 4.8281292f, 19.395199f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 6.0585449f, 18.164266f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 4.8281292f, 16.933333f)
close()
moveTo(12.765629f, 16.933333f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 11.534695f, 18.164266f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 12.765629f, 19.395199f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 13.996045f, 18.164266f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 12.765629f, 16.933333f)
close()
moveTo(20.703129f, 16.933333f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 19.472195f, 18.164266f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 20.703129f, 19.395199f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 21.933545f, 18.164266f)
arcTo(1.2307138f, 1.2307138f, 0f, false, false, 20.703129f, 16.933333f)
close()
}
}
val Icons.Rounded.WeatherRainAnimatable
get() = materialIcon("Icons.Rounded.WeatherRainAnimatable") {
materialPath {
moveTo(4.828129f, 0.1133928f)
curveTo(4.41263f, 0.1133928f, 4.0777866f, 0.4477181f, 4.0777866f, 0.8632184f)
verticalLineTo(4.8634909f)
curveTo(4.0777866f, 5.278991f, 4.41263f, 5.6133161f, 4.828129f, 5.6133161f)
curveTo(5.243628f, 5.6133161f, 5.5779529f, 5.278991f, 5.5779529f, 4.8634909f)
verticalLineTo(0.8632184f)
curveTo(5.5779529f, 0.4477181f, 5.243628f, 0.1133928f, 4.828129f, 0.1133928f)
close()
moveTo(12.765629f, 0.1133928f)
curveTo(12.35013f, 0.1133928f, 12.015286f, 0.4477181f, 12.015286f, 0.8632184f)
verticalLineTo(4.8634909f)
curveTo(12.015286f, 5.278991f, 12.35013f, 5.6133161f, 12.765629f, 5.6133161f)
curveTo(13.181128f, 5.6133161f, 13.515453f, 5.278991f, 13.515453f, 4.8634909f)
verticalLineTo(0.8632184f)
curveTo(13.515453f, 0.4477181f, 13.181128f, 0.1133928f, 12.765629f, 0.1133928f)
close()
moveTo(20.703129f, 0.1133928f)
curveTo(20.28763f, 0.1133928f, 19.952786f, 0.4477181f, 19.952786f, 0.8632184f)
verticalLineTo(4.8634909f)
curveTo(19.952786f, 5.278991f, 20.28763f, 5.6133161f, 20.703129f, 5.6133161f)
curveTo(21.118628f, 5.6133161f, 21.452953f, 5.278991f, 21.452953f, 4.8634909f)
verticalLineTo(0.8632184f)
curveTo(21.452953f, 0.4477181f, 21.118628f, 0.1133928f, 20.703129f, 0.1133928f)
close()
moveTo(8.796879f, 4.0821433f)
curveTo(8.3813799f, 4.0821433f, 8.0465365f, 4.4164684f, 8.0465365f, 4.8319684f)
verticalLineTo(8.8322408f)
curveTo(8.0465365f, 9.247741f, 8.3813799f, 9.582066f, 8.796879f, 9.582066f)
curveTo(9.212378f, 9.582066f, 9.5467028f, 9.247741f, 9.5467028f, 8.8322408f)
verticalLineTo(4.8319684f)
curveTo(9.5467028f, 4.4164684f, 9.212378f, 4.0821433f, 8.796879f, 4.0821433f)
close()
moveTo(16.734379f, 4.0821433f)
curveTo(16.31888f, 4.0821433f, 15.984036f, 4.4164684f, 15.984036f, 4.8319684f)
verticalLineTo(8.8322408f)
curveTo(15.984036f, 9.247741f, 16.31888f, 9.582066f, 16.734379f, 9.582066f)
curveTo(17.149878f, 9.582066f, 17.484203f, 9.247741f, 17.484203f, 8.8322408f)
verticalLineTo(4.8319684f)
curveTo(17.484203f, 4.4164684f, 17.149878f, 4.0821433f, 16.734379f, 4.0821433f)
close()
moveTo(4.828129f, 8.0508932f)
curveTo(4.41263f, 8.0508932f, 4.0777866f, 8.3852183f, 4.0777866f, 8.8007184f)
verticalLineTo(12.800991f)
curveTo(4.0777866f, 13.216491f, 4.41263f, 13.550816f, 4.828129f, 13.550816f)
curveTo(5.243628f, 13.550816f, 5.5779529f, 13.216491f, 5.5779529f, 12.800991f)
verticalLineTo(8.8007184f)
curveTo(5.5779529f, 8.3852183f, 5.243628f, 8.0508932f, 4.828129f, 8.0508932f)
close()
moveTo(12.765629f, 8.0508932f)
curveTo(12.35013f, 8.0508932f, 12.015286f, 8.3852183f, 12.015286f, 8.8007184f)
verticalLineTo(12.800991f)
curveTo(12.015286f, 13.216491f, 12.35013f, 13.550816f, 12.765629f, 13.550816f)
curveTo(13.181128f, 13.550816f, 13.515453f, 13.216491f, 13.515453f, 12.800991f)
verticalLineTo(8.8007184f)
curveTo(13.515453f, 8.3852183f, 13.181128f, 8.0508932f, 12.765629f, 8.0508932f)
close()
moveTo(20.703129f, 8.0508932f)
curveTo(20.28763f, 8.0508932f, 19.952786f, 8.3852183f, 19.952786f, 8.8007184f)
verticalLineTo(12.800991f)
curveTo(19.952786f, 13.216491f, 20.28763f, 13.550816f, 20.703129f, 13.550816f)
curveTo(21.118628f, 13.550816f, 21.452953f, 13.216491f, 21.452953f, 12.800991f)
verticalLineTo(8.8007184f)
curveTo(21.452953f, 8.3852183f, 21.118628f, 8.0508932f, 20.703129f, 8.0508932f)
close()
moveTo(8.796879f, 12.019643f)
curveTo(8.3813799f, 12.019643f, 8.0465365f, 12.353968f, 8.0465365f, 12.769468f)
verticalLineTo(16.769741f)
curveTo(8.0465365f, 17.185241f, 8.3813799f, 17.519566f, 8.796879f, 17.519566f)
curveTo(9.212378f, 17.519566f, 9.5467028f, 17.185241f, 9.5467028f, 16.769741f)
verticalLineTo(12.769468f)
curveTo(9.5467028f, 12.353968f, 9.212378f, 12.019643f, 8.796879f, 12.019643f)
close()
moveTo(16.734379f, 12.019643f)
curveTo(16.31888f, 12.019643f, 15.984036f, 12.353968f, 15.984036f, 12.769468f)
verticalLineTo(16.769741f)
curveTo(15.984036f, 17.185241f, 16.31888f, 17.519566f, 16.734379f, 17.519566f)
curveTo(17.149878f, 17.519566f, 17.484203f, 17.185241f, 17.484203f, 16.769741f)
verticalLineTo(12.769468f)
curveTo(17.484203f, 12.353968f, 17.149878f, 12.019643f, 16.734379f, 12.019643f)
close()
moveTo(4.828129f, 15.988393f)
curveTo(4.41263f, 15.988393f, 4.0777866f, 16.322718f, 4.0777866f, 16.738218f)
verticalLineTo(20.738491f)
curveTo(4.0777866f, 21.153991f, 4.41263f, 21.488316f, 4.828129f, 21.488316f)
curveTo(5.243628f, 21.488316f, 5.5779529f, 21.153991f, 5.5779529f, 20.738491f)
verticalLineTo(16.738218f)
curveTo(5.5779529f, 16.322718f, 5.243628f, 15.988393f, 4.828129f, 15.988393f)
close()
moveTo(12.765629f, 15.988393f)
curveTo(12.35013f, 15.988393f, 12.015286f, 16.322718f, 12.015286f, 16.738218f)
verticalLineTo(20.738491f)
curveTo(12.015286f, 21.153991f, 12.35013f, 21.488316f, 12.765629f, 21.488316f)
curveTo(13.181128f, 21.488316f, 13.515453f, 21.153991f, 13.515453f, 20.738491f)
verticalLineTo(16.738218f)
curveTo(13.515453f, 16.322718f, 13.181128f, 15.988393f, 12.765629f, 15.988393f)
close()
moveTo(20.703129f, 15.988393f)
curveTo(20.28763f, 15.988393f, 19.952786f, 16.322718f, 19.952786f, 16.738218f)
verticalLineTo(20.738491f)
curveTo(19.952786f, 21.153991f, 20.28763f, 21.488316f, 20.703129f, 21.488316f)
curveTo(21.118628f, 21.488316f, 21.452953f, 21.153991f, 21.452953f, 20.738491f)
verticalLineTo(16.738218f)
curveTo(21.452953f, 16.322718f, 21.118628f, 15.988393f, 20.703129f, 15.988393f)
close()
}
}
val Icons.Rounded.WeatherSleetRainAnimatable
get() = materialIcon("Icons.Rounded.WeatherSleetRainAnimatable") {
materialPath {
moveTo(4.8281293f, 0.11317139f)
curveTo(4.4126296f, 0.11317139f, 4.0777866f, 0.44749611f, 4.0777866f, 0.86299642f)
verticalLineTo(4.8632689f)
curveTo(4.0777866f, 5.278769f, 4.4126296f, 5.6130941f, 4.8281293f, 5.6130941f)
curveTo(5.2436283f, 5.6130941f, 5.5779543f, 5.278769f, 5.5779543f, 4.8632689f)
verticalLineTo(0.86299642f)
curveTo(5.5779543f, 0.44749611f, 5.2436283f, 0.11317139f, 4.8281293f, 0.11317139f)
close()
moveTo(12.765629f, 0.11317139f)
curveTo(12.35013f, 0.11317139f, 12.015287f, 0.44749611f, 12.015287f, 0.86299642f)
verticalLineTo(4.8632689f)
curveTo(12.015287f, 5.278769f, 12.35013f, 5.6130941f, 12.765629f, 5.6130941f)
curveTo(13.181128f, 5.6130941f, 13.515454f, 5.278769f, 13.515454f, 4.8632689f)
verticalLineTo(0.86299642f)
curveTo(13.515454f, 0.44749611f, 13.181128f, 0.11317139f, 12.765629f, 0.11317139f)
close()
moveTo(20.703129f, 0.11317139f)
curveTo(20.287629f, 0.11317139f, 19.952787f, 0.44749611f, 19.952787f, 0.86299642f)
verticalLineTo(4.8632689f)
curveTo(19.952787f, 5.278769f, 20.287629f, 5.6130941f, 20.703129f, 5.6130941f)
curveTo(21.118628f, 5.6130941f, 21.452953f, 5.278769f, 21.452953f, 4.8632689f)
verticalLineTo(0.86299642f)
curveTo(21.452953f, 0.44749611f, 21.118628f, 0.11317139f, 20.703129f, 0.11317139f)
close()
moveTo(4.8281293f, 8.0506712f)
curveTo(4.4126296f, 8.0506712f, 4.0777866f, 8.3849963f, 4.0777866f, 8.8004964f)
verticalLineTo(12.800769f)
curveTo(4.0777866f, 13.216269f, 4.4126296f, 13.550594f, 4.8281293f, 13.550594f)
curveTo(5.2436283f, 13.550594f, 5.5779543f, 13.216269f, 5.5779543f, 12.800769f)
verticalLineTo(8.8004964f)
curveTo(5.5779543f, 8.3849963f, 5.2436283f, 8.0506712f, 4.8281293f, 8.0506712f)
close()
moveTo(12.765629f, 8.0506712f)
curveTo(12.35013f, 8.0506712f, 12.015287f, 8.3849963f, 12.015287f, 8.8004964f)
verticalLineTo(12.800769f)
curveTo(12.015287f, 13.216269f, 12.35013f, 13.550594f, 12.765629f, 13.550594f)
curveTo(13.181128f, 13.550594f, 13.515454f, 13.216269f, 13.515454f, 12.800769f)
verticalLineTo(8.8004964f)
curveTo(13.515454f, 8.3849963f, 13.181128f, 8.0506712f, 12.765629f, 8.0506712f)
close()
moveTo(20.703129f, 8.0506712f)
curveTo(20.287629f, 8.0506712f, 19.952787f, 8.3849963f, 19.952787f, 8.8004964f)
verticalLineTo(12.800769f)
curveTo(19.952787f, 13.216269f, 20.287629f, 13.550594f, 20.703129f, 13.550594f)
curveTo(21.118628f, 13.550594f, 21.452953f, 13.216269f, 21.452953f, 12.800769f)
verticalLineTo(8.8004964f)
curveTo(21.452953f, 8.3849963f, 21.118628f, 8.0506712f, 20.703129f, 8.0506712f)
close()
moveTo(4.8281293f, 15.988171f)
curveTo(4.4126296f, 15.988171f, 4.0777866f, 16.322496f, 4.0777866f, 16.737996f)
verticalLineTo(20.738269f)
curveTo(4.0777866f, 21.153769f, 4.4126296f, 21.488094f, 4.8281293f, 21.488094f)
curveTo(5.2436283f, 21.488094f, 5.5779543f, 21.153769f, 5.5779543f, 20.738269f)
verticalLineTo(16.737996f)
curveTo(5.5779543f, 16.322496f, 5.2436283f, 15.988171f, 4.8281293f, 15.988171f)
close()
moveTo(12.765629f, 15.988171f)
curveTo(12.35013f, 15.988171f, 12.015287f, 16.322496f, 12.015287f, 16.737996f)
verticalLineTo(20.738269f)
curveTo(12.015287f, 21.153769f, 12.35013f, 21.488094f, 12.765629f, 21.488094f)
curveTo(13.181128f, 21.488094f, 13.515454f, 21.153769f, 13.515454f, 20.738269f)
verticalLineTo(16.737996f)
curveTo(13.515454f, 16.322496f, 13.181128f, 15.988171f, 12.765629f, 15.988171f)
close()
moveTo(20.703129f, 15.988171f)
curveTo(20.287629f, 15.988171f, 19.952787f, 16.322496f, 19.952787f, 16.737996f)
verticalLineTo(20.738269f)
curveTo(19.952787f, 21.153769f, 20.287629f, 21.488094f, 20.703129f, 21.488094f)
curveTo(21.118628f, 21.488094f, 21.452953f, 21.153769f, 21.452953f, 20.738269f)
verticalLineTo(16.737996f)
curveTo(21.452953f, 16.322496f, 21.118628f, 15.988171f, 20.703129f, 15.988171f)
close()
}
}
val Icons.Rounded.WeatherSleetSnowAnimatable
get() = materialIcon("Icons.Rounded.WeatherSleetSnowAnimatable") {
materialPath {
moveTo(8.796879f, 5.0270833f)
curveTo(7.8632606f, 4.9745641f, 7.2031834f, 6.1917513f, 7.7668228f, 6.9446931f)
curveTo(8.2182954f, 7.6432508f, 9.3919824f, 7.6787827f, 9.8262364f, 6.9412491f)
curveTo(10.250965f, 6.3103634f, 9.9588313f, 5.3184227f, 9.2064902f, 5.0975184f)
curveTo(9.0753076f, 5.0509595f, 8.9361544f, 5.0263693f, 8.796879f, 5.0270833f)
close()
moveTo(16.734379f, 5.0270833f)
curveTo(15.800761f, 4.974564f, 15.140683f, 6.1917512f, 15.704322f, 6.9446931f)
curveTo(16.155795f, 7.643251f, 17.329482f, 7.6787825f, 17.763736f, 6.9412491f)
curveTo(18.188465f, 6.3103636f, 17.896331f, 5.3184225f, 17.14399f, 5.0975184f)
curveTo(17.012808f, 5.0509596f, 16.873654f, 5.0263694f, 16.734379f, 5.0270833f)
close()
moveTo(8.796879f, 12.964583f)
curveTo(7.8632606f, 12.912064f, 7.2031834f, 14.129251f, 7.7668228f, 14.882193f)
curveTo(8.2182957f, 15.580751f, 9.3919822f, 15.616282f, 9.8262364f, 14.878749f)
curveTo(10.250965f, 14.247863f, 9.9588308f, 13.255923f, 9.2064902f, 13.035018f)
curveTo(9.0753077f, 12.988459f, 8.9361544f, 12.963869f, 8.796879f, 12.964583f)
close()
moveTo(16.734379f, 12.964583f)
curveTo(15.800761f, 12.912064f, 15.140683f, 14.129251f, 15.704322f, 14.882193f)
curveTo(16.155795f, 15.580751f, 17.329482f, 15.616282f, 17.763736f, 14.878749f)
curveTo(18.188465f, 14.247863f, 17.896331f, 13.255923f, 17.14399f, 13.035018f)
curveTo(17.012808f, 12.988459f, 16.873654f, 12.963869f, 16.734379f, 12.964583f)
close()
}
}
val Icons.Rounded.WeatherFog
get() = materialIcon("Icons.Rounded.WeatherFog") {
materialPath {
moveTo(9.7558594f, 9.9609375f)
curveTo(9.2018594f, 9.9609375f, 8.7558594f, 10.406937f, 8.7558594f, 10.960938f)
curveTo(8.7558594f, 11.514938f, 9.2018594f, 11.960938f, 9.7558594f, 11.960938f)
lineTo(19.662109f, 11.960938f)
curveTo(20.216109f, 11.960937f, 20.662109f, 11.514938f, 20.662109f, 10.960938f)
curveTo(20.662109f, 10.406937f, 20.216109f, 9.9609375f, 19.662109f, 9.9609375f)
lineTo(9.7558594f, 9.9609375f)
close()
moveTo(2.390625f, 12.818359f)
curveTo(1.836625f, 12.818359f, 1.390625f, 13.264359f, 1.390625f, 13.818359f)
curveTo(1.390625f, 14.372359f, 1.836625f, 14.818359f, 2.390625f, 14.818359f)
lineTo(11.5625f, 14.818359f)
curveTo(12.1165f, 14.818359f, 12.5625f, 14.372359f, 12.5625f, 13.818359f)
curveTo(12.5625f, 13.264359f, 12.1165f, 12.818359f, 11.5625f, 12.818359f)
lineTo(2.390625f, 12.818359f)
close()
moveTo(14.847656f, 12.818359f)
curveTo(14.293656f, 12.818359f, 13.847656f, 13.264359f, 13.847656f, 13.818359f)
curveTo(13.847656f, 14.372359f, 14.293656f, 14.818359f, 14.847656f, 14.818359f)
lineTo(21.183594f, 14.818359f)
curveTo(21.737594f, 14.818359f, 22.183594f, 14.372359f, 22.183594f, 13.818359f)
curveTo(22.183594f, 13.264359f, 21.737594f, 12.818359f, 21.183594f, 12.818359f)
lineTo(14.847656f, 12.818359f)
close()
}
}
val Icons.Rounded.NotificationBadge
get() = materialIcon("Icons.Rounded.Badge") {
materialPath {
moveTo(11.826172f, 2.0507812f)
curveTo(9.3350327f, 2.0955476f, 6.8902426f, 3.0711501f, 5.03125f, 4.8867188f)
curveTo(2.0568619f, 7.7916288f, 1.1976573f, 12.247433f, 2.8769531f, 16.050781f)
curveTo(4.5562487f, 19.854128f, 8.4275333f, 22.217552f, 12.578125f, 21.976562f)
arcTo(1f, 1f, 0f, false, false, 13.517578f, 20.921875f)
arcTo(1f, 1f, 0f, false, false, 12.462891f, 19.980469f)
curveTo(9.1386756f, 20.173477f, 6.0519814f, 18.28634f, 4.7070312f, 15.240234f)
curveTo(3.3620806f, 12.194127f, 4.0474958f, 8.6429527f, 6.4296875f, 6.3164062f)
curveTo(8.8118791f, 3.9898597f, 12.379106f, 3.3881285f, 15.392578f, 4.8046875f)
curveTo(18.406051f, 6.2212464f, 20.218777f, 9.3531497f, 19.947266f, 12.671875f)
arcTo(1f, 1f, 0f, false, false, 20.861328f, 13.748047f)
arcTo(1f, 1f, 0f, false, false, 21.941406f, 12.833984f)
curveTo(22.11091f, 10.762115f, 21.627235f, 8.7461001f, 20.630859f, 7.0253906f)
curveTo(19.634483f, 5.3046802f, 18.125441f, 3.8804465f, 16.244141f, 2.9960938f)
curveTo(14.833165f, 2.332829f, 13.320855f, 2.0239214f, 11.826172f, 2.0507812f)
close()
moveTo(18f, 14f)
arcTo(4f, 4f, 0f, false, false, 14f, 18f)
arcTo(4f, 4f, 0f, false, false, 18f, 22f)
arcTo(4f, 4f, 0f, false, false, 22f, 18f)
arcTo(4f, 4f, 0f, false, false, 18f, 14f)
close()
}
}
val Icons.Rounded.GitHub
get() = materialIcon("Icons.Rounded.GitHub") {
materialPath {
moveTo(12f, 2f)
arcTo(10f, 10f, 0f, false, false, 2f, 12f)
curveTo(2f, 16.42f, 4.87f, 20.17f, 8.84f, 21.5f)
curveTo(9.34f, 21.58f, 9.5f, 21.27f, 9.5f, 21f)
curveTo(9.5f, 20.77f, 9.5f, 20.14f, 9.5f, 19.31f)
curveTo(6.73f, 19.91f, 6.14f, 17.97f, 6.14f, 17.97f)
curveTo(5.68f, 16.81f, 5.03f, 16.5f, 5.03f, 16.5f)
curveTo(4.12f, 15.88f, 5.1f, 15.9f, 5.1f, 15.9f)
curveTo(6.1f, 15.97f, 6.63f, 16.93f, 6.63f, 16.93f)
curveTo(7.5f, 18.45f, 8.97f, 18f, 9.54f, 17.76f)
curveTo(9.63f, 17.11f, 9.89f, 16.67f, 10.17f, 16.42f)
curveTo(7.95f, 16.17f, 5.62f, 15.31f, 5.62f, 11.5f)
curveTo(5.62f, 10.39f, 6f, 9.5f, 6.65f, 8.79f)
curveTo(6.55f, 8.54f, 6.2f, 7.5f, 6.75f, 6.15f)
curveTo(6.75f, 6.15f, 7.59f, 5.88f, 9.5f, 7.17f)
curveTo(10.29f, 6.95f, 11.15f, 6.84f, 12f, 6.84f)
curveTo(12.85f, 6.84f, 13.71f, 6.95f, 14.5f, 7.17f)
curveTo(16.41f, 5.88f, 17.25f, 6.15f, 17.25f, 6.15f)
curveTo(17.8f, 7.5f, 17.45f, 8.54f, 17.35f, 8.79f)
curveTo(18f, 9.5f, 18.38f, 10.39f, 18.38f, 11.5f)
curveTo(18.38f, 15.32f, 16.04f, 16.16f, 13.81f, 16.41f)
curveTo(14.17f, 16.72f, 14.5f, 17.33f, 14.5f, 18.26f)
curveTo(14.5f, 19.6f, 14.5f, 20.68f, 14.5f, 21f)
curveTo(14.5f, 21.27f, 14.66f, 21.59f, 15.17f, 21.5f)
curveTo(19.14f, 20.16f, 22f, 16.42f, 22f, 12f)
arcTo(10f, 10f, 0f, false, false, 12f, 2f)
close()
}
}
val Icons.Rounded.Telegram
get() = materialIcon("Icons.Rounded.Telegram") {
materialPath {
moveTo(9.78f, 18.65f)
lineTo(10.06f, 14.42f)
lineTo(17.74f, 7.5f)
curveTo(18.08f, 7.19f, 17.67f, 7.04f, 17.22f, 7.31f)
lineTo(7.74f, 13.3f)
lineTo(3.64f, 12f)
curveTo(2.76f, 11.75f, 2.75f, 11.14f, 3.84f, 10.7f)
lineTo(19.81f, 4.54f)
curveTo(20.54f, 4.21f, 21.24f, 4.72f, 20.96f, 5.84f)
lineTo(18.24f, 18.65f)
curveTo(18.05f, 19.56f, 17.5f, 19.78f, 16.74f, 19.36f)
lineTo(12.6f, 16.3f)
lineTo(10.61f, 18.23f)
curveTo(10.38f, 18.46f, 10.19f, 18.65f, 9.78f, 18.65f)
close()
}
}
val Icons.Rounded.Fdroid
get() = materialIcon("Icons.Rounded.Fdroid") {
materialPath {
moveTo(4.4570312f, 3.6269531f)
arcTo(0.44109249f, 0.44109249f, 0f, false, false, 4.1757812f, 3.71875f)
arcTo(0.44109249f, 0.44109249f, 0f, false, false, 4.0957031f, 4.3378906f)
lineTo(5.3691406f, 5.984375f)
curveTo(5.3240789f, 6.102208f, 5.2949219f, 6.2272726f, 5.2949219f, 6.3613281f)
lineTo(5.2949219f, 6.5507812f)
lineTo(5.2949219f, 7.4199219f)
lineTo(5.2949219f, 7.7714844f)
lineTo(5.2949219f, 8.640625f)
lineTo(5.2949219f, 8.8300781f)
curveTo(5.2949219f, 9.4165531f, 5.7670378f, 9.8886719f, 6.3535156f, 9.8886719f)
lineTo(17.646484f, 9.8886719f)
curveTo(18.232962f, 9.8886719f, 18.705078f, 9.4165531f, 18.705078f, 8.8300781f)
lineTo(18.705078f, 8.640625f)
lineTo(18.705078f, 6.5507812f)
lineTo(18.705078f, 6.3613281f)
curveTo(18.705078f, 6.2272726f, 18.675921f, 6.102208f, 18.630859f, 5.984375f)
lineTo(19.904297f, 4.3378906f)
arcTo(0.44109249f, 0.44109249f, 0f, false, false, 19.824219f, 3.71875f)
arcTo(0.44109249f, 0.44109249f, 0f, false, false, 19.566406f, 3.6269531f)
arcTo(0.44109249f, 0.44109249f, 0f, false, false, 19.207031f, 3.796875f)
lineTo(17.990234f, 5.3710938f)
curveTo(17.881129f, 5.3332803f, 17.768779f, 5.3027344f, 17.646484f, 5.3027344f)
lineTo(6.3535156f, 5.3027344f)
curveTo(6.2312214f, 5.3027344f, 6.1188708f, 5.3332803f, 6.0097656f, 5.3710938f)
lineTo(4.7929688f, 3.796875f)
arcTo(0.44109249f, 0.44109249f, 0f, false, false, 4.4570312f, 3.6269531f)
close()
moveTo(8.5664062f, 6.625f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 8.6035156f, 6.625f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 9.7949219f, 7.8164062f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 8.6035156f, 9.0078125f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 7.4121094f, 7.8164062f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 8.5664062f, 6.625f)
close()
moveTo(15.447266f, 6.625f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 15.484375f, 6.625f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 16.675781f, 7.8164062f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 15.484375f, 9.0078125f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 14.292969f, 7.8164062f)
arcTo(1.1909523f, 1.1909465f, 0f, false, true, 15.447266f, 6.625f)
close()
moveTo(6.3535156f, 10.242188f)
curveTo(5.7670378f, 10.242188f, 5.2949219f, 10.714306f, 5.2949219f, 11.300781f)
lineTo(5.2949219f, 11.572266f)
lineTo(5.2949219f, 13.771484f)
lineTo(5.2949219f, 15.888672f)
lineTo(5.2949219f, 18.087891f)
lineTo(5.2949219f, 18.359375f)
curveTo(5.2949219f, 18.94585f, 5.7670378f, 19.417969f, 6.3535156f, 19.417969f)
lineTo(17.646484f, 19.417969f)
curveTo(18.232962f, 19.417969f, 18.705078f, 18.94585f, 18.705078f, 18.359375f)
lineTo(18.705078f, 18.087891f)
lineTo(18.705078f, 11.572266f)
lineTo(18.705078f, 11.300781f)
curveTo(18.705078f, 10.714306f, 18.232962f, 10.242187f, 17.646484f, 10.242188f)
lineTo(6.3535156f, 10.242188f)
close()
}
}
val Icons.Rounded.Wikipedia
get() = materialIcon("Icons.Rounded.Wikipedia") {
materialPath {
moveTo(14.97f, 18.95f)
lineTo(12.41f, 12.92f)
curveTo(11.39f, 14.91f, 10.27f, 17f, 9.31f, 18.95f)
curveTo(9.3f, 18.96f, 8.84f, 18.95f, 8.84f, 18.95f)
curveTo(7.37f, 15.5f, 5.85f, 12.1f, 4.37f, 8.68f)
curveTo(4.03f, 7.84f, 2.83f, 6.5f, 2f, 6.5f)
curveTo(2f, 6.4f, 2f, 6.18f, 2f, 6.05f)
horizontalLineTo(7.06f)
verticalLineTo(6.5f)
curveTo(6.46f, 6.5f, 5.44f, 6.9f, 5.7f, 7.55f)
curveTo(6.42f, 9.09f, 8.94f, 15.06f, 9.63f, 16.58f)
curveTo(10.1f, 15.64f, 11.43f, 13.16f, 12f, 12.11f)
curveTo(11.55f, 11.23f, 10.13f, 7.93f, 9.71f, 7.11f)
curveTo(9.39f, 6.57f, 8.58f, 6.5f, 7.96f, 6.5f)
curveTo(7.96f, 6.35f, 7.97f, 6.25f, 7.96f, 6.06f)
lineTo(12.42f, 6.07f)
verticalLineTo(6.47f)
curveTo(11.81f, 6.5f, 11.24f, 6.71f, 11.5f, 7.29f)
curveTo(12.1f, 8.53f, 12.45f, 9.42f, 13f, 10.57f)
curveTo(13.17f, 10.23f, 14.07f, 8.38f, 14.5f, 7.41f)
curveTo(14.76f, 6.76f, 14.37f, 6.5f, 13.29f, 6.5f)
curveTo(13.3f, 6.38f, 13.3f, 6.17f, 13.3f, 6.07f)
curveTo(14.69f, 6.06f, 16.78f, 6.06f, 17.15f, 6.05f)
verticalLineTo(6.47f)
curveTo(16.44f, 6.5f, 15.71f, 6.88f, 15.33f, 7.46f)
lineTo(13.5f, 11.3f)
curveTo(13.68f, 11.81f, 15.46f, 15.76f, 15.65f, 16.2f)
lineTo(19.5f, 7.37f)
curveTo(19.2f, 6.65f, 18.34f, 6.5f, 18f, 6.5f)
curveTo(18f, 6.37f, 18f, 6.2f, 18f, 6.05f)
lineTo(22f, 6.08f)
verticalLineTo(6.1f)
lineTo(22f, 6.5f)
curveTo(21.12f, 6.5f, 20.57f, 7f, 20.25f, 7.75f)
curveTo(19.45f, 9.54f, 17f, 15.24f, 15.4f, 18.95f)
curveTo(15.4f, 18.95f, 14.97f, 18.95f, 14.97f, 18.95f)
close()
}
}
val Icons.Rounded.WhatsApp
get() = materialIcon("Icons.Rounded.WhatsApp") {
materialPath {
moveTo(12.04f, 2f)
curveTo(6.58f, 2f, 2.13f, 6.45f, 2.13f, 11.91f)
curveTo(2.13f, 13.66f, 2.59f, 15.36f, 3.45f, 16.86f)
lineTo(2.05f, 22f)
lineTo(7.3f, 20.62f)
curveTo(8.75f, 21.41f, 10.38f, 21.83f, 12.04f, 21.83f)
curveTo(17.5f, 21.83f, 21.95f, 17.38f, 21.95f, 11.92f)
curveTo(21.95f, 9.27f, 20.92f, 6.78f, 19.05f, 4.91f)
curveTo(17.18f, 3.03f, 14.69f, 2f, 12.04f, 2f)
moveTo(12.05f, 3.67f)
curveTo(14.25f, 3.67f, 16.31f, 4.53f, 17.87f, 6.09f)
curveTo(19.42f, 7.65f, 20.28f, 9.72f, 20.28f, 11.92f)
curveTo(20.28f, 16.46f, 16.58f, 20.15f, 12.04f, 20.15f)
curveTo(10.56f, 20.15f, 9.11f, 19.76f, 7.85f, 19f)
lineTo(7.55f, 18.83f)
lineTo(4.43f, 19.65f)
lineTo(5.26f, 16.61f)
lineTo(5.06f, 16.29f)
curveTo(4.24f, 15f, 3.8f, 13.47f, 3.8f, 11.91f)
curveTo(3.81f, 7.37f, 7.5f, 3.67f, 12.05f, 3.67f)
moveTo(8.53f, 7.33f)
curveTo(8.37f, 7.33f, 8.1f, 7.39f, 7.87f, 7.64f)
curveTo(7.65f, 7.89f, 7f, 8.5f, 7f, 9.71f)
curveTo(7f, 10.93f, 7.89f, 12.1f, 8f, 12.27f)
curveTo(8.14f, 12.44f, 9.76f, 14.94f, 12.25f, 16f)
curveTo(12.84f, 16.27f, 13.3f, 16.42f, 13.66f, 16.53f)
curveTo(14.25f, 16.72f, 14.79f, 16.69f, 15.22f, 16.63f)
curveTo(15.7f, 16.56f, 16.68f, 16.03f, 16.89f, 15.45f)
curveTo(17.1f, 14.87f, 17.1f, 14.38f, 17.04f, 14.27f)
curveTo(16.97f, 14.17f, 16.81f, 14.11f, 16.56f, 14f)
curveTo(16.31f, 13.86f, 15.09f, 13.26f, 14.87f, 13.18f)
curveTo(14.64f, 13.1f, 14.5f, 13.06f, 14.31f, 13.3f)
curveTo(14.15f, 13.55f, 13.67f, 14.11f, 13.53f, 14.27f)
curveTo(13.38f, 14.44f, 13.24f, 14.46f, 13f, 14.34f)
curveTo(12.74f, 14.21f, 11.94f, 13.95f, 11f, 13.11f)
curveTo(10.26f, 12.45f, 9.77f, 11.64f, 9.62f, 11.39f)
curveTo(9.5f, 11.15f, 9.61f, 11f, 9.73f, 10.89f)
curveTo(9.84f, 10.78f, 10f, 10.6f, 10.1f, 10.45f)
curveTo(10.23f, 10.31f, 10.27f, 10.2f, 10.35f, 10.04f)
curveTo(10.43f, 9.87f, 10.39f, 9.73f, 10.33f, 9.61f)
curveTo(10.27f, 9.5f, 9.77f, 8.26f, 9.56f, 7.77f)
curveTo(9.36f, 7.29f, 9.16f, 7.35f, 9f, 7.34f)
curveTo(8.86f, 7.34f, 8.7f, 7.33f, 8.53f, 7.33f)
close()
}
}
@@ -0,0 +1,14 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt
fun Color.toHexString(): String {
val red = (this.red * 255).roundToInt()
val green = (this.green * 255).roundToInt()
val blue = (this.blue * 255).roundToInt()
return "#" +
red.toString(16).run { if (length == 1) "0$this" else this } +
green.toString(16).run { if (length == 1) "0$this" else this } +
blue.toString(16).run { if (length == 1) "0$this" else this }
}
@@ -0,0 +1,10 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
@Composable
fun Dp.toPixels(): Float {
return value * LocalDensity.current.density
}
@@ -0,0 +1,14 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Converts the given pixel size to a Dp value based on the current density
*/
@Composable
fun Float.toDp(): Dp {
return (this / LocalDensity.current.density).dp
}
@@ -0,0 +1,14 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Converts the given pixel size to a Dp value based on the current density
*/
@Composable
fun Int.toDp(): Dp {
return (this / LocalDensity.current.density).dp
}
@@ -0,0 +1,11 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.ui.Modifier
fun Modifier.conditional(condition: Boolean, other: Modifier): Modifier {
if (condition) {
return this then other
}
return this
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.VectorConverter
import androidx.compose.runtime.MutableState
import androidx.compose.ui.unit.Dp
suspend fun MutableState<Dp>.animateTo(targetValue: Dp) {
animateTo(targetValue, Dp.VectorConverter)
}
suspend fun MutableState<Float>.animateTo(targetValue: Float) {
animateTo(targetValue, Float.VectorConverter)
}
suspend inline fun <T, V: AnimationVector> MutableState<T>.animateTo(targetValue: T, converter: TwoWayConverter<T, V>) {
val animatable = Animatable(this.value, converter)
animatable.animateTo(targetValue) {
this@animateTo.value = this.value
}
}
@@ -0,0 +1,9 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntOffset
import kotlin.math.roundToInt
fun Offset.toIntOffset(): IntOffset {
return IntOffset(x.roundToInt(), y.roundToInt())
}
@@ -0,0 +1,28 @@
package de.mm20.launcher2.ui.launcher
import android.content.Intent
import com.android.launcher3.GestureNavContract
class LauncherActivity: SharedLauncherActivity(LauncherActivityMode.Launcher) {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val navContract = intent?.let { GestureNavContract.fromIntent(it) }
if (navContract != null) {
homeTransitionManager.resolve(navContract, window)
} else {
onBackPressed()
}
}
override fun onPause() {
super.onPause()
homeTransitionManager.clear()
}
override fun onBackPressed() {
if (onBackPressedDispatcher.hasEnabledCallbacks()) {
onBackPressedDispatcher.onBackPressed()
}
}
}
@@ -0,0 +1,41 @@
package de.mm20.launcher2.ui.launcher
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.AppearanceSettings.Theme
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class LauncherActivityVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private var isSystemInDarkMode = MutableStateFlow(false)
private val dimBackgroundState = combine(
dataStore.data.map { it.appearance.dimWallpaper },
dataStore.data.map { it.appearance.theme },
isSystemInDarkMode
) { dim, theme, systemDarkMode ->
dim && (theme == Theme.Dark || theme == Theme.System && systemDarkMode)
}
val dimBackground = dimBackgroundState.asLiveData()
val statusBarColor = dataStore.data.map { it.systemBars.statusBarColor }.asLiveData()
val navBarColor = dataStore.data.map { it.systemBars.statusBarColor }.asLiveData()
val hideNavBar = dataStore.data.map { it.systemBars.hideNavBar }.asLiveData()
val hideStatusBar = dataStore.data.map { it.systemBars.hideStatusBar }.asLiveData()
fun setSystemInDarkMode(darkMode: Boolean) {
isSystemInDarkMode.value = darkMode
}
val layout = dataStore.data.map { it.appearance.layout }.asLiveData()
}
@@ -0,0 +1,57 @@
package de.mm20.launcher2.ui.launcher
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class LauncherScaffoldVM : ViewModel(), KoinComponent {
val isSearchOpen = MutableLiveData(false)
val isWidgetEditMode = MutableLiveData(false)
val searchBarFocused = MutableLiveData(false)
val dataStore: LauncherDataStore by inject()
val autoFocusSearch = dataStore.data.map { it.searchBar.autoFocus }
fun setSearchbarFocus(focused: Boolean) {
if (searchBarFocused.value != focused) searchBarFocused.value = focused
}
fun openSearch() {
if (isSearchOpen.value == true) return
isSearchOpen.value = true
viewModelScope.launch {
if (autoFocusSearch.first()) setSearchbarFocus(true)
}
}
fun closeSearch() {
if (isSearchOpen.value == false) return
isSearchOpen.value = false
setSearchbarFocus(false)
}
fun toggleSearch() {
if (isSearchOpen.value == true) closeSearch()
else openSearch()
}
fun setWidgetEditMode(editMode: Boolean) {
isSearchOpen.value = false
isWidgetEditMode.value = editMode
}
val wallpaperBlur = dataStore.data.map { it.appearance.blurWallpaper }.asLiveData()
val fillClockHeight = dataStore.data.map { it.clockWidget.fillHeight }.asLiveData()
val searchBarColor = dataStore.data.map { it.searchBar.color }.asLiveData()
val searchBarStyle = dataStore.data.map { it.searchBar.searchBarStyle }.asLiveData()
}
@@ -0,0 +1,467 @@
package de.mm20.launcher2.ui.launcher
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material.rememberSwipeableState
import androidx.compose.material.swipeable
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarColors
import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarStyle
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import de.mm20.launcher2.ui.utils.rememberNotificationShadeController
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable
fun PagerScaffold(
modifier: Modifier = Modifier,
darkStatusBarIcons: Boolean = false,
darkNavBarIcons: Boolean = false,
reverse: Boolean = false,
) {
val viewModel: LauncherScaffoldVM = viewModel()
val searchVM: SearchVM = viewModel()
val isSearchOpen by viewModel.isSearchOpen.observeAsState(false)
val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false)
val actions by searchVM.searchActionResults.observeAsState(emptyList())
val widgetsScrollState = rememberScrollState()
val searchState = rememberLazyListState()
val swipeableState = rememberSwipeableState(if (isSearchOpen) Page.Search else Page.Widgets)
val isSearchAtStart by remember {
derivedStateOf {
searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0
}
}
val isSearchAtEnd by remember {
derivedStateOf {
val lastItem =
searchState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding
}
}
val showStatusBarScrim by remember {
derivedStateOf {
if (isSearchOpen) {
!isSearchAtEnd
} else {
widgetsScrollState.value > 0
}
}
}
val fillClockHeight by viewModel.fillClockHeight.observeAsState(true)
val showNavBarScrim by remember {
derivedStateOf {
if (isSearchOpen) {
!isSearchAtStart
} else {
(widgetsScrollState.value > 0 || !fillClockHeight) && widgetsScrollState.value < widgetsScrollState.maxValue
}
}
}
val isWidgetsScrollZero by remember {
derivedStateOf {
widgetsScrollState.value == 0
}
}
val systemUiController = rememberSystemUiController()
val colorSurface = MaterialTheme.colorScheme.surface
LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim) {
if (isWidgetEditMode) {
systemUiController.setStatusBarColor(
colorSurface
)
} else if (showStatusBarScrim) {
systemUiController.setStatusBarColor(
colorSurface.copy(0.7f),
)
} else {
systemUiController.setStatusBarColor(
Color.Transparent,
darkIcons = darkStatusBarIcons
)
}
}
LaunchedEffect(darkNavBarIcons, showNavBarScrim) {
if (showNavBarScrim) {
systemUiController.setNavigationBarColor(
colorSurface.copy(0.7f),
)
} else {
systemUiController.setNavigationBarColor(
Color.Transparent,
darkIcons = darkNavBarIcons,
navigationBarContrastEnforced = false
)
}
}
val blurEnabled by viewModel.wallpaperBlur.observeAsState(false)
val blurWallpaper by remember {
derivedStateOf {
blurEnabled && (
isSearchOpen || swipeableState.progress.to == Page.Widgets && swipeableState.progress.fraction <= 0.5f ||
swipeableState.progress.to == Page.Search && swipeableState.progress.fraction > 0.5f ||
!isWidgetsScrollZero)
}
}
WallpaperBlur {
blurWallpaper
}
val currentPage = swipeableState.currentValue
LaunchedEffect(currentPage) {
if (currentPage == Page.Search) viewModel.openSearch()
else viewModel.closeSearch()
}
LaunchedEffect(isSearchOpen) {
if (isSearchOpen) swipeableState.animateTo(Page.Search)
else {
swipeableState.animateTo(Page.Widgets)
searchVM.search("")
}
}
val scope = rememberCoroutineScope()
BackHandler {
when {
isSearchOpen -> {
viewModel.closeSearch()
searchVM.search("")
}
isWidgetEditMode -> {
viewModel.setWidgetEditMode(false)
}
widgetsScrollState.value != 0 -> {
scope.launch {
widgetsScrollState.animateScrollTo(0)
}
}
}
}
val notificationDragThreshold = with(LocalDensity.current) { 200.dp.toPx() }
val notificationShadeController = rememberNotificationShadeController()
val keyboardController = LocalSoftwareKeyboardController.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
private var pullDownTotalY: Float? = 0f
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (!isWidgetsScrollZero) return Offset.Zero
val diff = -available.y
var totalY = pullDownTotalY ?: return available
if (diff >= 0) return super.onPreScroll(available, source)
totalY += diff
if (totalY < -notificationDragThreshold) {
notificationShadeController.expandNotifications()
pullDownTotalY = null
return available
}
pullDownTotalY = totalY
return super.onPreScroll(available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (pullDownTotalY == null) {
pullDownTotalY = 0f
return available
}
return super.onPreFling(available)
}
}
}
val searchNestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.Drag && available.y.absoluteValue > available.x.absoluteValue * 2) {
keyboardController?.hide()
}
return super.onPreScroll(available, source)
}
}
}
val insets = WindowInsets.safeDrawing.asPaddingValues()
Box(
modifier = modifier
) {
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val height by remember {
derivedStateOf { maxHeight }
}
val width by remember {
derivedStateOf { maxWidth }
}
val widthPx = width.toPixels()
val originalLayoutDirection = LocalLayoutDirection.current
CompositionLocalProvider(
LocalOverscrollConfiguration provides null,
LocalLayoutDirection provides if (reverse) LayoutDirection.Rtl else LayoutDirection.Ltr
) {
Row(
modifier = Modifier
.requiredWidth(width * 2)
.fillMaxHeight()
.swipeable(
swipeableState,
orientation = Orientation.Horizontal,
anchors = mapOf(
-widthPx / 2f to Page.Search,
widthPx / 2f to Page.Widgets,
),
thresholds = { _, _ ->
FractionalThreshold(0.5f)
},
enabled = !isWidgetEditMode,
reverseDirection = reverse,
)
.offset {
IntOffset(swipeableState.offset.value.roundToInt(), 0)
},
) {
CompositionLocalProvider(
LocalLayoutDirection provides originalLayoutDirection
) {
val editModePadding by animateDpAsState(if (isWidgetEditMode) 56.dp else 0.dp)
val clockPadding by animateDpAsState(
if (isWidgetsScrollZero && fillClockHeight) 64.dp + insets.calculateBottomPadding() else 0.dp
)
val clockHeight by remember {
derivedStateOf {
if (fillClockHeight) {
height - (64.dp + insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding)
} else {
null
}
}
}
Column(
modifier = Modifier
.requiredWidth(width)
.fillMaxHeight()
.nestedScroll(nestedScrollConnection)
.verticalScroll(widgetsScrollState)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp)
.padding(top = 8.dp, bottom = 64.dp)
.padding(top = editModePadding)
) {
AnimatedVisibility(!isWidgetEditMode) {
Box(
modifier = Modifier
.fillMaxWidth()
.then(clockHeight?.let { Modifier.height(it) } ?: Modifier)
.padding(bottom = clockPadding),
contentAlignment = Alignment.BottomCenter
) {
ClockWidget(
modifier = Modifier.fillMaxWidth()
)
}
}
WidgetColumn(
editMode = isWidgetEditMode,
onEditModeChange = {
viewModel.setWidgetEditMode(it)
}
)
}
val webSearchPadding by animateDpAsState(
if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
SearchColumn(
modifier = Modifier
.requiredWidth(width)
.fillMaxHeight()
.nestedScroll(searchNestedScrollConnection)
.padding(
start = windowInsets.calculateStartPadding(LocalLayoutDirection.current),
end = windowInsets.calculateStartPadding(LocalLayoutDirection.current),
),
reverse = true,
state = searchState,
paddingValues = PaddingValues(
top = 4.dp + windowInsets.calculateTopPadding(),
bottom = 60.dp + webSearchPadding + windowInsets.calculateBottomPadding()
)
)
}
}
}
}
AnimatedVisibility(visible = isWidgetEditMode,
enter = slideIn { IntOffset(0, -it.height) },
exit = slideOut { IntOffset(0, -it.height) }
) {
CenterAlignedTopAppBar(
modifier = Modifier.systemBarsPadding(),
title = {
Text(stringResource(R.string.menu_edit_widgets))
},
navigationIcon = {
IconButton(onClick = { viewModel.setWidgetEditMode(false) }) {
Icon(imageVector = Icons.Rounded.Done, contentDescription = null)
}
},
)
}
val searchBarLevel by remember {
derivedStateOf {
when {
swipeableState.direction != 0f -> SearchBarLevel.Raised
!isSearchOpen && isWidgetsScrollZero && fillClockHeight -> SearchBarLevel.Resting
isSearchOpen && isSearchAtStart -> SearchBarLevel.Active
else -> SearchBarLevel.Raised
}
}
}
val focusSearchBar by viewModel.searchBarFocused.observeAsState(false)
val widgetEditModeOffset by animateDpAsState(
if (isWidgetEditMode) 128.dp else 0.dp
)
val value by searchVM.searchQuery.observeAsState("")
val searchBarColor by viewModel.searchBarColor.observeAsState(SearchBarColors.Auto)
val searchBarStyle by viewModel.searchBarStyle.observeAsState(SearchBarStyle.Transparent)
LauncherSearchBar(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
.windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding()
.offset(y = widgetEditModeOffset),
level = { searchBarLevel },
focused = focusSearchBar,
onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
},
actions = actions,
showHiddenItemsButton = isSearchOpen,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
style = searchBarStyle,
reverse = true
)
}
}
private enum class Page {
Widgets,
Search
}
@@ -0,0 +1,435 @@
package de.mm20.launcher2.ui.launcher
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable
fun PullDownScaffold(
modifier: Modifier = Modifier,
darkStatusBarIcons: Boolean = false,
darkNavBarIcons: Boolean = false,
) {
val viewModel: LauncherScaffoldVM = viewModel()
val searchVM: SearchVM = viewModel()
val density = LocalDensity.current
val actions by searchVM.searchActionResults.observeAsState(emptyList())
val isSearchOpen by viewModel.isSearchOpen.observeAsState(false)
val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false)
val widgetsScrollState = rememberScrollState()
val searchState = rememberLazyListState()
val isSearchAtStart by remember {
derivedStateOf {
searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0
}
}
val isSearchAtEnd by remember {
derivedStateOf {
val lastItem = searchState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding
}
}
val systemUiController = rememberSystemUiController()
val isWidgetsAtStart by remember {
derivedStateOf {
widgetsScrollState.value == 0
}
}
val isWidgetsAtEnd by remember {
derivedStateOf {
widgetsScrollState.value >= widgetsScrollState.maxValue
}
}
val fillClockHeight by viewModel.fillClockHeight.observeAsState(true)
val showStatusBarScrim by remember {
derivedStateOf {
if (isSearchOpen) {
!isSearchAtStart
} else {
widgetsScrollState.value > 0
}
}
}
val showNavBarScrim by remember {
derivedStateOf {
if (isSearchOpen) {
!isSearchAtEnd
} else {
(widgetsScrollState.value > 0 || !fillClockHeight) && widgetsScrollState.value < widgetsScrollState.maxValue
}
}
}
val colorSurface = MaterialTheme.colorScheme.surface
LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim) {
if (isWidgetEditMode) {
systemUiController.setStatusBarColor(
colorSurface
)
} else if (showStatusBarScrim) {
systemUiController.setStatusBarColor(
colorSurface.copy(0.7f),
)
} else {
systemUiController.setStatusBarColor(
Color.Transparent,
darkIcons = darkStatusBarIcons
)
}
}
LaunchedEffect(darkNavBarIcons, showNavBarScrim) {
if (showNavBarScrim) {
systemUiController.setNavigationBarColor(
colorSurface.copy(0.7f),
)
} else {
systemUiController.setNavigationBarColor(
Color.Transparent,
darkIcons = darkNavBarIcons,
navigationBarContrastEnforced = false
)
}
}
val offsetY = remember { mutableStateOf(0f) }
val maxOffset = with(density) { 64.dp.toPx() }
val toggleSearchThreshold = with(density) { 48.dp.toPx() }
val searchBarOffset = remember { mutableStateOf(0f) }
val maxSearchBarOffset = with(density) { 128.dp.toPx() }
val blurEnabled by viewModel.wallpaperBlur.observeAsState(false)
val blurWallpaper by remember {
derivedStateOf {
blurEnabled && (isSearchOpen || offsetY.value > toggleSearchThreshold || widgetsScrollState.value > 0)
}
}
WallpaperBlur {
blurWallpaper
}
val scope = rememberCoroutineScope()
LaunchedEffect(isSearchOpen) {
if (isSearchOpen) searchState.scrollToItem(0)
if (!isSearchOpen) searchVM.search("")
searchBarOffset.animateTo(0f)
}
LaunchedEffect(isWidgetEditMode) {
if (!isWidgetEditMode) searchBarOffset.value = 0f
}
BackHandler {
when {
isSearchOpen -> {
viewModel.closeSearch()
searchVM.search("")
}
isWidgetEditMode -> {
viewModel.setWidgetEditMode(false)
}
widgetsScrollState.value != 0 -> {
scope.launch {
widgetsScrollState.animateScrollTo(0)
}
}
}
}
val keyboardController = LocalSoftwareKeyboardController.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (isWidgetEditMode) return Offset.Zero
if (source == NestedScrollSource.Drag && available.y.absoluteValue > available.x.absoluteValue * 2) {
keyboardController?.hide()
}
val canPullDown = if (isSearchOpen) {
isSearchAtStart
} else {
isWidgetsAtStart
}
val canPullUp = isSearchOpen && isSearchAtEnd
val consumed = when {
canPullUp && available.y < 0 || offsetY.value < 0 -> {
val consumed = available.y
offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(-maxOffset, 0f)
consumed
}
canPullDown && available.y > 0 || offsetY.value > 0 -> {
val consumed = available.y
offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(0f, maxOffset)
consumed
}
else -> 0f
}
searchBarOffset.value =
(searchBarOffset.value + (available.y - consumed)).coerceIn(
-maxSearchBarOffset,
0f
)
return Offset(0f, consumed)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (offsetY.value > toggleSearchThreshold || offsetY.value < -toggleSearchThreshold) {
viewModel.toggleSearch()
}
if (offsetY.value != 0f) {
offsetY.animateTo(0f)
return available
}
return Velocity.Zero
}
}
}
val insets = WindowInsets.safeDrawing.asPaddingValues()
Box(
modifier = modifier
.nestedScroll(nestedScrollConnection)
.offset { IntOffset(0, offsetY.value.toInt()) },
contentAlignment = Alignment.TopCenter
) {
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
) {
val height by remember {
derivedStateOf { maxHeight }
}
CompositionLocalProvider(
LocalOverscrollConfiguration provides null
) {
val offset by animateFloatAsState(if (isSearchOpen) 1f else 0f)
Column(
modifier = Modifier
.fillMaxWidth()
.requiredHeight(height * 2)
.offset {
IntOffset(
0,
((-0.5f + offset) * height.toPx()).roundToInt()
)
}
) {
val webSearchPadding by animateDpAsState(
if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
SearchColumn(
modifier = Modifier
.graphicsLayer {
transformOrigin = TransformOrigin.Center
scaleX = offset
scaleY = offset
alpha = offset
}
.fillMaxWidth()
.requiredHeight(height)
.padding(
start = windowInsets.calculateStartPadding(LocalLayoutDirection.current),
end = windowInsets.calculateStartPadding(LocalLayoutDirection.current),
),
paddingValues = PaddingValues(
top = 60.dp + webSearchPadding + windowInsets.calculateTopPadding(),
bottom = 4.dp + windowInsets.calculateBottomPadding()
),
state = searchState,
)
val clockPadding by animateDpAsState(
if (isWidgetsAtStart && fillClockHeight) insets.calculateBottomPadding() else 0.dp
)
val clockHeight by remember {
derivedStateOf {
if (fillClockHeight) {
height - (insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding + 56.dp)
} else {
null
}
}
}
Column(
modifier = Modifier
.graphicsLayer {
transformOrigin = TransformOrigin.Center
scaleX = 1 - offset
scaleY = 1 - offset
alpha = 1 - offset
}
.fillMaxWidth()
.requiredHeight(height)
.verticalScroll(widgetsScrollState)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp)
.padding(top = 56.dp)
) {
AnimatedVisibility(!isWidgetEditMode) {
Box(
modifier = Modifier
.fillMaxWidth()
.then(clockHeight?.let { Modifier.height(it) } ?: Modifier)
.padding(bottom = clockPadding),
contentAlignment = Alignment.BottomCenter
) {
ClockWidget(
modifier = Modifier.fillMaxWidth()
)
}
}
WidgetColumn(
editMode = isWidgetEditMode,
onEditModeChange = {
viewModel.setWidgetEditMode(it)
}
)
}
}
}
}
AnimatedVisibility(visible = isWidgetEditMode,
enter = slideIn { IntOffset(0, -it.height) },
exit = slideOut { IntOffset(0, -it.height) }
) {
CenterAlignedTopAppBar(
modifier = Modifier
.windowInsetsPadding(WindowInsets.safeDrawing),
title = {
Text(stringResource(R.string.menu_edit_widgets))
},
navigationIcon = {
IconButton(onClick = { viewModel.setWidgetEditMode(false) }) {
Icon(imageVector = Icons.Rounded.Done, contentDescription = null)
}
}
)
}
val searchBarLevel by remember {
derivedStateOf {
when {
offsetY.value != 0f -> SearchBarLevel.Raised
isSearchOpen && isSearchAtStart -> SearchBarLevel.Active
isSearchOpen && !isSearchAtStart -> SearchBarLevel.Raised
!isWidgetsAtStart -> SearchBarLevel.Raised
else -> SearchBarLevel.Resting
}
}
}
val searchBarFocused by viewModel.searchBarFocused.observeAsState(false)
val editModeSearchBarOffset by animateDpAsState(
if (isWidgetEditMode) -128.dp else 0.dp
)
val value by searchVM.searchQuery.observeAsState("")
val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
LauncherSearchBar(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp)
.offset { IntOffset(0,
if (searchBarFocused) 0 else searchBarOffset.value.toInt()) }
.offset {
IntOffset(
0,
with(density) {
editModeSearchBarOffset
.toPx()
.roundToInt()
})
},
level = { searchBarLevel },
focused = searchBarFocused,
onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
},
actions = actions,
showHiddenItemsButton = isSearchOpen,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == Settings.SearchBarSettings.SearchBarColors.Auto || searchBarColor == Settings.SearchBarSettings.SearchBarColors.Dark,
style = searchBarStyle,
)
}
}
@@ -0,0 +1,252 @@
package de.mm20.launcher2.ui.launcher
import android.app.WallpaperManager
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.preferences.Settings.SystemBarsSettings.SystemBarColors
import de.mm20.launcher2.ui.assistant.AssistantScaffold
import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.base.ProvideCurrentTime
import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.component.NavBarEffects
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.sheets.CustomizeSearchableSheet
import de.mm20.launcher2.ui.launcher.sheets.EditFavoritesSheet
import de.mm20.launcher2.ui.launcher.sheets.HiddenItemsSheet
import de.mm20.launcher2.ui.launcher.sheets.LauncherBottomSheetManager
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.launcher.transitions.HomeTransition
import de.mm20.launcher2.ui.launcher.transitions.HomeTransitionManager
import de.mm20.launcher2.ui.launcher.transitions.LocalHomeTransitionManager
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.locals.LocalWallpaperColors
import de.mm20.launcher2.ui.locals.LocalWindowSize
import de.mm20.launcher2.ui.theme.LauncherTheme
import de.mm20.launcher2.ui.theme.wallpaperColorsAsState
import kotlin.math.pow
abstract class SharedLauncherActivity(
private val mode: LauncherActivityMode
) : BaseActivity() {
private val viewModel: LauncherActivityVM by viewModels()
internal val homeTransitionManager = HomeTransitionManager()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val wallpaperManager = WallpaperManager.getInstance(this)
val windowSize = Resources.getSystem().displayMetrics.let {
Size(it.widthPixels.toFloat(), it.heightPixels.toFloat())
}
WindowCompat.setDecorFitsSystemWindows(window, false)
viewModel.setSystemInDarkMode(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)
val bottomSheetManager = LauncherBottomSheetManager()
setContent {
val snackbarHostState = remember { SnackbarHostState() }
val wallpaperColors by wallpaperColorsAsState()
val dimBackground by viewModel.dimBackground.observeAsState(false)
CompositionLocalProvider(
LocalHomeTransitionManager provides homeTransitionManager,
LocalWindowSize provides windowSize,
LocalSnackbarHostState provides snackbarHostState,
LocalWallpaperColors provides wallpaperColors,
LocalPreferDarkContentOverWallpaper provides (!dimBackground && wallpaperColors.supportsDarkText),
LocalBottomSheetManager provides bottomSheetManager,
) {
LauncherTheme {
ProvideCurrentTime {
ProvideSettings {
val statusBarColor by viewModel.statusBarColor.observeAsState(SystemBarColors.Auto)
val navBarColor by viewModel.navBarColor.observeAsState(SystemBarColors.Auto)
val lightStatus = !dimBackground && (statusBarColor == SystemBarColors.Dark || statusBarColor == SystemBarColors.Auto && wallpaperColors.supportsDarkText)
val lightNav = !dimBackground && (navBarColor == SystemBarColors.Dark || navBarColor == SystemBarColors.Auto && wallpaperColors.supportsDarkText)
val hideStatus by viewModel.hideStatusBar.observeAsState(false)
val hideNav by viewModel.hideNavBar.observeAsState(false)
val layout by viewModel.layout.observeAsState(null)
val systemUiController = rememberSystemUiController()
val enterTransitionProgress = remember { mutableStateOf(1f) }
var enterTransition by remember {
mutableStateOf<HomeTransition?>(
null
)
}
LaunchedEffect(null) {
homeTransitionManager
.currentTransition
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.collect {
enterTransitionProgress.value = 0f
enterTransition = it
enterTransitionProgress.animateTo(1f)
enterTransition = null
}
}
LaunchedEffect(hideStatus) {
systemUiController.isStatusBarVisible = !hideStatus
}
LaunchedEffect(hideNav) {
systemUiController.isNavigationBarVisible = !hideNav
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(null) {
detectTapGestures {
wallpaperManager?.sendWallpaperCommand(
window.decorView.applicationWindowToken,
WallpaperManager.COMMAND_TAP,
it.x.toInt(),
it.y.toInt(),
0,
null
)
}
}
.background(if (dimBackground) Color.Black.copy(alpha = 0.30f) else Color.Transparent),
contentAlignment = Alignment.BottomCenter
) {
NavBarEffects(modifier = Modifier.fillMaxSize())
if (mode == LauncherActivityMode.Assistant) {
AssistantScaffold(
modifier = Modifier
.fillMaxSize(),
darkStatusBarIcons = lightStatus,
darkNavBarIcons = lightNav,
)
} else {
when (layout) {
Settings.AppearanceSettings.Layout.PullDown -> {
PullDownScaffold(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX =
0.5f + enterTransitionProgress.value * 0.5f
scaleY =
0.5f + enterTransitionProgress.value * 0.5f
alpha = enterTransitionProgress.value
},
darkStatusBarIcons = lightStatus,
darkNavBarIcons = lightNav,
)
}
Settings.AppearanceSettings.Layout.Pager,
Settings.AppearanceSettings.Layout.PagerReversed -> {
PagerScaffold(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = enterTransitionProgress.value
scaleY = enterTransitionProgress.value
alpha = enterTransitionProgress.value
},
darkStatusBarIcons = lightStatus,
darkNavBarIcons = lightNav,
reverse = layout == Settings.AppearanceSettings.Layout.PagerReversed
)
}
else -> {}
}
}
SnackbarHost(
snackbarHostState,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
enterTransition?.let {
val dX = it.startBounds.center.x - it.targetBounds.center.x
val dY = it.startBounds.center.y - it.targetBounds.center.y
val s = (it.startBounds.minDimension / it.targetBounds.minDimension - 1f) * 0.5f
Box(
modifier = Modifier
.align(Alignment.TopStart)
.graphicsLayer {
val p = (enterTransitionProgress.value).pow(2f)
transformOrigin = TransformOrigin.Center
translationX = it.targetBounds.left + dX * (1 - p)
translationY = it.targetBounds.top + dY * (1 - p)
alpha = enterTransitionProgress.value
scaleX = 1f + s * (1 - p)
scaleY = 1f + s * (1 - p)
}) {
it.icon?.invoke(Offset(dX, dY)) { enterTransitionProgress.value }
}
}
bottomSheetManager.customizeSearchableSheetShown.value?.let {
CustomizeSearchableSheet(searchable = it, onDismiss = { bottomSheetManager.dismissCustomizeSearchableModal() })
}
if (bottomSheetManager.editFavoritesSheetShown.value) {
EditFavoritesSheet(onDismiss = { bottomSheetManager.dismissEditFavoritesSheet() })
}
}
}
}
}
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val windowController = WindowCompat.getInsetsController(window, window.decorView.rootView)
windowController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
enum class LauncherActivityMode {
Launcher,
Assistant
}
}
@@ -0,0 +1,322 @@
package de.mm20.launcher2.ui.launcher.helper
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.*
import androidx.compose.ui.zIndex
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.ktx.toIntOffset
import kotlinx.coroutines.*
import kotlinx.coroutines.android.awaitFrame
import kotlin.coroutines.coroutineContext
/**
* Create and remember a [LazyDragAndDropGridState]
* @param gridState the [LazyGridState] to use with the LazyGrid
* @param onDragStart callback that will be called when an item is picked up. If the return value
* is false, the drag operation will be canceled.
*/
@Composable
fun rememberLazyDragAndDropGridState(
gridState: LazyGridState = rememberLazyGridState(),
onDragStart: (item: LazyGridItemInfo) -> Boolean = { true },
onDrag: (item: LazyGridItemInfo, offset: Offset, position: Offset) -> Unit = {_, _, _ ->},
onDragEnd: (item: LazyGridItemInfo) -> Unit = {},
onDragCancel: (item: LazyGridItemInfo) -> Unit = {},
onItemMove: (from: LazyGridItemInfo, to: LazyGridItemInfo) -> Unit
): LazyDragAndDropGridState {
return remember {
LazyDragAndDropGridState(
gridState,
onDragStart,
onDrag,
onDragEnd,
onDragCancel,
onItemMove
)
}
}
data class LazyDragAndDropGridState(
val gridState: LazyGridState,
val onDragStart: (item: LazyGridItemInfo) -> Boolean = { true },
val onDrag: (item: LazyGridItemInfo, offset: Offset, position: Offset) -> Unit = {_, _, _ ->},
val onDragEnd: (item: LazyGridItemInfo) -> Unit = {},
val onDragCancel: (item: LazyGridItemInfo) -> Unit = {},
val onItemMove: (from: LazyGridItemInfo, to: LazyGridItemInfo) -> Unit
) {
var draggedItem by mutableStateOf<LazyGridItemInfo?>(null)
var draggedItemAbsolutePosition by mutableStateOf<Offset?>(null)
val draggedItemOffset by derivedStateOf {
val absPos = draggedItemAbsolutePosition ?: return@derivedStateOf null
val key = draggedItem?.key ?: return@derivedStateOf null
val draggedItem = gridState.layoutInfo.visibleItemsInfo.find {
it.key == key
} ?: return@derivedStateOf null
return@derivedStateOf absPos - draggedItem.offset.toOffset()
}
var droppedItemKey by mutableStateOf<Any?>(null)
val droppedItemOffset = mutableStateOf<IntOffset>(IntOffset.Zero)
private var currentDropPosition: Int? = null
private var dropJob: Job? = null
fun startDrag(draggedItem: LazyGridItemInfo): Boolean {
if (!onDragStart(draggedItem)) return false
this.draggedItem = draggedItem
draggedItemAbsolutePosition = draggedItem.offset.toOffset()
return true
}
/**
* Move the currently dragged item to the specified drop target if the dragged item is held
* for at least 300ms over the drop target. The move operation is canceled if the dragged item
* is released or moved out of the dropTarget area during the 300ms time frame.
*/
suspend fun attemptMove(dropTarget: LazyGridItemInfo) {
if (currentDropPosition != dropTarget.index) {
coroutineScope {
dropJob?.cancelAndJoin()
dropJob = launch {
currentDropPosition = dropTarget.index
delay(300)
// Get a fresh copy of layout info because index in saved layout info might be outdated
val dragged =
gridState.layoutInfo.visibleItemsInfo.find { it.key == draggedItem?.key }
if (dragged != null) {
onItemMove(dragged, dropTarget)
}
}
}
}
}
suspend fun cancelDrag() {
draggedItem?.let { onDragCancel(it) }
afterDragEnded()
}
suspend fun endDrag() {
draggedItem?.let { onDragEnd(it) }
afterDragEnded()
}
private suspend fun afterDragEnded() {
endScrolling()
val key = draggedItem?.key
val startOffset = draggedItemOffset
draggedItem = null
draggedItemAbsolutePosition = null
currentDropPosition = null
if (key == null || startOffset == null) return
droppedItemKey = key
droppedItemOffset.value = startOffset.toIntOffset()
droppedItemOffset.animateTo(IntOffset.Zero, IntOffset.VectorConverter)
droppedItemKey = null
}
private var scrollJob: Job? = null
private var currentScrollDelta = 0.0f
/**
* Scroll the lazy grid by `delta` px per second until [endScrolling] is called
*/
suspend fun enableScrolling(delta: Float) {
if (currentScrollDelta == delta) return
coroutineScope {
scrollJob?.cancelAndJoin()
scrollJob = launch {
currentScrollDelta = delta
delay(500)
var lastFrame = awaitFrame()
while (isActive) {
val frame = awaitFrame()
val timeDelta = frame - lastFrame
gridState.scrollBy(delta * timeDelta / 1000_000_000f)
lastFrame = frame
}
}
}
}
/**
* Cancel scrolling
*/
fun endScrolling() {
currentScrollDelta = 0f
scrollJob?.cancel()
scrollJob = null
}
}
@Composable
fun LazyVerticalDragAndDropGrid(
state: LazyDragAndDropGridState,
columns: GridCells,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
content: LazyGridScope.() -> Unit
) {
LazyVerticalGrid(
columns,
modifier.dragAndDrop(
state,
LocalLayoutDirection.current == LayoutDirection.Rtl,
LocalHapticFeedback.current
),
state.gridState,
contentPadding,
false,
verticalArrangement,
horizontalArrangement,
flingBehavior,
userScrollEnabled,
content,
)
}
@Composable
fun LazyHorizontalDragAndDropGrid(
state: LazyDragAndDropGridState,
rows: GridCells,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
content: LazyGridScope.() -> Unit
) {
LazyHorizontalGrid(
rows,
modifier.dragAndDrop(
state,
LocalLayoutDirection.current == LayoutDirection.Rtl,
LocalHapticFeedback.current
),
state.gridState,
contentPadding,
false,
horizontalArrangement,
verticalArrangement,
flingBehavior,
userScrollEnabled,
content,
)
}
fun Modifier.dragAndDrop(
state: LazyDragAndDropGridState,
isRtl: Boolean,
hapticFeedback: HapticFeedback
) =
this then pointerInput(null) {
val scope = CoroutineScope(coroutineContext)
val scrollEdgeSize = 32.dp.toPx()
val scrollDelta = 128.dp.toPx()
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val draggedItem = state.gridState.layoutInfo.visibleItemsInfo.find {
Rect(
it.offset.toOffset().let {off ->
if (isRtl) off.copy(x = state.gridState.layoutInfo.viewportSize.width - off.x - it.size.width)
else off
},
it.size.toSize()
).contains(offset)
}
if (draggedItem != null && state.startDrag(draggedItem)) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
},
onDrag = { change, dragAmount ->
val absPosition = state.draggedItemAbsolutePosition
val draggedItem = state.draggedItem
if (absPosition != null && draggedItem != null) {
state.draggedItemAbsolutePosition = absPosition + dragAmount.let {
if (isRtl) it.copy(x = -it.x)
else it
}
val draggedCenter = Rect(absPosition, draggedItem.size.toSize()).center
val dragOver = state.gridState.layoutInfo.visibleItemsInfo.find {
Rect(
it.offset.toOffset(),
it.size.toSize()
).contains(draggedCenter)
}
if (dragOver != null && dragOver.key != draggedItem.key) {
scope.launch {
state.attemptMove(dragOver)
}
}
val toStart =
if (state.gridState.layoutInfo.orientation == Orientation.Horizontal) draggedCenter.x else draggedCenter.y
if (toStart - state.gridState.layoutInfo.viewportStartOffset < scrollEdgeSize) {
scope.launch {
state.enableScrolling(-scrollDelta)
}
} else if (state.gridState.layoutInfo.viewportEndOffset - toStart < scrollEdgeSize) {
scope.launch {
state.enableScrolling(scrollDelta)
}
} else {
state.endScrolling()
}
state.draggedItemOffset?.let { state.onDrag(draggedItem, it, absPosition) }
}
},
onDragCancel = {
scope.launch { state.cancelDrag() }
},
onDragEnd = {
scope.launch { state.endDrag() }
},
)
}
@Composable
fun LazyGridItemScope.DraggableItem(
modifier: Modifier = Modifier,
state: LazyDragAndDropGridState,
key: Any?,
content: @Composable BoxScope.(isDragged: Boolean) -> Unit
) {
val isDragged = state.draggedItem?.key == key || state.droppedItemKey == key
Box(
modifier = modifier
.then(if (isDragged) Modifier else Modifier.animateItemPlacement())
.zIndex(if (isDragged) 1f else 0f)
.offset {
if (state.draggedItem?.key == key) {
state.draggedItemOffset?.toIntOffset() ?: IntOffset.Zero
} else if (state.droppedItemKey == key) {
state.droppedItemOffset.value
} else {
IntOffset.Zero
}
},
content = { content(isDragged) },
)
}
@@ -0,0 +1,358 @@
package de.mm20.launcher2.ui.launcher.helper
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.*
import androidx.compose.ui.zIndex
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.ktx.toIntOffset
import de.mm20.launcher2.ui.ktx.toPixels
import kotlinx.coroutines.*
import kotlinx.coroutines.android.awaitFrame
import kotlin.coroutines.coroutineContext
/**
* Create and remember a [LazyDragAndDropGridState]
* @param gridState the [LazyGridState] to use with the LazyGrid
* @param onDragStart callback that will be called when an item is picked up. If the return value
* is false, the drag operation will be canceled.
*/
@Composable
fun rememberLazyDragAndDropListState(
listState: LazyListState = rememberLazyListState(),
scrollEdgeSize: Dp = 32.dp,
scrollDelta: Dp = 128.dp,
onDragStart: (item: LazyListItemInfo) -> Boolean = { true },
onDrag: (item: LazyListItemInfo, offset: Offset) -> Unit = { _, _ -> },
onDragEnd: (item: LazyListItemInfo) -> Unit = {},
onDragCancel: (item: LazyListItemInfo) -> Unit = {},
onItemMove: (from: LazyListItemInfo, to: LazyListItemInfo) -> Unit,
): LazyDragAndDropListState {
val scrollDeltaPx = scrollDelta.toPixels()
val scrollEdgeSizePx = scrollEdgeSize.toPixels()
return remember {
LazyDragAndDropListState(
listState,
onDragStart,
onDrag,
onDragEnd,
onDragCancel,
onItemMove,
scrollDeltaPx,
scrollEdgeSizePx,
)
}
}
data class LazyDragAndDropListState(
val listState: LazyListState,
val onDragStart: (item: LazyListItemInfo) -> Boolean = { true },
val onDrag: (item: LazyListItemInfo, offset: Offset) -> Unit = { _, _ -> },
val onDragEnd: (item: LazyListItemInfo) -> Unit = {},
val onDragCancel: (item: LazyListItemInfo) -> Unit = {},
val onItemMove: (from: LazyListItemInfo, to: LazyListItemInfo) -> Unit,
private val scrollDelta: Float,
private val scrollEdgeSize: Float,
) {
var draggedItem by mutableStateOf<LazyListItemInfo?>(null)
var draggedItemAbsolutePosition by mutableStateOf<Offset?>(null)
val draggedItemOffset by derivedStateOf {
val absPos = draggedItemAbsolutePosition ?: return@derivedStateOf null
val key = draggedItem?.key ?: return@derivedStateOf null
val draggedItem = listState.layoutInfo.visibleItemsInfo.find {
it.key == key
} ?: return@derivedStateOf null
return@derivedStateOf absPos - draggedItem.offset.toOffset()
}
var droppedItemKey by mutableStateOf<Any?>(null)
val droppedItemOffset = mutableStateOf<IntOffset>(IntOffset.Zero)
private var currentDropPosition: Int? = null
private var dropJob: Job? = null
fun startDrag(offset: Offset): Boolean {
val draggedItem = listState.layoutInfo.visibleItemsInfo.find {
Rect(
(it.offset + listState.layoutInfo.beforeContentPadding).toOffset(),
it.size.toSize()
).contains(offset)
} ?: return false
if (!onDragStart(draggedItem)) return false
this.draggedItem = draggedItem
draggedItemAbsolutePosition = draggedItem.offset.toOffset()
return true
}
suspend fun drag(dragAmount: Offset) {
val absPosition = draggedItemAbsolutePosition
val draggedItem = draggedItem
if (absPosition != null && draggedItem != null) {
draggedItemAbsolutePosition = absPosition + dragAmount
val draggedCenter = Rect(absPosition, draggedItem.size.toSize()).center
val dragOver = listState.layoutInfo.visibleItemsInfo.find {
Rect(
it.offset.toOffset(),
it.size.toSize()
).contains(draggedCenter)
}
if (dragOver != null && dragOver.key != draggedItem.key) {
attemptMove(dragOver)
}
val toStart =
if (listState.layoutInfo.orientation == Orientation.Horizontal) draggedCenter.x else draggedCenter.y
if (toStart - listState.layoutInfo.viewportStartOffset < scrollEdgeSize) {
enableScrolling(-scrollDelta)
} else if (listState.layoutInfo.viewportEndOffset - toStart < scrollEdgeSize) {
enableScrolling(scrollDelta)
} else {
endScrolling()
}
draggedItemOffset?.let { onDrag(draggedItem, it) }
}
}
private fun Int.toOffset(): Offset {
return if (listState.layoutInfo.orientation == Orientation.Horizontal) {
Offset(this.toFloat(), 0f)
} else {
Offset(0f, this.toFloat())
}
}
private fun Int.toSize(): Size {
return if (listState.layoutInfo.orientation == Orientation.Horizontal) {
Size(this.toFloat(), listState.layoutInfo.viewportSize.height.toFloat())
} else {
Size(listState.layoutInfo.viewportSize.width.toFloat(), this.toFloat())
}
}
/**
* Move the currently dragged item to the specified drop target if the dragged item is held
* for at least 300ms over the drop target. The move operation is canceled if the dragged item
* is released or moved out of the dropTarget area during the 300ms time frame.
*/
private suspend fun attemptMove(dropTarget: LazyListItemInfo) {
if (currentDropPosition != dropTarget.index) {
coroutineScope {
dropJob?.cancelAndJoin()
dropJob = launch {
currentDropPosition = dropTarget.index
delay(300)
// Get a fresh copy of layout info because index in saved layout info might be outdated
val dragged =
listState.layoutInfo.visibleItemsInfo.find { it.key == draggedItem?.key }
if (dragged != null) {
onItemMove(dragged, dropTarget)
}
}
}
}
}
suspend fun cancelDrag() {
draggedItem?.let { onDragCancel(it) }
afterDragEnded()
}
suspend fun endDrag() {
draggedItem?.let { onDragEnd(it) }
afterDragEnded()
}
private suspend fun afterDragEnded() {
endScrolling()
val key = draggedItem?.key
val startOffset = draggedItemOffset
draggedItem = null
draggedItemAbsolutePosition = null
currentDropPosition = null
if (key == null || startOffset == null) return
droppedItemKey = key
droppedItemOffset.value = startOffset.toIntOffset()
droppedItemOffset.animateTo(IntOffset.Zero, IntOffset.VectorConverter)
droppedItemKey = null
}
private var scrollJob: Job? = null
private var currentScrollDelta = 0.0f
/**
* Scroll the lazy grid by `delta` px per second until [endScrolling] is called
*/
suspend fun enableScrolling(delta: Float) {
if (currentScrollDelta == delta) return
coroutineScope {
scrollJob?.cancelAndJoin()
scrollJob = launch {
currentScrollDelta = delta
delay(500)
var lastFrame = awaitFrame()
while (isActive) {
val frame = awaitFrame()
val timeDelta = frame - lastFrame
listState.scrollBy(delta * timeDelta / 1000_000_000f)
lastFrame = frame
}
}
}
}
/**
* Cancel scrolling
*/
fun endScrolling() {
currentScrollDelta = 0f
scrollJob?.cancel()
scrollJob = null
}
}
@Composable
fun LazyDragAndDropRow(
state: LazyDragAndDropListState,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
//reverseLayout: Boolean = false, //TODO: Fix reverse layout
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
bidirectionalDrag: Boolean = true,
content: LazyListScope.() -> Unit
) {
LazyRow(
modifier = modifier.dragAndDrop(
state,
isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
hapticFeedback = LocalHapticFeedback.current,
dragVertical = bidirectionalDrag,
dragHorizontal = true,
),
state = state.listState,
contentPadding = contentPadding,
reverseLayout = false,
verticalAlignment = verticalAlignment,
horizontalArrangement = horizontalArrangement,
flingBehavior = flingBehavior,
userScrollEnabled = userScrollEnabled,
content = content,
)
}
@Composable
fun LazyDragAndDropColumn(
state: LazyDragAndDropListState,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
//reverseLayout: Boolean = false, //TODO: Fix reverse layout
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
bidirectionalDrag: Boolean = true,
content: LazyListScope.() -> Unit
) {
LazyColumn(
modifier = modifier.dragAndDrop(
state,
isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
hapticFeedback = LocalHapticFeedback.current,
dragVertical = true,
dragHorizontal = bidirectionalDrag,
),
state = state.listState,
contentPadding = contentPadding,
reverseLayout = false,
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
flingBehavior = flingBehavior,
userScrollEnabled = userScrollEnabled,
content = content,
)
}
fun Modifier.dragAndDrop(
state: LazyDragAndDropListState,
isRtl: Boolean,
dragVertical: Boolean = true,
dragHorizontal: Boolean = true,
hapticFeedback: HapticFeedback
) =
this then pointerInput(null) {
val scope = CoroutineScope(coroutineContext)
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
if (state.startDrag(offset)) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
},
onDrag = { _, dragAmount ->
scope.launch {
state.drag(
when {
!dragVertical && !dragHorizontal -> Offset.Zero
dragVertical && !dragHorizontal -> Offset(0f, dragAmount.y)
!dragVertical && dragHorizontal && isRtl -> Offset(-dragAmount.x, 0f)
!dragVertical && dragHorizontal && !isRtl -> Offset(dragAmount.x, 0f)
dragVertical && dragHorizontal && isRtl -> Offset(-dragAmount.x, dragAmount.y)
else -> dragAmount
}
)
}
},
onDragCancel = {
scope.launch { state.cancelDrag() }
},
onDragEnd = {
scope.launch { state.endDrag() }
},
)
}
@Composable
fun LazyItemScope.DraggableItem(
modifier: Modifier = Modifier,
state: LazyDragAndDropListState,
key: Any?,
content: @Composable BoxScope.(isDragged: Boolean) -> Unit
) {
val isDragged = state.draggedItem?.key == key || state.droppedItemKey == key
Box(
modifier = modifier
.then(if (isDragged) Modifier else Modifier.animateItemPlacement())
.zIndex(if (isDragged) 1f else 0f)
.offset {
if (state.draggedItem?.key == key) {
state.draggedItemOffset?.toIntOffset() ?: IntOffset.Zero
} else if (state.droppedItemKey == key) {
state.droppedItemOffset.value
} else {
IntOffset.Zero
}
},
content = { content(isDragged) },
)
}
@@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.helper
import android.app.Activity
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ktx.isAtLeastApiLevel
@Composable
fun WallpaperBlur(blur: () -> Boolean) {
val context = LocalContext.current
val density = LocalDensity.current
val blurWallpaper = blur()
LaunchedEffect(blurWallpaper) {
if (!isAtLeastApiLevel(31)) return@LaunchedEffect
(context as Activity).window.attributes = context.window.attributes.also {
if (blurWallpaper) {
it.blurBehindRadius = with(density) { 32.dp.toPx().toInt() }
it.flags = it.flags or WindowManager.LayoutParams.FLAG_BLUR_BEHIND
} else {
it.blurBehindRadius = 0
it.flags = it.flags and WindowManager.LayoutParams.FLAG_BLUR_BEHIND.inv()
}
}
}
}
@@ -0,0 +1,570 @@
package de.mm20.launcher2.ui.launcher.search
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material.icons.rounded.Work
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SmallFloatingActionButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.PartialLauncherCard
import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorItem
import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem
import de.mm20.launcher2.ui.launcher.search.common.list.ListItem
import de.mm20.launcher2.ui.launcher.search.favorites.SearchFavoritesVM
import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterItem
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItem
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItem
import de.mm20.launcher2.ui.launcher.sheets.HiddenItemsSheet
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalGridSettings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlin.math.ceil
@Composable
fun SearchColumn(
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(0.dp),
state: LazyListState = rememberLazyListState(),
reverse: Boolean = false,
) {
val columns = LocalGridSettings.current.columnCount
val context = LocalContext.current
val viewModel: SearchVM = viewModel()
val favoritesVM: SearchFavoritesVM = viewModel()
val favorites by favoritesVM.favorites.collectAsState(emptyList())
var showWorkProfileApps by remember { mutableStateOf(false) }
val hideFavs by viewModel.hideFavorites.observeAsState(true)
val favoritesEnabled by viewModel.favoritesEnabled.collectAsState(false)
val apps by viewModel.appResults.observeAsState(emptyList())
val workApps by viewModel.workAppResults.observeAsState(emptyList())
val appShortcuts by viewModel.appShortcutResults.observeAsState(emptyList())
val contacts by viewModel.contactResults.observeAsState(emptyList())
val files by viewModel.fileResults.observeAsState(emptyList())
val events by viewModel.calendarResults.observeAsState(emptyList())
val unitConverter by viewModel.unitConverterResults.observeAsState(emptyList())
val calculator by viewModel.calculatorResults.observeAsState(emptyList())
val wikipedia by viewModel.wikipediaResults.observeAsState(emptyList())
val website by viewModel.websiteResults.observeAsState(emptyList())
val hiddenResults by viewModel.hiddenResults.observeAsState(emptyList())
val isSearchEmpty by viewModel.isSearchEmpty.observeAsState(true)
val missingCalendarPermission by viewModel.missingCalendarPermission.collectAsState(false)
val missingShortcutsPermission by viewModel.missingAppShortcutPermission.collectAsState(false)
val missingContactsPermission by viewModel.missingContactsPermission.collectAsState(false)
val missingFilesPermission by viewModel.missingFilesPermission.collectAsState(false)
val pinnedTags by favoritesVM.pinnedTags.collectAsState(emptyList())
val selectedTag by favoritesVM.selectedTag.collectAsState(null)
val tagsScrollState = rememberScrollState()
val favoritesEditButton by favoritesVM.showEditButton.collectAsState(false)
LazyColumn(
state = state,
modifier = modifier,
contentPadding = paddingValues,
reverseLayout = reverse,
) {
if (!hideFavs && favoritesEnabled) {
GridResults(
items = favorites.toImmutableList(),
columns = columns,
key = "favorites",
reverse = reverse,
before = if (favorites.isEmpty()) {
{
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(
if (selectedTag == null) R.string.favorites_empty else R.string.favorites_empty_tag
),
icon = if (selectedTag == null) Icons.Rounded.Star else Icons.Rounded.Tag,
)
}
} else null,
after = if (pinnedTags.isEmpty() && !favoritesEditButton) {
null
} else {
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = if (reverse) 8.dp else 4.dp,
bottom = if (reverse) 4.dp else 8.dp,
end = if (favoritesEditButton) 8.dp else 0.dp
),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(tagsScrollState)
.padding(end = 12.dp),
) {
FilterChip(
modifier = Modifier.padding(start = 16.dp),
selected = selectedTag == null,
onClick = { favoritesVM.selectTag(null) },
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Star,
contentDescription = null
)
},
label = { Text(stringResource(R.string.favorites)) }
)
for (tag in pinnedTags) {
FilterChip(
modifier = Modifier.padding(start = 8.dp),
selected = selectedTag == tag.tag,
onClick = { favoritesVM.selectTag(tag.tag) },
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Tag,
contentDescription = null
)
},
label = { Text(tag.label) }
)
}
}
if (favoritesEditButton) {
val sheetManager = LocalBottomSheetManager.current
SmallFloatingActionButton(
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
onClick = { sheetManager.showEditFavoritesSheet() }
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = null
)
}
}
}
}
}
)
}
GridResults(
items = if (showWorkProfileApps && workApps.isNotEmpty()) workApps.toImmutableList() else apps.toImmutableList(),
columns = columns,
reverse = reverse,
key = "apps",
before = if (workApps.isNotEmpty()) {
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(
top = if (reverse) 4.dp else 8.dp,
bottom = if (reverse) 8.dp else 4.dp
),
) {
FilterChip(
modifier = Modifier.padding(horizontal = 8.dp),
selected = !showWorkProfileApps,
onClick = { showWorkProfileApps = false },
leadingIcon = {
Icon(imageVector = Icons.Rounded.Person, contentDescription = null)
},
label = {
Text(
stringResource(R.string.apps_profile_main),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
FilterChip(
selected = showWorkProfileApps,
onClick = { showWorkProfileApps = true },
leadingIcon = {
Icon(imageVector = Icons.Rounded.Work, contentDescription = null)
},
label = {
Text(
stringResource(R.string.apps_profile_work),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
} else null
)
ListResults(
before = if (missingShortcutsPermission && !isSearchEmpty) {
{
MissingPermissionBanner(
modifier = Modifier.padding(8.dp),
text = stringResource(
R.string.missing_permission_appshortcuts_search,
stringResource(R.string.app_name)
),
onClick = { viewModel.requestAppShortcutPermission(context as AppCompatActivity) },
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableAppShortcutSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
} else null,
items = appShortcuts.toImmutableList(),
reverse = reverse,
key = "shortcuts"
)
for (conv in unitConverter) {
SingleResult {
UnitConverterItem(unitConverter = conv)
}
}
for (calc in calculator) {
SingleResult {
CalculatorItem(calculator = calc)
}
}
ListResults(
before = if (missingCalendarPermission && !isSearchEmpty) {
{
MissingPermissionBanner(
modifier = Modifier.padding(8.dp),
text = stringResource(R.string.missing_permission_calendar_search),
onClick = { viewModel.requestCalendarPermission(context as AppCompatActivity) },
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableCalendarSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
} else null,
items = events.toImmutableList(),
reverse = reverse,
key = "events"
)
ListResults(
before = if (missingContactsPermission && !isSearchEmpty) {
{
MissingPermissionBanner(
modifier = Modifier.padding(8.dp),
text = stringResource(R.string.missing_permission_contact_search),
onClick = { viewModel.requestContactsPermission(context as AppCompatActivity) },
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableContactsSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
} else null,
items = contacts.toImmutableList(),
reverse = reverse,
key = "contacts"
)
for (wiki in wikipedia) {
SingleResult {
WikipediaItem(wikipedia = wiki)
}
}
for (ws in website) {
SingleResult {
WebsiteItem(website = ws)
}
}
ListResults(
before = if (missingFilesPermission && !isSearchEmpty) {
{
MissingPermissionBanner(
modifier = Modifier.padding(8.dp),
text = stringResource(R.string.missing_permission_files_search),
onClick = { viewModel.requestFilesPermission(context as AppCompatActivity) },
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableFilesSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
} else null,
items = files.toImmutableList(),
reverse = reverse,
key = "files"
)
}
val sheetManager = LocalBottomSheetManager.current
if (sheetManager.hiddenItemsSheetShown.value) {
HiddenItemsSheet(items = hiddenResults, onDismiss = { sheetManager.dismissHiddenItemsSheet() })
}
}
fun LazyListScope.GridResults(
items: ImmutableList<SavableSearchable>,
columns: Int,
reverse: Boolean,
key: String,
before: (@Composable () -> Unit)? = null,
after: (@Composable () -> Unit)? = null,
) {
if (items.isEmpty() && before == null && after == null) return
if (before != null) {
item(key = "$key-before") {
PartialCardRow(
isFirst = true,
isLast = items.isEmpty() && after == null,
reverse = reverse
) {
before()
}
}
}
val rows = ceil(items.size / columns.toFloat()).toInt()
items(
rows,
key = {
"$key-${items[it * columns].key}"
}
) {
PartialCardRow(
isFirst = it == 0 && before == null,
isLast = it == rows - 1 && after == null,
reverse = reverse
) {
GridRow(
modifier = Modifier.padding(
top = if (if (reverse) it == rows - 1 else it == 0) 4.dp else 0.dp,
bottom = if (if (reverse) it == 0 else it == rows - 1) 2.dp else 0.dp,
),
items = items.subList(
it * columns,
(it * columns + columns).coerceAtMost(items.size)
),
columns = columns,
)
}
}
if (after != null) {
item(key = "$key-after") {
PartialCardRow(
isFirst = items.isEmpty() && before == null,
isLast = true,
reverse = reverse
) {
after()
}
}
}
}
@Composable
fun GridRow(
modifier: Modifier = Modifier,
items: ImmutableList<SavableSearchable>,
columns: Int,
showLabels: Boolean = LocalGridSettings.current.showLabels,
) {
Row(
modifier = modifier
) {
for (item in items) {
GridItem(
modifier = Modifier
.weight(1f)
.padding(4.dp, 8.dp),
item = item,
showLabels = showLabels
)
}
for (i in 0 until columns - items.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
fun LazyListScope.ListResults(
items: ImmutableList<SavableSearchable>,
reverse: Boolean,
key: String,
before: (@Composable () -> Unit)? = null,
after: (@Composable () -> Unit)? = null,
) {
if (before != null) {
item(key = "$key-before") {
PartialCardRow(
isFirst = true,
isLast = items.isEmpty() && after == null,
reverse = reverse
) {
before()
}
}
}
items(
items.size,
key = {
"$key-${items[it].key}"
}
) {
PartialCardRow(
isFirst = it == 0 && before == null,
isLast = it == items.lastIndex && after == null,
reverse = reverse
) {
ListRow(
modifier = Modifier.padding(
top = if (if (reverse) it == items.size - 1 else it == 0) 8.dp else 4.dp,
bottom = if (if (reverse) it == 0 else it == items.size - 1) 8.dp else 4.dp,
),
item = items[it],
)
}
}
if (after != null) {
item(key = "$key-after") {
PartialCardRow(
isFirst = items.isEmpty() && before == null,
isLast = true,
reverse = reverse
) {
after()
}
}
}
}
@Composable
fun ListRow(
modifier: Modifier = Modifier,
item: SavableSearchable,
) {
Box(
modifier = modifier.padding(
start = 8.dp,
end = 8.dp,
)
) {
ListItem(
modifier = Modifier
.fillMaxWidth(),
item = item
)
}
}
fun LazyListScope.SingleResult(content: @Composable (() -> Unit)?) {
if (content == null) return
item {
LauncherCard(
modifier = Modifier
.padding(
horizontal = 8.dp,
vertical = 4.dp,
)
.animateItemPlacement()
) {
content()
}
}
}
@Composable
fun LazyItemScope.PartialCardRow(
modifier: Modifier = Modifier,
isFirst: Boolean,
isLast: Boolean,
reverse: Boolean,
content: @Composable () -> Unit
) {
val isTop = isFirst && !reverse || isLast && reverse
val isBottom = isLast && !reverse || isFirst && reverse
Box(
modifier = modifier
.clipToBounds()
.animateItemPlacement()
) {
PartialLauncherCard(
modifier = Modifier.padding(
start = 8.dp,
end = 8.dp,
top = if (isTop) 4.dp else 0.dp,
bottom = if (isBottom) 4.dp else 0.dp,
),
isTop = isTop,
isBottom = isBottom,
) {
content()
}
}
}
@@ -0,0 +1,228 @@
package de.mm20.launcher2.ui.launcher.search
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.Calculator
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.UnitConverter
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.searchactions.actions.SearchAction
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class SearchVM : ViewModel(), KoinComponent {
private val favoritesRepository: FavoritesRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val dataStore: LauncherDataStore by inject()
private val searchService: SearchService by inject()
val isSearching = MutableLiveData(false)
val searchQuery = MutableLiveData<String>("")
val isSearchEmpty = MutableLiveData(true)
val appResults = MutableLiveData<List<LauncherApp>>(emptyList())
val workAppResults = MutableLiveData<List<LauncherApp>>(emptyList())
val appShortcutResults = MutableLiveData<List<AppShortcut>>(emptyList())
val fileResults = MutableLiveData<List<File>>(emptyList())
val contactResults = MutableLiveData<List<Contact>>(emptyList())
val calendarResults = MutableLiveData<List<CalendarEvent>>(emptyList())
val wikipediaResults = MutableLiveData<List<Wikipedia>>(emptyList())
val websiteResults = MutableLiveData<List<Website>>(emptyList())
val calculatorResults = MutableLiveData<List<Calculator>>(emptyList())
val unitConverterResults = MutableLiveData<List<UnitConverter>>(emptyList())
val searchActionResults = MutableLiveData<List<SearchAction>>(emptyList())
val hiddenResults = MutableLiveData<List<SavableSearchable>>(emptyList())
val favoritesEnabled = dataStore.data.map { it.favorites.enabled }
val hideFavorites = MutableLiveData(false)
private val hiddenItemKeys = favoritesRepository
.getHiddenItemKeys()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
init {
search("", true)
}
var searchJob: Job? = null
fun search(query: String, forceRestart: Boolean = false) {
if (searchQuery.value == query && !forceRestart) return
searchQuery.value = query
isSearchEmpty.value = query.isEmpty()
hiddenResults.value = emptyList()
try {
searchJob?.cancel()
} catch (_: CancellationException) {
}
hideFavorites.postValue(query.isNotEmpty())
searchJob = viewModelScope.launch {
isSearching.postValue(true)
dataStore.data.collectLatest {
searchService.search(
query,
calculator = it.calculatorSearch,
unitConverter = it.unitConverterSearch,
calendars = it.calendarSearch,
contacts = it.contactsSearch,
files = it.fileSearch,
shortcuts = it.appShortcutSearch,
websites = it.websiteSearch,
wikipedia = it.wikipediaSearch,
).collectLatest { results ->
hiddenItemKeys.collectLatest { hiddenKeys ->
val hidden = mutableListOf<SavableSearchable>()
val apps = mutableListOf<LauncherApp>()
val workApps = mutableListOf<LauncherApp>()
val shortcuts = mutableListOf<AppShortcut>()
val files = mutableListOf<File>()
val contacts = mutableListOf<Contact>()
val events = mutableListOf<CalendarEvent>()
val unitConv = mutableListOf<UnitConverter>()
val calc = mutableListOf<Calculator>()
val wikipedia = mutableListOf<Wikipedia>()
val website = mutableListOf<Website>()
val actions = mutableListOf<SearchAction>()
for (r in results) {
when {
r is SavableSearchable && hiddenKeys.contains(r.key) -> {
hidden.add(r)
}
r is LauncherApp && !r.isMainProfile -> workApps.add(r)
r is LauncherApp -> apps.add(r)
r is AppShortcut -> shortcuts.add(r)
r is File -> files.add(r)
r is Contact -> contacts.add(r)
r is CalendarEvent -> events.add(r)
r is UnitConverter -> unitConv.add(r)
r is Calculator -> calc.add(r)
r is Website -> website.add(r)
r is Wikipedia -> wikipedia.add(r)
r is SearchAction -> actions.add(r)
}
}
searchActionResults.value = actions
appResults.value = apps
workAppResults.value = workApps
appShortcutResults.value = shortcuts
fileResults.value = files
contactResults.value = contacts
calendarResults.value = events
wikipediaResults.value = wikipedia
websiteResults.value = website
calculatorResults.value = calc
unitConverterResults.value = unitConv
hiddenResults.value = hidden
}
}
}
isSearching.postValue(false)
}
}
val missingCalendarPermission = combine(
permissionsManager.hasPermission(PermissionGroup.Calendar),
dataStore.data.map { it.calendarSearch.enabled }.distinctUntilChanged()
) { perm, enabled -> !perm && enabled }
fun requestCalendarPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.Calendar)
}
fun disableCalendarSearch() {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setCalendarSearch(it.calendarSearch.toBuilder().setEnabled(false))
.build()
}
}
}
val missingContactsPermission = combine(
permissionsManager.hasPermission(PermissionGroup.Contacts),
dataStore.data.map { it.contactsSearch.enabled }.distinctUntilChanged()
) { perm, enabled -> !perm && enabled }
fun requestContactsPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.Contacts)
}
fun disableContactsSearch() {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setContactsSearch(it.contactsSearch.toBuilder().setEnabled(false))
.build()
}
}
}
val missingFilesPermission = combine(
permissionsManager.hasPermission(PermissionGroup.ExternalStorage),
dataStore.data.map { it.fileSearch.localFiles }.distinctUntilChanged()
) { perm, enabled -> !perm && enabled }
fun requestFilesPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.ExternalStorage)
}
fun disableFilesSearch() {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFileSearch(it.fileSearch.toBuilder().setLocalFiles(false))
.build()
}
}
}
val missingAppShortcutPermission = combine(
permissionsManager.hasPermission(PermissionGroup.AppShortcuts),
dataStore.data.map { it.appShortcutSearch.enabled }.distinctUntilChanged()
) { perm, enabled -> !perm && enabled }
fun requestAppShortcutPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.AppShortcuts)
}
fun disableAppShortcutSearch() {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setAppShortcutSearch(it.appShortcutSearch.toBuilder().setEnabled(false))
.build()
}
}
}
}
@@ -0,0 +1,374 @@
package de.mm20.launcher2.ui.launcher.search.apps
import androidx.compose.animation.*
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
import androidx.lifecycle.lifecycleScope
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import de.mm20.launcher2.search.data.LauncherApp
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.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import kotlin.math.pow
import kotlin.math.roundToInt
@Composable
fun AppItem(
modifier: Modifier = Modifier,
app: LauncherApp,
onBack: () -> Unit
) {
val viewModel = remember { AppItemVM(app) }
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
Column(
modifier = modifier
) {
Row {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
Text(
text = app.labelOverride ?: app.label,
style = MaterialTheme.typography.titleMedium
)
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
app.version?.let {
Text(
text = stringResource(R.string.app_info_version, it),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = app.`package`,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 1.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
modifier = Modifier
.padding(top = 12.dp)
.animateContentSize(),
mainAxisSpacing = 12.dp,
crossAxisSpacing = 0.dp
) {
val notifications by viewModel.notifications.collectAsState(initial = emptyList())
for (not in notifications) {
val title =
not.notification.extras.getString(NotificationCompat.EXTRA_TITLE, null)
?.takeIf { it.isNotBlank() }
?: not.notification.extras.getString(
NotificationCompat.EXTRA_TEXT,
null
)
?.takeIf { it.isNotBlank() }
?: continue
val icon =
remember { not.notification.smallIcon?.loadDrawable(context) }?.let {
rememberAsyncImagePainter(
it
)
}
Chip(
text = title,
icon = icon,
rightIcon = Icons.Rounded.Clear,
rightAction = {
viewModel.clearNotification(not)
},
onClick = {
viewModel.openNotification(not)
}
)
}
val shortcuts by viewModel.shortcuts.collectAsState(emptyList())
for (shortcut in shortcuts) {
val title =
shortcut.launcherShortcut.shortLabel
?: shortcut.launcherShortcut.longLabel
?: continue
val isPinned by remember(shortcut) { viewModel.isShortcutPinned(shortcut) }.collectAsState(
false
)
val icon =
remember {
viewModel.getShortcutIcon(
context,
shortcut.launcherShortcut
)
}
?.let {
rememberAsyncImagePainter(it)
}
Chip(
text = title.toString(),
icon = icon,
rightIcon = if (LocalFavoritesEnabled.current) {
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline
} else null,
rightAction = {
if (isPinned) {
viewModel.unpinShortcut(shortcut)
} else {
viewModel.pinShortcut(shortcut)
}
},
onClick = {
viewModel.launchShortcut(context, shortcut)
}
)
}
}
}
val badge by viewModel.badge.collectAsState(null)
val iconSize = 84.dp.toPixels().toInt()
val icon by remember(app) { viewModel.getIcon(iconSize) }.collectAsState(null)
ShapedLauncherIcon(
size = 84.dp,
modifier = Modifier
.padding(16.dp),
badge = { badge },
icon = { icon },
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
viewModel.openAppInfo(context)
})
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_launch),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(app) }
))
val storeDetails = remember(app) { app.getStoreDetails(context) }
val shareAction = if (storeDetails == null) {
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share
) {
scope.launch {
viewModel.shareApkFile(context)
}
}
} else {
SubmenuToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
children = listOf(
DefaultToolbarAction(
label = stringResource(R.string.menu_share_store_link, storeDetails.label),
icon = Icons.Rounded.Link,
action = {
viewModel.shareStoreLink(context, storeDetails.url)
}
),
DefaultToolbarAction(
label = stringResource(R.string.menu_share_apk_file),
icon = Icons.Rounded.Android
) {
scope.launch {
viewModel.shareApkFile(context)
}
}
)
)
}
toolbarActions.add(shareAction)
if (viewModel.canUninstall) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_uninstall),
icon = Icons.Rounded.Delete,
) {
viewModel.uninstall(context)
onBack()
}
)
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.msg_item_hidden, app.label),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
viewModel.unhide()
}
}
})
}
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
@Composable
fun AppItemGridPopup(
app: LauncherApp,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
slideInHorizontally(
tween(300),
initialOffsetX = { -it + origin.width.roundToInt() }) with
slideOutHorizontally(
tween(300),
targetOffsetX = { -it + origin.width.roundToInt() }) + fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
AppItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 16.dp * (1 - animationProgress).pow(10),
y = -16.dp * (1 - animationProgress),
),
app = app,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
@@ -0,0 +1,137 @@
package de.mm20.launcher2.ui.launcher.search.apps
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.*
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Process
import android.service.notification.StatusBarNotification
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.inject
class AppItemVM(
private val app: LauncherApp
) : SearchableItemVM(app) {
private val notificationRepository: NotificationRepository by inject()
private val appShortcutRepository: AppShortcutRepository by inject()
val notifications =
notificationRepository.notifications.map { it.filter { it.packageName == app.`package` } }
fun clearNotification(notification: StatusBarNotification) {
notificationRepository.cancelNotification(notification)
}
fun openAppInfo(context: Context) {
val launcherApps = context.getSystemService<LauncherApps>()!!
launcherApps.startAppDetailsActivity(
ComponentName(app.`package`, app.activity),
app.getUser(),
null,
null
)
}
suspend fun shareApkFile(context: Context) {
val launcherApps = context.getSystemService<LauncherApps>()!!
val fileCopy = java.io.File(
context.cacheDir,
"${app.`package`}-${app.version}.apk"
)
withContext(Dispatchers.IO) {
try {
val user = (app as? LauncherApp)?.getUser()
val info = if (user != null) {
launcherApps.getApplicationInfo(app.`package`, 0, user)
} else {
context.packageManager.getApplicationInfo(app.`package`, 0)
}
val file = java.io.File(info.publicSourceDir)
try {
file.copyTo(fileCopy, false)
} catch (e: FileAlreadyExistsException) {
// Do nothing. If the file is already there we don't have to copy it again.
}
} catch (e: PackageManager.NameNotFoundException) {
CrashReporter.logException(e)
}
}
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
fileCopy
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = "application/vnd.android.package-archive"
withContext(Dispatchers.Main) {
context.startActivity(Intent.createChooser(shareIntent, null))
}
}
fun shareStoreLink(context: Context, url: String) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, url)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
val canUninstall = app.flags and ApplicationInfo.FLAG_SYSTEM == 0 && (app as? LauncherApp)?.getUser() == Process.myUserHandle()
fun uninstall(context: Context) {
val intent = Intent(Intent.ACTION_DELETE)
intent.data = Uri.parse("package:" + app.`package`)
context.startActivity(intent)
}
fun openNotification(notification: StatusBarNotification) {
try {
notification.notification.contentIntent?.send()
} catch (e: PendingIntent.CanceledException) {}
}
fun getShortcutIcon(context: Context, shortcut: ShortcutInfo) : Drawable? {
val launcherApps = context.getSystemService<LauncherApps>() ?: return null
return launcherApps.getShortcutIconDrawable(shortcut, 0)
}
val shortcuts = flow {
emit(appShortcutRepository.getShortcutsForActivity(app.launcherActivityInfo, 5))
}
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> {
return favoritesRepository.isPinned(shortcut)
}
fun pinShortcut(shortcut: AppShortcut) {
favoritesRepository.pinItem(shortcut)
}
fun unpinShortcut(shortcut: AppShortcut) {
favoritesRepository.unpinItem(shortcut)
}
fun launchShortcut(context: Context, shortcut: AppShortcut) {
shortcut.launch(context, null)
}
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.ui.launcher.search.apps
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
@Composable
fun AppResults(reverse: Boolean = false) {
val viewModel: SearchVM = viewModel()
val apps by viewModel.appResults.observeAsState(emptyList())
if (apps.isNotEmpty()) {
LauncherCard(
modifier = Modifier
.padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp)
) {
SearchResultGrid(items = apps, reverse = reverse)
}
}
}
@@ -0,0 +1,69 @@
package de.mm20.launcher2.ui.launcher.search.appshortcuts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.AppShortcutResults(reverse: Boolean = false) {
val viewModel: SearchVM = viewModel()
val shortcuts by viewModel.appShortcutResults.observeAsState(emptyList())
val context = LocalContext.current
val isSearchEmpty by viewModel.isSearchEmpty.observeAsState(true)
val missingPermission by viewModel.missingAppShortcutPermission.collectAsState(false)
AnimatedVisibility(shortcuts.isNotEmpty() || (!isSearchEmpty && missingPermission)) {
LauncherCard(
modifier = Modifier
.padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp)
) {
Column {
AnimatedVisibility(!isSearchEmpty && missingPermission) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_appshortcuts_search, stringResource(R.string.app_name)),
onClick = { viewModel.requestAppShortcutPermission(context as AppCompatActivity) },
modifier = Modifier.padding(16.dp),
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableAppShortcutSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
}
SearchResultList(
items = shortcuts,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
reverse = reverse
)
}
}
}
@@ -0,0 +1,59 @@
package de.mm20.launcher2.ui.launcher.search.calculator
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.Calculator
@Composable
fun CalculatorItem(
calculator: Calculator
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = calculator.getBeatifiedTerm(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.secondary
)
}
Text(
text = "= ${calculator.formattedString}",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.End),
)
if (calculator.term.matches(Regex("(0x|0b)?[0-9]+"))) {
Text(
calculator.formattedBinaryString,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier
.align(Alignment.End)
.padding(top = 8.dp),
)
Text(
calculator.formattedHexString,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.align(Alignment.End),
)
Text(
calculator.formattedOctString,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.align(Alignment.End),
)
}
}
}
@@ -0,0 +1,299 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import kotlinx.coroutines.launch
import palettes.TonalPalette
@Composable
fun CalendarItem(
modifier: Modifier = Modifier,
calendar: CalendarEvent,
showDetails: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(calendar.key) { CalendarItemVM(calendar) }
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val darkMode = LocalDarkTheme.current
Row(
modifier = modifier
.drawBehind {
val color = TonalPalette.fromInt(calendar.color).tone(
if (darkMode) 80 else 40
)
drawRect(Color(color), Offset.Zero, this.size.copy(width = 8.dp.toPx()))
}
.padding(start = 8.dp),
) {
Column(
modifier = Modifier.weight(1f)
) {
Row {
val padding by animateDpAsState(if (showDetails) 16.dp else 12.dp)
Column(
modifier = Modifier.padding(
top = padding,
start = padding,
bottom = 12.dp,
end = padding
)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.titleSmall
)
Text(text = calendar.labelOverride ?: calendar.label, style = textStyle)
AnimatedVisibility(!showDetails) {
Text(
modifier = Modifier.padding(top = 2.dp),
text = viewModel.getSummary(context),
style = MaterialTheme.typography.bodySmall
)
}
AnimatedVisibility(showDetails) {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
AnimatedVisibility(showDetails) {
Column {
Row(
Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.Schedule,
contentDescription = null
)
Text(
text = viewModel.formatTime(context),
style = MaterialTheme.typography.bodySmall
)
}
if (calendar.description.isNotBlank()) {
Row(
Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.Notes,
contentDescription = null
)
Text(
text = calendar.description,
style = MaterialTheme.typography.bodySmall
)
}
}
if (calendar.attendees.isNotEmpty()) {
Row(
Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.People,
contentDescription = null
)
Text(
text = calendar.attendees.joinToString(),
style = MaterialTheme.typography.bodySmall
)
}
}
if (calendar.location.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.openLocation(context)
}
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.Place,
contentDescription = null
)
Text(
text = calendar.location,
style = MaterialTheme.typography.bodySmall
)
}
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.msg_item_hidden, calendar.label),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)
if(result == SnackbarResult.ActionPerformed) {
viewModel.unhide()
}
}
})
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_calendar_open_externally),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context)
onBack()
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(calendar) }
))
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
}
}
}
@Composable
fun CalendarItemGridPopup(
calendar: CalendarEvent,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(snap()) with
fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
CalendarItem(
modifier = Modifier
.fillMaxWidth()
.background(Color(calendar.color).copy(alpha = 1f - animationProgress)),
calendar = calendar,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
@@ -0,0 +1,82 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.format.DateUtils
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import java.net.URLEncoder
class CalendarItemVM(
private val calendarEvent: CalendarEvent
) : SearchableItemVM(calendarEvent) {
fun getSummary(context: Context): String {
val isToday =
DateUtils.isToday(calendarEvent.startTime) && DateUtils.isToday(calendarEvent.endTime)
return if (isToday) {
if (calendarEvent.allDay) {
context.getString(R.string.calendar_event_allday)
} else {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_TIME
)
}
} else {
if (calendarEvent.allDay) {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE
)
} else {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
)
}
}
}
fun formatTime(context: Context): String {
if (calendarEvent.allDay) return DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY
)
return DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_WEEKDAY
)
}
fun openLocation(context: Context) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(
Uri.parse(
"geo:0,0?q=${
URLEncoder.encode(
calendarEvent.location,
"utf8"
)
}"
)
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
@@ -0,0 +1,69 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.CalendarResults(reverse: Boolean = false) {
val viewModel: SearchVM = viewModel()
val calendarEvents by viewModel.calendarResults.observeAsState(emptyList())
val context = LocalContext.current
val isSearchEmpty by viewModel.isSearchEmpty.observeAsState(true)
val missingPermission by viewModel.missingCalendarPermission.collectAsState(false)
AnimatedVisibility(calendarEvents.isNotEmpty() || (!isSearchEmpty && missingPermission)) {
LauncherCard(
modifier = Modifier
.padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp)
.fillMaxWidth()
) {
Column {
AnimatedVisibility(!isSearchEmpty && missingPermission) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_calendar_search),
onClick = { viewModel.requestCalendarPermission(context as AppCompatActivity) },
modifier = Modifier.padding(16.dp),
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableCalendarSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
SearchResultList(
items = calendarEvents,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
reverse = reverse
)
}
}
}
}
@@ -0,0 +1,82 @@
package de.mm20.launcher2.ui.launcher.search.common
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect
import androidx.core.app.ActivityOptionsCompat
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
abstract class SearchableItemVM(
private val searchable: SavableSearchable
) : KoinComponent {
protected val favoritesRepository: FavoritesRepository by inject()
protected val badgeRepository: BadgeRepository by inject()
protected val iconRepository: IconRepository by inject()
protected val customAttributesRepository: CustomAttributesRepository by inject()
val isPinned = favoritesRepository.isPinned(searchable)
fun pin() {
favoritesRepository.pinItem(searchable)
}
fun unpin() {
favoritesRepository.unpinItem(searchable)
}
val isHidden = favoritesRepository.isHidden(searchable)
fun hide() {
favoritesRepository.hideItem(searchable)
}
fun unhide() {
favoritesRepository.unhideItem(searchable)
}
val badge = badgeRepository.getBadge(searchable)
fun getIcon(size: Int): Flow<LauncherIcon> {
return iconRepository.getIcon(searchable, size)
}
fun getTags(): Flow<List<String>> {
return customAttributesRepository.getTags(searchable)
}
open fun launch(context: Context, bounds: Rect? = null): Boolean {
val view = (context as? AppCompatActivity)?.window?.decorView
val options = if (bounds != null && view != null) {
ActivityOptionsCompat.makeClipRevealAnimation(
view,
bounds.left.toInt(),
bounds.top.toInt(),
bounds.width.toInt(),
bounds.height.toInt()
)
} else {
ActivityOptionsCompat.makeBasic()
}
val bundle = options.toBundle()
if (isAtLeastApiLevel(31)) {
bundle?.putInt("android.activity.splashScreenStyle", 1)
}
if (searchable.launch(context, bundle)) {
favoritesRepository.incrementLaunchCounter(searchable)
return true
} else if (searchable is LauncherApp || searchable is AppShortcut) {
favoritesRepository.remove(searchable)
}
return false
}
}
@@ -0,0 +1,246 @@
package de.mm20.launcher2.ui.launcher.search.common.grid
import android.content.ComponentName
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.apps.AppItemGridPopup
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItemGridPopup
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItemGridPopup
import de.mm20.launcher2.ui.launcher.search.files.FileItemGridPopup
import de.mm20.launcher2.ui.launcher.search.shortcut.ShortcutItemGridPopup
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItemGridPopup
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItemGridPopup
import de.mm20.launcher2.ui.launcher.transitions.HandleHomeTransition
import de.mm20.launcher2.ui.launcher.transitions.HomeTransitionParams
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalWindowPosition
import de.mm20.launcher2.ui.locals.LocalWindowSize
import kotlinx.coroutines.delay
@Composable
fun GridItem(modifier: Modifier = Modifier, item: SavableSearchable, showLabels: Boolean = true) {
val viewModel = remember(item.key) { GridItemVM(item) }
val context = LocalContext.current
var showPopup by remember(item.key) { mutableStateOf(false) }
var bounds by remember { mutableStateOf(Rect.Zero) }
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
val badge by remember(item.key) { viewModel.badge }.collectAsState(null)
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
val icon by remember(item.key) { viewModel.getIcon(iconSize.toInt()) }.collectAsState(null)
val launchOnPress = !item.preferDetailsOverLaunch
val windowSize = LocalWindowSize.current
if (item is LauncherApp) {
HandleHomeTransition {
val cn = ComponentName(item.`package`, item.activity)
if (
it.componentName == cn &&
bounds.right > 0f && bounds.left < windowSize.width &&
bounds.bottom > 0f && bounds.top < windowSize.height
) {
return@HandleHomeTransition HomeTransitionParams(
bounds
) { _, _ ->
ShapedLauncherIcon(size = LocalGridSettings.current.iconSize.dp, icon = { icon })
}
}
return@HandleHomeTransition null
}
}
val hapticFeedback = LocalHapticFeedback.current
ShapedLauncherIcon(
modifier = Modifier
.onGloballyPositioned {
bounds = it.boundsInWindow()
},
size = LocalGridSettings.current.iconSize.dp,
badge = { badge },
icon = { icon },
onClick = {
if (!launchOnPress || !viewModel.launch(context, bounds)) {
showPopup = true
}
},
onLongClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
showPopup = true
}
)
if (showLabels) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
text = item.labelOverride ?: item.label,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (showPopup) {
ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false })
}
}
}
@Composable
fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit) {
var show by remember { mutableStateOf(false) }
LaunchedEffect(null) {
show = true
}
LaunchedEffect(show) {
if (!show) {
delay(300L)
onDismissRequest()
}
}
BackHandler {
show = false
}
val animationProgress by animateFloatAsState(if (show) 1f else 0f, tween(300))
Popup(
properties = PopupProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
),
alignment = Alignment.TopCenter,
onDismissRequest = {
show = false
},
offset = IntOffset(-origin.left.toInt(), 0)
) {
CompositionLocalProvider(LocalWindowPosition provides origin.top) {
Box(
modifier = Modifier
.fillMaxWidth()
) {
LauncherCard(
elevation = 8.dp * animationProgress,
backgroundOpacity = 1f,
modifier = Modifier
.padding(horizontal = 16.dp)
.absoluteOffset(
x = ((1 - animationProgress) * origin.left).toDp() - 16.dp * (1 - animationProgress),
)
.wrapContentSize()
) {
when (searchable) {
is LauncherApp -> {
AppItemGridPopup(
app = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is Website -> {
WebsiteItemGridPopup(
website = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is Wikipedia -> {
WikipediaItemGridPopup(
wikipedia = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is Contact -> {
ContactItemGridPopup(
contact = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is File -> {
FileItemGridPopup(
file = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is CalendarEvent -> {
CalendarItemGridPopup(
calendar = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is AppShortcut -> {
ShortcutItemGridPopup(
shortcut = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
}
}
}
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.ui.launcher.search.common.grid
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class GridItemVM(
searchable: SavableSearchable
): SearchableItemVM(searchable)
@@ -0,0 +1,47 @@
package de.mm20.launcher2.ui.launcher.search.common.grid
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalGridSettings
import kotlin.math.ceil
@Composable
fun SearchResultGrid(
items: List<SavableSearchable>,
modifier: Modifier = Modifier,
showLabels: Boolean = LocalGridSettings.current.showLabels,
columns: Int = LocalGridSettings.current.columnCount,
reverse: Boolean = false
) {
Column(
modifier = modifier
.animateContentSize()
.fillMaxWidth()
.padding(4.dp),
verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top
) {
for (i in 0 until ceil(items.size / columns.toFloat()).toInt()) {
Row {
for (j in 0 until columns) {
val item = items.getOrNull(i * columns + j)
if (item != null) {
GridItem(
modifier = Modifier
.weight(1f)
.padding(4.dp, 8.dp),
item = item,
showLabels = showLabels
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@@ -0,0 +1,101 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.ui.component.InnerCard
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem
import de.mm20.launcher2.ui.launcher.search.files.FileItem
import de.mm20.launcher2.ui.launcher.search.shortcut.AppShortcutItem
@Composable
fun ListItem(modifier: Modifier = Modifier, item: SavableSearchable) {
var showDetails by remember { mutableStateOf(false) }
val context = LocalContext.current
val viewModel = remember(item.key) { ListItemVM(item) }
var bounds by remember { mutableStateOf(Rect.Zero) }
InnerCard(
modifier = modifier
.onGloballyPositioned {
bounds = it.boundsInWindow()
},
raised = showDetails
) {
when (item) {
is Contact -> {
ContactItem(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = !showDetails,
onClick = { showDetails = true },
onLongClick = { showDetails = true }
),
contact = item,
showDetails = showDetails,
onBack = { showDetails = false }
)
}
is File -> {
FileItem(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = !showDetails,
onClick = {
if (!viewModel.launch(context, bounds)) {
showDetails = true
}
},
onLongClick = { showDetails = true }
),
file = item,
showDetails = showDetails,
onBack = { showDetails = false }
)
}
is CalendarEvent -> {
CalendarItem(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = !showDetails,
onClick = { showDetails = true },
onLongClick = { showDetails = true }
),
calendar = item,
showDetails = showDetails,
onBack = { showDetails = false }
)
}
is AppShortcut -> {
AppShortcutItem(
shortcut = item,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = !showDetails,
onClick = {
if (!viewModel.launch(context, bounds)) {
showDetails = true
}
},
onLongClick = { showDetails = true }
),
showDetails = showDetails,
onBack = { showDetails = false }
)
}
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class ListItemVM(
searchable: SavableSearchable
): SearchableItemVM(searchable)
@@ -0,0 +1,35 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.layout.BottomReversed
@Composable
fun SearchResultList(
items: List<SavableSearchable>,
modifier: Modifier = Modifier,
reverse: Boolean = false
) {
Column(
modifier = modifier,
verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top
) {
for (item in items) {
key(item.key) {
ListItem(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
item = item
)
}
}
}
}
@@ -0,0 +1,362 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.icons.Telegram
import de.mm20.launcher2.ui.icons.WhatsApp
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
@Composable
fun ContactItem(
modifier: Modifier = Modifier,
contact: Contact,
showDetails: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(contact) { ContactItemVM(contact) }
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val transition = updateTransition(showDetails, label = "ContactItem")
Column(
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val iconSize = 48.dp.toPixels().toInt()
val icon by remember(contact) { viewModel.getIcon(iconSize) }.collectAsState(null)
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(start = padding, top = padding, bottom = padding),
icon = { icon },
)
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleLarge
else MaterialTheme.typography.titleSmall
)
Text(
text = contact.labelOverride ?: contact.label,
style = textStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(!showDetails) {
Text(
contact.summary,
modifier = Modifier.padding(top = 2.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility(showDetails) {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
AnimatedVisibility(showDetails) {
Column {
if (contact.phones.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Call, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.phones.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
}
)
}
}
}
}
if (contact.emails.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Email, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.emails.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
}
)
}
}
}
}
if (contact.telegram.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Telegram, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.telegram.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
}
)
}
}
}
}
if (contact.whatsapp.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.WhatsApp, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.whatsapp.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
}
)
}
}
}
}
if (contact.postals.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Place, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.postals.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context, it)
}
)
}
}
}
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.msg_item_hidden, contact.label),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)
if(result == SnackbarResult.ActionPerformed) {
viewModel.unhide()
}
}
})
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_contacts_open_externally),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(contact) }
))
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
}
}
@Composable
fun ContactItemGridPopup(
contact: Contact,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(snap()) with
fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
ContactItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 48f) * (1 - animationProgress),
transformOrigin = TransformOrigin(0f, 0f)
)
.offset(
x = -16.dp * (1 - animationProgress),
y = -16.dp * (1 - animationProgress)
),
contact = contact,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.ContactInfo
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class ContactItemVM(
val contact: Contact
) : SearchableItemVM(contact) {
fun contact(context: Context, contactInfo: ContactInfo) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(contactInfo.data))
)
}
}
@@ -0,0 +1,68 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.ContactResults(reverse: Boolean = false) {
val viewModel: SearchVM = viewModel()
val context = LocalContext.current
val contacts by viewModel.contactResults.observeAsState(emptyList())
val isSearchEmpty by viewModel.isSearchEmpty.observeAsState(true)
val missingPermission by viewModel.missingContactsPermission.collectAsState(false)
AnimatedVisibility(contacts.isNotEmpty() || (!isSearchEmpty && missingPermission)) {
LauncherCard(
modifier = Modifier
.padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp)
.fillMaxWidth()
) {
Column {
AnimatedVisibility(!isSearchEmpty && missingPermission) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_contact_search),
onClick = { viewModel.requestContactsPermission(context as AppCompatActivity) },
modifier = Modifier.padding(16.dp),
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableContactsSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
SearchResultList(
items = contacts,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
reverse = reverse
)
}
}
}
}
@@ -0,0 +1,5 @@
package de.mm20.launcher2.ui.launcher.search.favorites
import de.mm20.launcher2.ui.common.FavoritesVM
class SearchFavoritesVM: FavoritesVM()
@@ -0,0 +1,344 @@
package de.mm20.launcher2.ui.launcher.search.files
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import java.text.DecimalFormat
import kotlin.math.roundToInt
@Composable
fun FileItem(
modifier: Modifier = Modifier,
file: File,
showDetails: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(file.key) { FileItemVM(file) }
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val transition = updateTransition(showDetails, label = "ContactItem")
Column(
modifier = modifier
) {
Row(
verticalAlignment = Alignment.Top
) {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.titleSmall
)
Text(
text = file.labelOverride ?: file.label,
style = textStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(!showDetails) {
Text(
file.getFileType(context),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
AnimatedVisibility(showDetails) {
Column {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
Text(
text = stringResource(
R.string.file_meta_type,
file.mimeType
),
style = MaterialTheme.typography.bodySmall,
)
for ((k, v) in file.metaData) {
Text(
text = stringResource(k, v),
style = MaterialTheme.typography.bodySmall
)
}
Text(
text = stringResource(
R.string.file_meta_path,
file.path
),
style = MaterialTheme.typography.bodySmall,
)
if (!file.isDirectory) {
Text(
text = stringResource(
R.string.file_meta_size,
formatFileSize(file.size)
),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
val iconSize = 48.dp.toPixels().toInt()
val icon by remember(file) { viewModel.getIcon(iconSize) }.collectAsState(null)
val badge by viewModel.badge.collectAsState(null)
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(end = padding, top = padding, bottom = padding),
icon = { icon },
badge = { badge }
)
}
AnimatedVisibility(showDetails) {
Column {
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_open_file),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
)
)
if (viewModel.canShare) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
viewModel.share(context)
}
))
}
if (viewModel.canDelete) {
var showConfirmDialog by remember { mutableStateOf(false) }
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),
icon = Icons.Rounded.Delete,
action = {
showConfirmDialog = true
}
))
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
confirmButton = {
TextButton(onClick = {
viewModel.delete()
showConfirmDialog = false
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
showConfirmDialog = false
}) {
Text(stringResource(android.R.string.cancel))
}
},
text = {
Text(
if (file.isDirectory) stringResource(
R.string.alert_delete_directory,
file.label
)
else stringResource(R.string.alert_delete_file, file.label)
)
}
)
}
}
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(file) }
))
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.msg_item_hidden, file.label),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)
if(result == SnackbarResult.ActionPerformed) {
viewModel.unhide()
}
}
})
}
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
}
}
@Composable
fun FileItemGridPopup(
file: File,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
slideInHorizontally(
tween(300),
initialOffsetX = { -it + origin.width.roundToInt() }) with
slideOutHorizontally(
tween(300),
targetOffsetX = { -it + origin.width.roundToInt() }) + fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
FileItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 48f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 16.dp * (1 - animationProgress),
y = -16.dp * (1 - animationProgress)
),
file = file,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
private fun formatFileSize(size: Long): String {
return when {
size < 1000L -> "$size Bytes"
size < 1000000L -> "${DecimalFormat("#,##0.#").format(size / 1000.0)} kB"
size < 1000000000L -> "${DecimalFormat("#,##0.#").format(size / 1000000.0)} MB"
size < 1000000000000L -> "${DecimalFormat("#,##0.#").format(size / 1000000000.0)} GB"
else -> "${DecimalFormat("#,##0.#").format(size / 1000000000000.0)} TB"
}
}
@@ -0,0 +1,36 @@
package de.mm20.launcher2.ui.launcher.search.files
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import de.mm20.launcher2.files.FileRepository
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LocalFile
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import org.koin.core.component.inject
class FileItemVM(
private val file: File
) : SearchableItemVM(file) {
private val fileRepository: FileRepository by inject()
val canShare = file is LocalFile
val canDelete = file.isDeletable
fun delete() {
fileRepository.deleteFile(file)
}
fun share(context: Context) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
java.io.File(file.path)
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = file.mimeType
context.startActivity(Intent.createChooser(shareIntent, null))
}
}
@@ -0,0 +1,67 @@
package de.mm20.launcher2.ui.launcher.search.files
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.FileResults(reverse: Boolean = false) {
val viewModel: SearchVM = viewModel()
val files by viewModel.fileResults.observeAsState(emptyList())
val context = LocalContext.current
val isSearchEmpty by viewModel.isSearchEmpty.observeAsState(true)
val missingPermission by viewModel.missingFilesPermission.collectAsState(false)
AnimatedVisibility(files.isNotEmpty() || (!isSearchEmpty && missingPermission)) {
LauncherCard(
modifier = Modifier
.padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp)
.fillMaxWidth()
) {
Column {
AnimatedVisibility(!isSearchEmpty && missingPermission) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_files_search),
onClick = { viewModel.requestFilesPermission(context as AppCompatActivity) },
modifier = Modifier.padding(16.dp),
secondaryAction = {
OutlinedButton(onClick = {
viewModel.disableFilesSearch()
}) {
Text(
stringResource(R.string.turn_off),
)
}
}
)
}
SearchResultList(
items = files,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
reverse = reverse
)}
}
}
}
@@ -0,0 +1,281 @@
package de.mm20.launcher2.ui.launcher.search.shortcut
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import kotlin.math.pow
import kotlin.math.roundToInt
@Composable
fun AppShortcutItem(
modifier: Modifier = Modifier,
shortcut: AppShortcut,
showDetails: Boolean = false,
onBack: () -> Unit
) {
val viewModel = remember { ShortcutItemVM(shortcut) }
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
var requestDelete by remember { mutableStateOf(false) }
val transition = updateTransition(showDetails, label = "AppShortcutItem")
Column(
modifier = modifier
) {
Row {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
val titleStyle by animateTextStyleAsState(if (showDetails) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleSmall)
Text(
text = shortcut.labelOverride ?: shortcut.label,
style = titleStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(showDetails) {
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 2.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
}
val textSpace by transition.animateDp(label = "textSpace") {
if (it) 4.dp else 2.dp
}
shortcut.appName?.let {
Text(
text = stringResource(R.string.shortcut_summary, it),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = textSpace),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
val badge by viewModel.badge.collectAsState(null)
val size by animateDpAsState(if (showDetails) 84.dp else 48.dp)
val iconSize = 84.dp.toPixels().toInt()
val icon by remember(shortcut.key) { viewModel.getIcon(iconSize) }.collectAsState(null)
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
}
ShapedLauncherIcon(
size = size,
modifier = Modifier
.padding(padding),
badge = { badge },
icon = { icon },
)
}
AnimatedVisibility(showDetails) {
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
viewModel.openAppInfo(context)
})
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(shortcut) }
))
if (viewModel.canDelete) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),
icon = Icons.Rounded.Delete,
action = { requestDelete = true }
))
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
lifecycleOwner.lifecycleScope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(
R.string.msg_item_hidden,
shortcut.label
),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
viewModel.unhide()
}
}
})
}
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
if (requestDelete) {
AlertDialog(
onDismissRequest = { requestDelete = false },
text = { Text(stringResource(R.string.alert_delete_shortcut, shortcut.label)) },
confirmButton = {
TextButton(onClick = {
viewModel.deleteShortcut()
requestDelete = false
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
requestDelete = false
}) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
}
@Composable
fun ShortcutItemGridPopup(
shortcut: AppShortcut,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
slideInHorizontally(
tween(300),
initialOffsetX = { -it + origin.width.roundToInt() }) with
slideOutHorizontally(
tween(300),
targetOffsetX = { -it + origin.width.roundToInt() }) + fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
AppShortcutItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 16.dp * (1 - animationProgress).pow(10),
y = -16.dp * (1 - animationProgress),
),
shortcut = shortcut,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
@@ -0,0 +1,42 @@
package de.mm20.launcher2.ui.launcher.search.shortcut
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ShortcutItemVM(private val shortcut: AppShortcut) : SearchableItemVM(shortcut), KoinComponent {
private val shortcutRepository: AppShortcutRepository by inject()
val canDelete = shortcut is LauncherShortcut && shortcut.launcherShortcut.isPinned
fun openAppInfo(context: Context) {
val packageName = when(shortcut) {
is LegacyShortcut -> shortcut.intent.`package` ?: return
is LauncherShortcut -> shortcut.launcherShortcut.`package`
else -> return
}
context.tryStartActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
fun deleteShortcut() {
if (!canDelete) return
if (shortcut is LauncherShortcut) shortcutRepository.removePinnedShortcut(shortcut)
favoritesRepository.unpinItem(shortcut)
}
}
@@ -0,0 +1,160 @@
package de.mm20.launcher2.ui.launcher.search.unitconverter
import android.icu.text.DateFormat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.CurrencyUnitConverter
import de.mm20.launcher2.search.data.UnitConverter
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.unitconverter.Dimension
import java.util.*
@Composable
fun UnitConverterItem(
unitConverter: UnitConverter,
) {
var showAll by remember { mutableStateOf(false) }
Column(
modifier = Modifier.padding(bottom = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = unitConverter.inputValue.let { "${it.formattedValue} ${it.formattedName}" },
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
overflow = TextOverflow.Ellipsis,
softWrap = false
)
Surface(
modifier = Modifier
.padding(12.dp)
.size(48.dp),
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = getDimensionIcon(unitConverter.dimension),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
}
Row {
Column {
for ((i, unit) in unitConverter.values.withIndex()) {
if (!showAll && i >= 5) break
Text(
text = unit.formattedValue,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold
),
modifier = Modifier
.padding(start = 16.dp, bottom = 12.dp)
)
}
}
Column {
for ((i, unit) in unitConverter.values.withIndex()) {
if (!showAll && i >= 5) break
Text(
text = "${unit.formattedName} (${unit.symbol})",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(end = 16.dp, bottom = 12.dp, start = 8.dp)
)
}
}
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
var showDisclaimer by remember { mutableStateOf(false) }
(unitConverter as? CurrencyUnitConverter)?.let {
val df = DateFormat.getDateInstance(DateFormat.SHORT)
Text(
text = "${df.format(Date(it.updateTimestamp))}",
style = MaterialTheme.typography.labelSmall,
)
Text(
text = stringResource(id = R.string.disclaimer),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
showDisclaimer = true
}
)
if (showDisclaimer) {
AlertDialog(
onDismissRequest = { showDisclaimer = false },
confirmButton = {
TextButton(onClick = { showDisclaimer = false }) {
Text(text = stringResource(id = R.string.close))
}
},
title = { Text(stringResource(id = R.string.disclaimer)) },
text = { Text(stringResource(id = R.string.disclaimer_currency_converter, df.format(Date(it.updateTimestamp)))) }
)
}
}
}
if (!showAll && unitConverter.values.size > 5) {
TextButton(
onClick = { showAll = true },
modifier = Modifier
.padding(horizontal = 12.dp)
) {
Text(
text = stringResource(id = R.string.unit_converter_show_all),
)
}
}
}
}
}
fun getDimensionIcon(dimension: Dimension): ImageVector {
return when (dimension) {
Dimension.Mass -> Icons.Rounded.FitnessCenter
Dimension.Length -> Icons.Rounded.Straighten
Dimension.Velocity -> Icons.Rounded.Speed
Dimension.Volume -> TODO()
Dimension.Area -> Icons.Rounded.SquareFoot
Dimension.Currency -> Icons.Rounded.Toll
Dimension.Data -> Icons.Rounded.Storage
Dimension.Bitrate -> TODO()
Dimension.Pressure -> TODO()
Dimension.Energy -> Icons.Rounded.Bolt
Dimension.Frequency -> TODO()
Dimension.Temperature -> Icons.Rounded.Thermostat
Dimension.Time -> Icons.Rounded.Schedule
}
}
@@ -0,0 +1,163 @@
package de.mm20.launcher2.ui.launcher.search.website
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
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 coil.compose.AsyncImage
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
@Composable
fun WebsiteItem(
modifier: Modifier = Modifier,
website: Website,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel = remember(website) { WebsiteItemVM(website) }
Column(
modifier = modifier.clickable {
viewModel.launch(context)
}
) {
if (website.image.isNotBlank()) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
model = website.image,
contentScale = ContentScale.Crop,
contentDescription = null)
}
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = website.labelOverride ?: website.label,
style = MaterialTheme.typography.titleLarge
)
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 2.dp, bottom = 2.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = website.description,
style = MaterialTheme.typography.bodySmall
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
onBack?.invoke()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
onBack?.invoke()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon= Icons.Rounded.Share,
action = {
viewModel.share(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(website) }
))
Toolbar(
leftActions = if (onBack != null) listOf(
DefaultToolbarAction(
stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack,
action = onBack
)
) else emptyList(),
rightActions = toolbarActions
)
}
}
@Composable
fun WebsiteItemGridPopup(
website: Website,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(tween(200)) with
fadeOut(tween(200, 200)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
WebsiteItem(
modifier = Modifier
.fillMaxWidth(),
website = website,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.ui.launcher.search.website
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class WebsiteItemVM(
private val website: Website
) : SearchableItemVM(website) {
fun share(context: Context) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT,
"${website.label}\n\n${website.description}\n\n${website.url}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
}
@@ -0,0 +1,171 @@
package de.mm20.launcher2.ui.launcher.search.wikipedia
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
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 coil.compose.AsyncImage
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.utils.htmlToAnnotatedString
@Composable
fun WikipediaItem(
modifier: Modifier = Modifier,
wikipedia: Wikipedia,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel = remember(wikipedia) { WikipediaItemVM(wikipedia) }
Column(
modifier = modifier.clickable {
viewModel.launch(context)
}
) {
if (!wikipedia.image.isNullOrEmpty()) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
model = wikipedia.image,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = wikipedia.label,
style = MaterialTheme.typography.titleLarge
)
val tags by remember(viewModel) { viewModel.getTags() }.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
Text(
modifier = Modifier.padding(top = 4.dp),
text = stringResource(id = R.string.wikipedia_source),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary
)
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = htmlToAnnotatedString(wikipedia.text),
style = MaterialTheme.typography.bodySmall
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
onBack?.invoke()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
onBack?.invoke()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
viewModel.share(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(wikipedia) }
))
Toolbar(
leftActions = if (onBack != null) listOf(
DefaultToolbarAction(
stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack,
action = onBack
)
) else emptyList(),
rightActions = toolbarActions
)
}
}
@Composable
fun WikipediaItemGridPopup(
wikipedia: Wikipedia,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(tween(200)) with
fadeOut(tween(200, 200)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
WikipediaItem(
modifier = Modifier
.fillMaxWidth(),
wikipedia = wikipedia,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
@@ -0,0 +1,24 @@
package de.mm20.launcher2.ui.launcher.search.wikipedia
import android.content.Context
import android.content.Intent
import androidx.core.text.HtmlCompat
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class WikipediaItemVM(
private val wikipedia: Wikipedia
) : SearchableItemVM(wikipedia) {
fun share(context: Context) {
val text = HtmlCompat.fromHtml(wikipedia.text, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT, "${wikipedia.label}\n\n" +
"${text.substring(0, 200)}\n\n" +
"${wikipedia.wikipediaUrl}/wiki?curid=${wikipedia.id}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
}
@@ -0,0 +1,89 @@
package de.mm20.launcher2.ui.launcher.searchbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.ui.component.SearchBar
import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.launcher.sheets.HiddenItemsSheet
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
@Composable
fun LauncherSearchBar(
modifier: Modifier = Modifier,
style: Settings.SearchBarSettings.SearchBarStyle,
level: () -> SearchBarLevel,
value: () -> String,
onValueChange: (String) -> Unit,
focused: Boolean,
onFocusChange: (Boolean) -> Unit,
actions: List<SearchAction>,
showHiddenItemsButton: Boolean = false,
reverse: Boolean = false,
darkColors: Boolean = false,
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
val sheetManager = LocalBottomSheetManager.current
val searchVM: SearchVM = viewModel()
val hiddenItems by searchVM.hiddenResults.observeAsState(emptyList())
LaunchedEffect(focused) {
if (focused) focusRequester.requestFocus()
else focusManager.clearFocus()
}
val _value = value()
SearchBar(
modifier = modifier,
style = style, level = level(), value = _value, onValueChange = onValueChange,
reverse = reverse,
darkColors = darkColors,
menu = {
AnimatedVisibility(
showHiddenItemsButton && hiddenItems.isNotEmpty(),
enter = scaleIn(tween(100)),
exit = scaleOut(tween(100))
) {
FilledIconButton(
onClick = { sheetManager.showHiddenItemsSheet() },
colors = if (sheetManager.hiddenItemsSheetShown.value) IconButtonDefaults.filledTonalIconButtonColors() else IconButtonDefaults.iconButtonColors()
) {
Icon(imageVector = Icons.Rounded.VisibilityOff, contentDescription = null)
}
}
SearchBarMenu(searchBarValue = _value, onSearchBarValueChange = onValueChange)
},
actions = {
SearchBarActions(actions = actions, reverse = reverse)
},
focusRequester = focusRequester,
onFocus = { onFocusChange(true) },
onUnfocus = { onFocusChange(false) },
)
}
@@ -0,0 +1,92 @@
package de.mm20.launcher2.ui.launcher.searchbar
import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.AssistChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.ui.component.SearchActionIcon
import de.mm20.launcher2.ui.settings.SettingsActivity
@Composable
fun ColumnScope.SearchBarActions(
modifier: Modifier = Modifier,
actions: List<SearchAction>,
reverse: Boolean = false,
) {
val context = LocalContext.current
AnimatedVisibility(actions.isNotEmpty()) {
LazyRow(
modifier = Modifier
.height(48.dp)
.padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(start = 8.dp, end = 4.dp)
) {
items(actions) {
AssistChip(
modifier = Modifier.padding(4.dp),
onClick = {
it.start(context)
},
label = { Text(it.label) },
leadingIcon = {
SearchActionIcon(
action = it
)
}
/*leadingIcon = {
val icon = it.icon
if (icon == null) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = if (it.color == 0) MaterialTheme.colorScheme.primary else Color(
it.color
)
)
} else {
AsyncImage(
modifier = Modifier.size(24.dp),
model = File(icon),
contentDescription = null
)
}
}*/
)
}
item {
SmallFloatingActionButton(
modifier = Modifier.padding(start = 4.dp),
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
onClick = {
context.startActivity(
Intent(context, SettingsActivity::class.java).apply {
putExtra(SettingsActivity.EXTRA_ROUTE, "settings/search/searchactions")
}
)
}
) {
Icon(imageVector = Icons.Rounded.Edit, contentDescription = null)
}
}
}
}
}
@@ -0,0 +1,107 @@
package de.mm20.launcher2.ui.launcher.searchbar
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Wallpaper
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.settings.SettingsActivity
@Composable
fun RowScope.SearchBarMenu(
searchBarValue: String,
onSearchBarValueChange: (newValue: String) -> Unit,
) {
val context = LocalContext.current
var showOverflowMenu by remember { mutableStateOf(false) }
val rightIcon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_menu_clear)
IconButton(onClick = {
if (searchBarValue.isNotBlank()) onSearchBarValueChange("")
else showOverflowMenu = true
}) {
Icon(
painter = rememberAnimatedVectorPainter(
rightIcon,
atEnd = searchBarValue.isNotEmpty()
),
contentDescription = null,
tint = LocalContentColor.current
)
}
DropdownMenu(expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false }) {
DropdownMenuItem(
onClick = {
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SET_WALLPAPER),
null
)
)
showOverflowMenu = false
},
text = {
Text(stringResource(R.string.wallpaper))
},
leadingIcon = {
Icon(imageVector = Icons.Rounded.Wallpaper, contentDescription = null)
}
)
DropdownMenuItem(
onClick = {
context.startActivity(Intent(context, SettingsActivity::class.java))
showOverflowMenu = false
},
text = {
Text(stringResource(R.string.settings))
},
leadingIcon = {
Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
}
)
val colorScheme = MaterialTheme.colorScheme
DropdownMenuItem(
onClick = {
CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(colorScheme.primaryContainer.toArgb())
.setSecondaryToolbarColor(colorScheme.secondaryContainer.toArgb())
.build()
)
.build()
.launchUrl(context, Uri.parse("https://kvaesitso.mm20.de/docs/user-guide"))
showOverflowMenu = false
},
text = {
Text(stringResource(R.string.help))
},
leadingIcon = {
Icon(imageVector = Icons.Rounded.HelpOutline, contentDescription = null)
}
)
}
}
@@ -0,0 +1,286 @@
package de.mm20.launcher2.ui.launcher.sheets
import android.graphics.drawable.InsetDrawable
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
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.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.CustomIconWithPreview
import de.mm20.launcher2.search.SavableSearchable
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.component.OutlinedTagsInputField
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalGridSettings
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Composable
fun CustomizeSearchableSheet(
searchable: SavableSearchable,
onDismiss: () -> Unit,
) {
val viewModel: CustomizeSearchableSheetVM =
remember(searchable.key) { 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))
},
swipeToDismiss = { !pickIcon },
dismissOnBackPress = { !pickIcon },
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()
}
)
var customLabelValue by remember {
mutableStateOf(searchable.labelOverride ?: "")
}
OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 24.dp),
value = customLabelValue,
onValueChange = {
customLabelValue = it
},
singleLine = true,
placeholder = {
Text(searchable.label)
},
)
var tags by remember { mutableStateOf(emptyList<String>()) }
LaunchedEffect(searchable.key) {
tags = viewModel.getTags().first()
}
OutlinedTagsInputField(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
tags = tags, onTagsChange = { tags = it.distinct() },
placeholder = {
Text(stringResource(R.string.customize_tags_placeholder))
},
textStyle = MaterialTheme.typography.bodyMedium,
onAutocomplete = {
viewModel.autocompleteTags(it).minus(tags.toSet())
}
)
DisposableEffect(searchable.key) {
onDispose {
viewModel.setCustomLabel(customLabelValue)
viewModel.setTags(tags)
}
}
}
} else {
val iconSize = 48.dp
val iconSizePx = iconSize.toPixels()
val scope = rememberCoroutineScope()
val suggestions by remember { viewModel.getIconSuggestions(iconSizePx.toInt()) }
.observeAsState(emptyList())
val defaultIcon by remember {
viewModel.getDefaultIcon(iconSizePx.toInt())
}.observeAsState()
var query by remember { mutableStateOf("") }
val isSearching by viewModel.isSearchingIcons.observeAsState(initial = false)
val iconResults by viewModel.iconSearchResults.observeAsState(emptyList())
val columns = LocalGridSettings.current.columnCount
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Fixed(columns)
) {
item(span = { GridItemSpan(columns) }) {
OutlinedTextField(
modifier = Modifier.padding(bottom = 16.dp),
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = {
query = ""
scope.launch {
viewModel.searchIcon("")
}
}) {
Icon(
imageVector = Icons.Rounded.Clear,
contentDescription = null
)
}
}
},
value = query,
onValueChange = {
query = it
scope.launch {
viewModel.searchIcon(query)
}
},
label = {
Text(stringResource(R.string.icon_picker_search_icon))
},
singleLine = true,
)
}
if (query.isEmpty()) {
if (defaultIcon != null) {
item(span = { GridItemSpan(columns) }) {
Separator(stringResource(R.string.icon_picker_default_icon))
}
item {
IconPreview(item = defaultIcon, iconSize = iconSize, onClick = {
viewModel.pickIcon(null)
})
}
}
item(span = { GridItemSpan(columns) }) {
Separator(stringResource(R.string.icon_picker_suggestions))
}
items(suggestions) {
IconPreview(
it,
iconSize,
onClick = { viewModel.pickIcon(it.customIcon) }
)
}
} else {
items(iconResults) {
IconPreview(
it,
iconSize,
onClick = { viewModel.pickIcon(it.customIcon) }
)
}
if (isSearching) {
item(span = { GridItemSpan(columns) }) {
Box(
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.padding(12.dp)
.size(24.dp)
)
}
}
}
}
}
}
}
}
@Composable
fun IconPreview(
item: CustomIconWithPreview?,
iconSize: Dp,
onClick: () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(vertical = 8.dp)
) {
ShapedLauncherIcon(
size = iconSize,
icon = { item?.preview },
onClick = onClick
)
}
}
@Composable
fun Separator(label: String) {
Text(
label,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(top = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
}
@@ -0,0 +1,90 @@
package de.mm20.launcher2.ui.launcher.sheets
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.CustomIcon
import de.mm20.launcher2.icons.CustomIconWithPreview
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext
class CustomizeSearchableSheetVM(
private val searchable: SavableSearchable
) : KoinComponent {
private val iconRepository: IconRepository by inject()
private val customAttributesRepository: CustomAttributesRepository 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()
}
fun getDefaultIcon(size: Int) = liveData {
emit(iconRepository.getUncustomizedDefaultIcon(searchable, size))
}
val iconSearchResults = MutableLiveData(emptyList<CustomIconWithPreview>())
val isSearchingIcons = MutableLiveData(false)
private var debounceSearchJob: Job? = null
suspend fun searchIcon(query: String) {
debounceSearchJob?.cancelAndJoin()
if (query.isBlank()) {
iconSearchResults.value = emptyList()
isSearchingIcons.value = false
return
}
withContext(coroutineContext) {
debounceSearchJob = launch {
delay(1000)
isSearchingIcons.value = true
iconSearchResults.value = iconRepository.searchCustomIcons(query)
isSearchingIcons.value = false
}
}
}
fun setCustomLabel(label: String) {
if (label.isBlank()) {
customAttributesRepository.clearCustomLabel(searchable)
} else {
customAttributesRepository.setCustomLabel(searchable, label)
}
}
fun setTags(tags: List<String>) {
customAttributesRepository.setTags(searchable, tags)
}
fun getTags(): Flow<List<String>> {
return customAttributesRepository.getTags(searchable)
}
suspend fun autocompleteTags(query: String): List<String> {
return customAttributesRepository.getAllTags(startsWith = query)
}
}
@@ -0,0 +1,752 @@
package de.mm20.launcher2.ui.launcher.sheets
import android.app.Activity
import android.content.Context
import android.content.pm.LauncherApps
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.helper.DraggableItem
import de.mm20.launcher2.ui.launcher.helper.LazyDragAndDropRow
import de.mm20.launcher2.ui.launcher.helper.LazyVerticalDragAndDropGrid
import de.mm20.launcher2.ui.launcher.helper.rememberLazyDragAndDropGridState
import de.mm20.launcher2.ui.launcher.helper.rememberLazyDragAndDropListState
import de.mm20.launcher2.ui.locals.LocalGridSettings
import kotlinx.coroutines.currentCoroutineContext
import kotlin.math.roundToInt
@Composable
fun EditFavoritesSheet(
onDismiss: () -> Unit,
) {
val viewModel: EditFavoritesSheetVM = viewModel()
LaunchedEffect(null) {
viewModel.reload()
}
val loading by viewModel.loading.observeAsState(true)
val createShortcutTarget by viewModel.createShortcutTarget.observeAsState(null)
BottomSheetDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (createShortcutTarget == null) {
stringResource(id = R.string.menu_item_edit_favs)
} else {
stringResource(id = R.string.create_app_shortcut)
}
)
},
swipeToDismiss = {
createShortcutTarget == null
},
dismissOnBackPress = {
createShortcutTarget == null
},
confirmButton = {
if (createShortcutTarget != null) {
OutlinedButton(onClick = { viewModel.cancelPickShortcut() }) {
Text(stringResource(id = android.R.string.cancel))
}
} else {
OutlinedButton(onClick = onDismiss) {
Text(stringResource(id = R.string.close))
}
}
}
) {
if (loading) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
) {
CircularProgressIndicator(
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
)
}
} else if (createShortcutTarget != null) {
ShortcutPicker(viewModel)
} else {
ReorderFavoritesGrid(viewModel)
}
}
}
@Composable
fun ReorderFavoritesGrid(viewModel: EditFavoritesSheetVM) {
val items by viewModel.gridItems.observeAsState(emptyList())
val columns = LocalGridSettings.current.columnCount
val availableTags by viewModel.availableTags.observeAsState(emptyList())
val pinnedTags by viewModel.pinnedTags.observeAsState(emptyList())
var contextMenuItemKey by remember { mutableStateOf<String?>(null) }
val contextMenuCloseDistance = 8.dp.toPixels()
var draggedItemKey by remember { mutableStateOf<String?>(null) }
var hoveredTag by remember { mutableStateOf<String?>(null) }
val gridState = rememberLazyGridState()
val tagsListState = rememberLazyListState()
val tagsTitleSize = 48.dp.toPixels()
val tagsSpacing = 12.dp.toPixels()
val state = rememberLazyDragAndDropGridState(
gridState = gridState,
onDragStart = {
val item = items.getOrNull(it.index)
if (item !is FavoritesSheetGridItem.Favorite) return@rememberLazyDragAndDropGridState false
draggedItemKey = item.item.key
contextMenuItemKey = item.item.key
true
},
onDrag = { item, offset, position ->
if (offset.getDistanceSquared() > contextMenuCloseDistance) {
contextMenuItemKey = null
}
val draggedCenter = Rect(position, item.size.toSize()).center
val hoveredItem = gridState.layoutInfo.visibleItemsInfo.find {
Rect(
it.offset.toOffset(),
it.size.toSize()
).contains(draggedCenter)
}
if (hoveredItem != null
&& items[hoveredItem.index] is FavoritesSheetGridItem.Tags
&& hoveredItem.offset.y + tagsTitleSize < position.y
) {
val scroll = tagsListState.layoutInfo.viewportStartOffset
val tag = tagsListState.layoutInfo.visibleItemsInfo.find {
draggedCenter.x + scroll > it.offset && draggedCenter.x + scroll < it.offset + it.size - tagsSpacing
}
hoveredTag = tag?.index?.let { pinnedTags[it].tag }
} else {
hoveredTag = null
}
},
onDragEnd = {
viewModel.addTag(draggedItemKey, hoveredTag)
draggedItemKey = null
hoveredTag = null
},
onDragCancel = {
draggedItemKey = null
hoveredTag = null
}
) { from, to ->
viewModel.moveItem(from, to)
}
val iconSize = 48.dp.toPixels()
LazyVerticalDragAndDropGrid(
state = state,
columns = GridCells.Fixed(columns),
) {
items(
items.size,
key = { i ->
val it = items[i]
if (it is FavoritesSheetGridItem.Favorite) it.item.key else i
},
span = { i ->
val it = items[i]
when (it) {
is FavoritesSheetGridItem.Favorite -> GridItemSpan(1)
is FavoritesSheetGridItem.Divider -> GridItemSpan(columns)
is FavoritesSheetGridItem.EmptySection -> GridItemSpan(columns)
is FavoritesSheetGridItem.Spacer -> GridItemSpan(it.span)
is FavoritesSheetGridItem.Tags -> GridItemSpan(columns)
}
}
) { i ->
when (val it = items[i]) {
is FavoritesSheetGridItem.Favorite -> {
val icon by remember(it.item.key) {
viewModel.getIcon(
it.item,
iconSize.roundToInt()
)
}.collectAsState(null)
val badge by remember(it.item.key) {
viewModel.getBadge(
it.item,
)
}.collectAsState(null)
DraggableItem(state = state, key = it.item.key) { dragged ->
GridItem(
label = it.item.labelOverride ?: it.item.label,
icon = icon,
badge = badge
)
if (contextMenuItemKey == it.item.key) {
DropdownMenu(
expanded = true,
onDismissRequest = { contextMenuItemKey = null }) {
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = null
)
},
text = {
Text(stringResource(R.string.menu_remove))
}, onClick = {
contextMenuItemKey?.let { viewModel.remove(it) }
contextMenuItemKey = null
}
)
}
}
}
}
is FavoritesSheetGridItem.Divider -> {
val title = when (it.section) {
FavoritesSheetSection.ManuallySorted -> R.string.edit_favorites_dialog_pinned_sorted
FavoritesSheetSection.AutomaticallySorted -> R.string.edit_favorites_dialog_pinned_unsorted
FavoritesSheetSection.FrequentlyUsed -> R.string.edit_favorites_dialog_unpinned
}
var showSettings by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = stringResource(id = title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary
)
if (it.section == FavoritesSheetSection.FrequentlyUsed) {
FilledTonalIconToggleButton(
modifier = Modifier.offset(x = 4.dp),
checked = showSettings,
onCheckedChange = { showSettings = it }) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
)
}
} else {
FilledTonalIconButton(
modifier = Modifier.offset(x = 4.dp),
onClick = {
viewModel.pickShortcut(it.section)
}) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null
)
}
}
}
val enableFrequentlyUsed by viewModel.enableFrequentlyUsed.observeAsState(
null
)
val frequentlyUsedRows by viewModel.frequentlyUsedRows.observeAsState(1)
AnimatedVisibility(showSettings) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
text = stringResource(R.string.frequently_used_show_in_favorites),
style = MaterialTheme.typography.labelMedium
)
Switch(
checked = enableFrequentlyUsed == true,
onCheckedChange = {
viewModel.setFrequentlyUsed(it)
}
)
}
AnimatedVisibility(enableFrequentlyUsed == true) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.frequently_used_rows),
style = MaterialTheme.typography.labelMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Slider(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
value = frequentlyUsedRows.toFloat(),
colors = SliderDefaults.colors(
inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant
),
onValueChange = {
viewModel.setFrequentlyUsedRows(it.roundToInt())
},
steps = 2,
valueRange = 1f..4f
)
Text(
text = frequentlyUsedRows.toString(),
modifier = Modifier
.width(52.dp)
.padding(4.dp),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}
}
is FavoritesSheetGridItem.EmptySection -> {
val shape = MaterialTheme.shapes.medium
val color = MaterialTheme.colorScheme.outline
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.drawBehind {
drawOutline(
outline = shape.createOutline(
size,
layoutDirection,
Density(density, fontScale)
),
color = color,
style = Stroke(
2.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(
4.dp.toPx(),
4.dp.toPx(),
)
)
)
)
}
) {
Text(
modifier = Modifier
.align(Alignment.Center)
.padding(
horizontal = 16.dp,
vertical = 24.dp,
),
text = stringResource(R.string.edit_favorites_dialog_empty_section),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.outline
)
}
}
is FavoritesSheetGridItem.Spacer -> {
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
)
}
is FavoritesSheetGridItem.Tags -> {
var showAddMenu by remember { mutableStateOf(false) }
Column {
Row(
modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = stringResource(R.string.edit_favorites_dialog_tags),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary
)
Box() {
FilledTonalIconButton(
modifier = Modifier.offset(x = 4.dp),
onClick = {
showAddMenu = true
}) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null
)
}
DropdownMenu(
expanded = showAddMenu,
onDismissRequest = { showAddMenu = false }) {
for (tag in availableTags) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Rounded.Tag, null)
},
text = { Text(tag.label) },
onClick = {
viewModel.pinTag(tag)
showAddMenu = false
})
}
if (availableTags.isNotEmpty()) {
Divider()
}
var newTag by remember { mutableStateOf("") }
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Rounded.Create, null)
},
contentPadding = PaddingValues(
start = MenuDefaults.DropdownMenuItemContentPadding.calculateStartPadding(LocalLayoutDirection.current),
end = MenuDefaults.DropdownMenuItemContentPadding.calculateEndPadding(LocalLayoutDirection.current),
top = if(availableTags.isNotEmpty()) 8.dp else 0.dp,
),
text = {
Box {
if (newTag.isEmpty()) {
Text(
stringResource(R.string.edit_favorites_dialog_new_tag),
color = LocalContentColor.current.copy(alpha = 0.5f)
)
}
BasicTextField(
value = newTag,
onValueChange = { newTag = it.replace(",", "") },
textStyle = LocalTextStyle.current.copy(
color = LocalContentColor.current
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
keyboardActions = KeyboardActions(
onDone = {
if (newTag.isBlank()) return@KeyboardActions
viewModel.createNewTag(newTag)
showAddMenu = false
}
)
)
}
},
trailingIcon = {
Icon(
modifier = Modifier.clickable {
if (newTag.isBlank()) return@clickable
viewModel.createNewTag(newTag)
showAddMenu = false
},
imageVector = Icons.Rounded.ArrowForward,
contentDescription = null
)
},
onClick = { }
)
}
}
}
if (pinnedTags.isNotEmpty()) {
val rowState = rememberLazyDragAndDropListState(
listState = tagsListState,
) { from, to ->
viewModel.moveTag(from, to)
}
LazyDragAndDropRow(
modifier = Modifier.fillMaxWidth(),
state = rowState
) {
items(
pinnedTags,
key = { it.key }
) { tag ->
DraggableItem(state = rowState, key = tag.key) { dragged ->
FilterChip(
modifier = Modifier
.padding(end = 12.dp)
.pointerInput(null) {
val coroutineContext =
currentCoroutineContext()
},
selected = tag.tag == hoveredTag,
onClick = {},
label = { Text(tag.label) },
leadingIcon = {
Icon(Icons.Rounded.Tag, null)
},
trailingIcon = {
Icon(
modifier = Modifier.clickable {
viewModel.unpinTag(tag)
},
imageVector = Icons.Rounded.Close,
contentDescription = null
)
},
elevation = if (dragged) FilterChipDefaults.elevatedFilterChipElevation() else FilterChipDefaults.filterChipElevation(),
colors = if (dragged) FilterChipDefaults.elevatedFilterChipColors()
else FilterChipDefaults.filterChipColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
}
}
} else {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
text = stringResource(R.string.edit_favorites_dialog_tag_section_empty),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
}
}
}
@Composable
fun GridItem(
modifier: Modifier = Modifier,
label: String,
icon: LauncherIcon?,
badge: Badge?
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ShapedLauncherIcon(
size = 48.dp,
icon = { icon },
badge = { badge })
Text(
label,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall
)
}
}
@Composable
fun ShortcutPicker(viewModel: EditFavoritesSheetVM) {
val hasShortcutPermission by remember { viewModel.hasShortcutPermission }.collectAsState(null)
val shortcutActivities by remember(hasShortcutPermission) { viewModel.getShortcutActivities() }.collectAsState(
emptyList()
)
val context = LocalContext.current
val activityLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) {
if (it.resultCode != Activity.RESULT_OK) {
viewModel.cancelPickShortcut()
}
viewModel.createShortcut(context, it.data)
}
val iconSize = 48.dp.toPixels().roundToInt()
val activity = LocalLifecycleOwner.current as AppCompatActivity
LazyColumn {
if (hasShortcutPermission == false) {
item {
MissingPermissionBanner(
modifier = Modifier.padding(bottom = 16.dp),
text = stringResource(
R.string.missing_permission_appshortcuts_create,
stringResource(R.string.app_name)
),
onClick = { viewModel.requestShortcutPermission(activity) })
}
}
items(shortcutActivities) {
val icon by remember(it.key) { viewModel.getIcon(it, iconSize) }.collectAsState(null)
val badge by remember(it.key) { viewModel.getBadge(it) }.collectAsState(null)
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
onClick = {
val launcherApps =
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val sender =
launcherApps.getShortcutConfigActivityIntent(it.launcherActivityInfo)
?: return@OutlinedCard
activityLauncher.launch(IntentSenderRequest.Builder(sender).build(), null)
}) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShapedLauncherIcon(
size = 48.dp,
icon = { icon },
badge = { badge },
)
Text(
text = it.labelOverride ?: it.label,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
}
sealed interface FavoritesSheetGridItem {
class Favorite(val item: SavableSearchable) : FavoritesSheetGridItem
class Divider(val section: FavoritesSheetSection) : FavoritesSheetGridItem
class Spacer(val span: Int = 1) : FavoritesSheetGridItem
object EmptySection : FavoritesSheetGridItem
object Tags : FavoritesSheetGridItem
}
enum class FavoritesSheetSection {
ManuallySorted,
AutomaticallySorted,
FrequentlyUsed
}
@@ -0,0 +1,327 @@
package de.mm20.launcher2.ui.launcher.sheets
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.normalize
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.Tag
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class EditFavoritesSheetVM : ViewModel(), KoinComponent {
private val repository: FavoritesRepository by inject()
private val shortcutRepository: AppShortcutRepository by inject()
private val iconRepository: IconRepository by inject()
private val badgeRepository: BadgeRepository by inject()
private val customAttributesRepository: CustomAttributesRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val dataStore: LauncherDataStore by inject()
val gridItems = MutableLiveData<List<FavoritesSheetGridItem>>(emptyList())
val loading = MutableLiveData(false)
val createShortcutTarget = MutableLiveData<FavoritesSheetSection?>(null)
private var manuallySorted: MutableList<SavableSearchable> = mutableListOf()
private var automaticallySorted: MutableList<SavableSearchable> = mutableListOf()
private var frequentlyUsed: MutableList<SavableSearchable> = mutableListOf()
val pinnedTags = MutableLiveData<List<Tag>>(emptyList())
val availableTags = MutableLiveData<List<Tag>>(emptyList())
suspend fun reload(showLoadingIndicator: Boolean = true) {
loading.value = showLoadingIndicator
manuallySorted = mutableListOf()
manuallySorted = repository.getFavorites(
manuallySorted = true,
excludeTypes = listOf("tag"),
).first().toMutableList()
automaticallySorted = repository.getFavorites(
automaticallySorted = true,
excludeTypes = listOf("tag"),
).first().toMutableList()
frequentlyUsed = repository.getFavorites(
frequentlyUsed = true,
excludeTypes = listOf("tag"),
).first().toMutableList()
val pinnedTags = repository.getFavorites(
includeTypes = listOf("tag"),
manuallySorted = true,
automaticallySorted = true,
).first().filterIsInstance<Tag>().toMutableList()
availableTags.value =
customAttributesRepository
.getAllTags()
.filter {t -> pinnedTags.none { it.tag == t } }
.sortedBy { it.normalize() }
.map { Tag(it) }
this.pinnedTags.value = pinnedTags
buildItemList()
loading.value = false
}
private fun buildItemList() {
val items = mutableListOf<FavoritesSheetGridItem>()
items.add(FavoritesSheetGridItem.Tags)
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.ManuallySorted))
if (manuallySorted.isEmpty()) {
items.add(FavoritesSheetGridItem.EmptySection)
} else {
items.addAll(manuallySorted.map { FavoritesSheetGridItem.Favorite(it) })
items.add(FavoritesSheetGridItem.Spacer())
}
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.AutomaticallySorted))
if (automaticallySorted.isEmpty()) {
items.add(FavoritesSheetGridItem.EmptySection)
} else {
items.addAll(automaticallySorted.map { FavoritesSheetGridItem.Favorite(it) })
items.add(FavoritesSheetGridItem.Spacer())
}
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.FrequentlyUsed))
if (frequentlyUsed.isEmpty()) {
items.add(FavoritesSheetGridItem.EmptySection)
} else {
items.addAll(frequentlyUsed.map { FavoritesSheetGridItem.Favorite(it) })
items.add(FavoritesSheetGridItem.Spacer())
}
gridItems.value = items
}
fun moveItem(from: LazyGridItemInfo, to: LazyGridItemInfo) {
gridItems.value?.getOrNull(from.index)?.takeIf { it is FavoritesSheetGridItem.Favorite }
?: return
gridItems.value?.getOrNull(to.index)
?.takeIf {
it is FavoritesSheetGridItem.Favorite ||
it is FavoritesSheetGridItem.EmptySection ||
it is FavoritesSheetGridItem.Spacer
}
?: return
val manuallySortedSize = manuallySorted.size + 1
val automaticallySortedSize = automaticallySorted.size + 1
val item = when {
from.index < manuallySortedSize + 2 -> {
manuallySorted.removeAt(from.index - 2)
}
from.index < manuallySortedSize + automaticallySortedSize + 3 -> {
automaticallySorted.removeAt(from.index - 3 - manuallySortedSize)
}
else -> {
frequentlyUsed.removeAt(from.index - 4 - manuallySortedSize - automaticallySortedSize)
}
}
when {
to.index < manuallySortedSize + 2 -> {
manuallySorted.add((to.index - 2).coerceAtMost(manuallySorted.size), item)
}
to.index < manuallySortedSize + automaticallySortedSize + 3 -> {
automaticallySorted.add(
(to.index - 3 - manuallySortedSize).coerceAtMost(
automaticallySorted.size
), item
)
}
else -> {
frequentlyUsed.add(
(to.index - 4 - manuallySortedSize - automaticallySortedSize).coerceAtMost(
frequentlyUsed.size
),
item
)
}
}
save()
buildItemList()
}
private fun save() {
repository.updateFavorites(
manuallySorted = buildList {
pinnedTags.value?.let { addAll(it) }
addAll(manuallySorted)
},
automaticallySorted = buildList {
addAll(automaticallySorted)
},
)
}
fun getIcon(searchable: SavableSearchable, size: Int): Flow<LauncherIcon?> {
return iconRepository.getIcon(searchable, size)
}
fun getBadge(searchable: Searchable): Flow<Badge?> {
return badgeRepository.getBadge(searchable)
}
fun pickShortcut(section: FavoritesSheetSection) {
createShortcutTarget.value = section
}
fun cancelPickShortcut() {
createShortcutTarget.value = null
}
fun getShortcutActivities() = flow {
emit(shortcutRepository.getShortcutsConfigActivities())
}
val hasShortcutPermission = permissionsManager.hasPermission(PermissionGroup.AppShortcuts)
fun requestShortcutPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.AppShortcuts)
}
fun createShortcut(context: Context, data: Intent?) {
data ?: return cancelPickShortcut()
val shortcut = AppShortcut.fromPinRequestIntent(context, data)
if (shortcut == null) {
cancelPickShortcut()
return
}
if (!manuallySorted.any { it.key == shortcut.key }
&& !automaticallySorted.any { it.key == shortcut.key }
&& !frequentlyUsed.any { it.key == shortcut.key }
) {
if (createShortcutTarget.value == FavoritesSheetSection.ManuallySorted) {
manuallySorted.add(shortcut)
} else {
automaticallySorted.add(shortcut)
}
}
save()
buildItemList()
createShortcutTarget.value = null
}
fun remove(key: String) {
val gridItems = gridItems.value?.toMutableList() ?: return
val item =
gridItems.find { it is FavoritesSheetGridItem.Favorite && it.item.key == key } as FavoritesSheetGridItem.Favorite?
if (item != null) {
repository.removeFromFavorites(item.item)
automaticallySorted.removeAll { it.key == item.item.key }
|| manuallySorted.removeAll { it.key == item.item.key }
|| frequentlyUsed.removeAll { it.key == item.item.key }
buildItemList()
}
}
val enableFrequentlyUsed = dataStore.data.map { it.favorites.frequentlyUsed }.asLiveData()
fun setFrequentlyUsed(frequentlyUsed: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFavorites(
it.favorites
.toBuilder()
.setFrequentlyUsed(frequentlyUsed)
)
.build()
}
}
}
val frequentlyUsedRows = dataStore.data.map { it.favorites.frequentlyUsedRows }.asLiveData()
fun setFrequentlyUsedRows(frequentlyUsedRows: Int) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setFavorites(
it.favorites
.toBuilder()
.setFrequentlyUsedRows(frequentlyUsedRows)
)
.build()
}
}
}
fun pinTag(tag: Tag) {
val pinned = pinnedTags.value?.toMutableList() ?: mutableListOf()
pinned.add(tag)
val available = availableTags.value ?: emptyList()
availableTags.value = available.filter { it.tag != tag.tag }
pinnedTags.value = pinned.distinctBy { it.tag }
save()
}
fun unpinTag(tag: Tag) {
val pinned = pinnedTags.value?.toMutableList() ?: mutableListOf()
val available = availableTags.value ?: emptyList()
availableTags.value = (available + tag).sorted()
pinnedTags.value = pinned.filter { it.tag != tag.tag }
save()
}
fun moveTag(from: LazyListItemInfo, to: LazyListItemInfo) {
val pinned = pinnedTags.value?.toMutableList() ?: return
val tag = pinned.removeAt(from.index)
pinned.add(to.index, tag)
pinnedTags.value = pinned
save()
}
fun addTag(key: String?, tag: String?) {
val gridItems = gridItems.value?.toMutableList() ?: return
if (key == null || tag == null) return
val item =
gridItems.find { it is FavoritesSheetGridItem.Favorite && it.item.key == key } as FavoritesSheetGridItem.Favorite?
if (item != null) {
automaticallySorted.removeAll { it.key == item.item.key }
|| manuallySorted.removeAll { it.key == item.item.key }
|| frequentlyUsed.removeAll { it.key == item.item.key }
buildItemList()
customAttributesRepository.addTag(item.item, tag)
repository.unpinItem(item.item)
viewModelScope.launch {
frequentlyUsed = repository.getFavorites(
frequentlyUsed = true,
excludeTypes = listOf("tag"),
).first().toMutableList()
buildItemList()
}
}
}
fun createNewTag(newTag: String) {
pinTag(Tag(newTag))
}
}
@@ -0,0 +1,61 @@
package de.mm20.launcher2.ui.launcher.sheets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
@Composable
fun HiddenItemsSheet(
items: List<SavableSearchable>,
onDismiss: () -> Unit
) {
val viewModel: HiddenItemsSheetVM = viewModel()
val context = LocalContext.current
BottomSheetDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(R.string.preference_hidden_items),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 16.dp),
maxLines = 1
)
},
actions = {
IconButton(onClick = { viewModel.showHiddenItems(context) }) {
Icon(imageVector = Icons.Rounded.Edit, contentDescription = null)
}
},
confirmButton = {
OutlinedButton(onClick = onDismiss) {
Text(stringResource(id = R.string.close))
}
}
) {
SearchResultGrid(
items,
modifier = Modifier
.verticalScroll(rememberScrollState())
)
}
}
@@ -0,0 +1,17 @@
package de.mm20.launcher2.ui.launcher.sheets
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
import de.mm20.launcher2.ui.settings.SettingsActivity
class HiddenItemsSheetVM: ViewModel() {
fun showHiddenItems(context: Context) {
context.startActivity(
Intent(context, SettingsActivity::class.java).apply {
putExtra(SettingsActivity.EXTRA_ROUTE, "settings/search/hiddenitems")
}
)
}
}
@@ -0,0 +1,34 @@
package de.mm20.launcher2.ui.launcher.sheets
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import de.mm20.launcher2.search.SavableSearchable
class LauncherBottomSheetManager {
val customizeSearchableSheetShown = mutableStateOf<SavableSearchable?>(null)
val editFavoritesSheetShown = mutableStateOf(false)
val hiddenItemsSheetShown = mutableStateOf(false)
fun showCustomizeSearchableModal(item: SavableSearchable) {
customizeSearchableSheetShown.value = item
}
fun dismissCustomizeSearchableModal() {
customizeSearchableSheetShown.value = null
}
fun showEditFavoritesSheet() {
editFavoritesSheetShown.value = true
}
fun dismissEditFavoritesSheet() {
editFavoritesSheetShown.value = false
}
fun showHiddenItemsSheet() {
hiddenItemsSheetShown.value = true
}
fun dismissHiddenItemsSheet() {
hiddenItemsSheetShown.value = false
}
}
val LocalBottomSheetManager = compositionLocalOf { LauncherBottomSheetManager() }
@@ -0,0 +1,11 @@
package de.mm20.launcher2.ui.launcher.transitions
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
data class HomeTransition(
val startBounds: Rect,
val targetBounds: Rect,
val icon: (@Composable (animVector: Offset, progress: () -> Float) -> Unit)? = null
)

Some files were not shown because too many files have changed in this diff Show More