diff --git a/.idea/emulatorDisplays.xml b/.idea/emulatorDisplays.xml new file mode 100644 index 00000000..1bb36d9e --- /dev/null +++ b/.idea/emulatorDisplays.xml @@ -0,0 +1,47 @@ + + + + + + \ No newline at end of file diff --git a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index 5874ba4e..47f33b95 100644 --- a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -43,7 +43,6 @@ import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import org.koin.core.logger.Level -import java.text.Collator import kotlin.coroutines.CoroutineContext class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory { @@ -109,12 +108,4 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory { .crossfade(200) .build() } - - companion object { - - val collator: Collator by lazy { - Collator.getInstance().apply { strength = Collator.SECONDARY } - } - } - } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt index 3d1d5eeb..e5527d59 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt @@ -2,6 +2,7 @@ package de.mm20.launcher2.ui.assistant import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* @@ -25,6 +26,7 @@ import de.mm20.launcher2.ui.launcher.gestures.LauncherGestureHandler 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.LocalDarkTheme import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -112,6 +114,7 @@ fun AssistantScaffold( val colorSurface = MaterialTheme.colorScheme.surface + val isDarkTheme = LocalDarkTheme.current LaunchedEffect(darkStatusBarIcons, colorSurface, showStatusBarScrim) { if (showStatusBarScrim) { systemUiController.setStatusBarColor( @@ -120,7 +123,7 @@ fun AssistantScaffold( } else { systemUiController.setStatusBarColor( Color.Transparent, - darkIcons = darkStatusBarIcons + darkIcons = !isDarkTheme, ) } } @@ -167,8 +170,8 @@ fun AssistantScaffold( SearchColumn( modifier = Modifier.fillMaxSize(), paddingValues = PaddingValues( - top = (if (bottomSearchBar) 0.dp else 56.dp + webSearchPadding) + 4.dp + windowInsets.calculateTopPadding(), - bottom = (if (bottomSearchBar) 56.dp + webSearchPadding else 0.dp) + 4.dp + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding + top = (if (bottomSearchBar) 0.dp else 64.dp + webSearchPadding) + 8.dp + windowInsets.calculateTopPadding(), + bottom = (if (bottomSearchBar) 64.dp + webSearchPadding else 0.dp) + 8.dp + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding ), reverse = reverseSearchResults, state = searchState diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt index b740aa98..c575d799 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt @@ -24,7 +24,7 @@ abstract class FavoritesVM : ViewModel(), KoinComponent { val selectedTag = MutableStateFlow(null) - val showEditButton = settings.showEditButton + val showEditButton = settings.showEditButton.stateIn(viewModelScope, SharingStarted.Lazily, false) abstract val tagsExpanded: Flow val pinnedTags = favoritesService.getFavorites( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt index 0e34ade4..8056da3f 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt @@ -80,7 +80,7 @@ fun SearchBar( when { it == SearchBarLevel.Resting && style != SearchBarStyle.Solid -> 0.dp it == SearchBarLevel.Raised -> 8.dp - else -> 2.dp + else -> 0.dp } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ButtonDefaults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ButtonDefaults.kt new file mode 100644 index 00000000..d756f8cf --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/ButtonDefaults.kt @@ -0,0 +1,13 @@ +package de.mm20.launcher2.ui.ktx + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonDefaults +import androidx.compose.ui.unit.dp + +val ButtonDefaults.TextButtonWithTrailingIconContentPadding + get() = PaddingValues( + start = 16.dp, + top = ContentPadding.calculateTopPadding(), + end = 12.dp, + bottom = ContentPadding.calculateBottomPadding() + ) \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/CornerBasedShape.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/CornerBasedShape.kt new file mode 100644 index 00000000..57a6315d --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/CornerBasedShape.kt @@ -0,0 +1,138 @@ +package de.mm20.launcher2.ui.ktx + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.animateValueAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +/** + * Animates the corners of a shape between its original value and 0. + * For each parameter set to true, the corresponding corner will animate to the original value. + * Otherwise, it will animate to 0. + */ +@Composable +fun CornerBasedShape.animateCorners( + topStart: Boolean, + topEnd: Boolean, + bottomEnd: Boolean, + bottomStart: Boolean, +): CornerBasedShape { + val value by animateShapeAsState( + copy( + topStart = if (topStart) this.topStart else CornerSize(0f), + topEnd = if (topEnd) this.topEnd else CornerSize(0f), + bottomEnd = if (bottomEnd) this.bottomEnd else CornerSize(0f), + bottomStart = if (bottomStart) this.bottomStart else CornerSize(0f) + ) + ) + + return value +} + +/** + * Animate between two shapes. + * Limitations: + * - Only works for [RoundedCornerShape] and [CutCornerShape] + * - Shape type should be consistent (e.g. you can't animate between [RoundedCornerShape] and [CutCornerShape]), otherwise the animation will be incorrect + * - Doesn't support percentage based corner sizes + */ +@Composable +fun animateShapeAsState( + shape: CornerBasedShape, + animationSpec: AnimationSpec = remember { spring() }, + visibilityThreshold: CornerBasedShape? = null, + label: String = "ValueAnimation", + finishedListener: ((CornerBasedShape) -> Unit)? = null +): State { + val density = LocalDensity.current + val converter = remember(shape.javaClass, density) { + if (shape is CutCornerShape) CutCornerShapeConverter(density) + else RoundedCornerShapeConverter(density) + } + return animateValueAsState( + shape, + typeConverter = converter, + animationSpec = animationSpec, + visibilityThreshold = visibilityThreshold, + label = label, + finishedListener = finishedListener + ) +} + +private class RoundedCornerShapeConverter( + private val density: Density, +) : TwoWayConverter { + override val convertFromVector: (AnimationVector4D) -> CornerBasedShape + get() = { + RoundedCornerShape( + topStart = it.v1, + topEnd = it.v2, + bottomEnd = it.v3, + bottomStart = it.v4 + ) + } + override val convertToVector: (CornerBasedShape) -> AnimationVector4D + get() = { + AnimationVector4D( + it.topStart.toPx(Size.Zero, density), + it.topEnd.toPx(Size.Zero, density), + it.bottomEnd.toPx(Size.Zero, density), + it.bottomStart.toPx(Size.Zero, density) + ) + } + +} + +private class CutCornerShapeConverter( + private val density: Density, +) : TwoWayConverter { + override val convertFromVector: (AnimationVector4D) -> CornerBasedShape + get() = { + CutCornerShape( + topStart = it.v1, + topEnd = it.v2, + bottomEnd = it.v3, + bottomStart = it.v4 + ) + } + override val convertToVector: (CornerBasedShape) -> AnimationVector4D + get() = { + AnimationVector4D( + it.topStart.toPx(Size.Zero, density), + it.topEnd.toPx(Size.Zero, density), + it.bottomEnd.toPx(Size.Zero, density), + it.bottomStart.toPx(Size.Zero, density) + ) + } + +} + +fun CornerBasedShape.withCorners( + topStart: Boolean = true, + topEnd: Boolean = true, + bottomEnd: Boolean = true, + bottomStart: Boolean = true +): Shape { + if (topStart && topEnd && bottomEnd && bottomStart) return this + if (!topStart && !topEnd && !bottomEnd && !bottomStart) return RectangleShape + return copy( + topStart = if (topStart) this.topStart else CornerSize(0f), + topEnd = if (topEnd) this.topEnd else CornerSize(0f), + bottomEnd = if (bottomEnd) this.bottomEnd else CornerSize(0f), + bottomStart = if (bottomStart) this.bottomStart else CornerSize(0f) + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt index e50a1d91..374d777b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt @@ -30,6 +30,7 @@ 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.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults @@ -56,6 +57,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -96,6 +98,7 @@ 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.LocalDarkTheme import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper import kotlinx.coroutines.launch import kotlin.math.absoluteValue @@ -192,7 +195,8 @@ fun PagerScaffold( val systemUiController = rememberSystemUiController() val colorSurface = MaterialTheme.colorScheme.surface - LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim) { + val isDarkTheme = LocalDarkTheme.current + LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim, isSearchOpen) { if (isWidgetEditMode) { systemUiController.setStatusBarColor( colorSurface @@ -201,6 +205,11 @@ fun PagerScaffold( systemUiController.setStatusBarColor( colorSurface.copy(0.7f), ) + } else if (isSearchOpen) { + systemUiController.setStatusBarColor( + Color.Transparent, + darkIcons = !isDarkTheme, + ) } else { systemUiController.setStatusBarColor( Color.Transparent, @@ -293,8 +302,6 @@ fun PagerScaffold( handleBackOrHomeEvent() } - val keyboardController = LocalSoftwareKeyboardController.current - val gestureManager = LocalGestureDetector.current val density = LocalDensity.current @@ -395,10 +402,16 @@ fun PagerScaffold( ) { val minFlingVelocity = 1000.dp.toPixels() + val colorSurfaceContainer = MaterialTheme.colorScheme.surfaceContainer HorizontalPager( modifier = Modifier .fillMaxSize() + .drawBehind { + drawRect( + color = colorSurfaceContainer.copy(alpha = -pagerState.getOffsetDistanceInPages(0) * 0.85f), + ) + } .nestedScroll(pagerNestedScrollConnection), beyondViewportPageCount = 1, reverseLayout = reverse == (LocalLayoutDirection.current == LayoutDirection.Ltr), @@ -514,13 +527,13 @@ fun PagerScaffold( val windowInsets = WindowInsets.safeDrawing.asPaddingValues() val paddingValues = if (bottomSearchBar) { PaddingValues( - top = 4.dp + windowInsets.calculateTopPadding(), - bottom = 60.dp + webSearchPadding + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding + top = 8.dp + windowInsets.calculateTopPadding(), + bottom = 64.dp + webSearchPadding + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding ) } else { PaddingValues( - bottom = 4.dp + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding, - top = 60.dp + webSearchPadding + windowInsets.calculateTopPadding() + bottom = 8.dp + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding, + top = 64.dp + webSearchPadding + windowInsets.calculateTopPadding() ) } SearchColumn( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt index 74cdf35f..d1843f77 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box @@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState @@ -50,6 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.Color import androidx.compose.ui.graphics.TransformOrigin @@ -80,6 +83,7 @@ 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.LocalDarkTheme import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper import kotlinx.coroutines.launch import kotlin.math.absoluteValue @@ -194,7 +198,8 @@ fun PullDownScaffold( } val colorSurface = MaterialTheme.colorScheme.surface - LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim) { + val isDarkTheme = LocalDarkTheme.current + LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim, isSearchOpen) { if (isWidgetEditMode) { systemUiController.setStatusBarColor( colorSurface @@ -203,6 +208,11 @@ fun PullDownScaffold( systemUiController.setStatusBarColor( colorSurface.copy(0.7f), ) + } else if (isSearchOpen) { + systemUiController.setStatusBarColor( + Color.Transparent, + darkIcons = !isDarkTheme, + ) } else { systemUiController.setStatusBarColor( Color.Transparent, @@ -376,8 +386,14 @@ fun PullDownScaffold( } val insets = WindowInsets.safeDrawing.asPaddingValues() + val colorSurfaceContainer = MaterialTheme.colorScheme.surfaceContainer Box( modifier = modifier + .drawBehind { + drawRect( + color = colorSurfaceContainer.copy(alpha = -pagerState.getOffsetDistanceInPages(0) * 0.85f), + ) + } .pointerInput(Unit) { detectHorizontalDragGestures( onDragEnd = { @@ -402,7 +418,8 @@ fun PullDownScaffold( LocalOverscrollConfiguration provides null ) { VerticalPager( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), beyondViewportPageCount = 1, state = pagerState, reverseLayout = true, @@ -518,10 +535,10 @@ fun PullDownScaffold( ), ), paddingValues = PaddingValues( - top = windowInsets.calculateTopPadding() + if (!bottomSearchBar) 60.dp + webSearchPadding else 4.dp, + top = windowInsets.calculateTopPadding() + if (!bottomSearchBar) 64.dp + webSearchPadding else 8.dp, bottom = windowInsets.calculateBottomPadding() + keyboardFilterBarPadding + - if (bottomSearchBar) 60.dp + webSearchPadding else 4.dp + if (bottomSearchBar) 64.dp + webSearchPadding else 8.dp ), state = searchState, reverse = reverseSearchResults, @@ -596,7 +613,7 @@ fun PullDownScaffold( searchBarOffset = { (if (searchBarFocused || fixedSearchBar) 0 else searchBarOffset.value.toInt() * (if (bottomSearchBar) 1 else -1)) + with(density) { - (editModeSearchBarOffset - if(bottomSearchBar) keyboardFilterBarPadding else 0.dp) + (editModeSearchBarOffset - if (bottomSearchBar) keyboardFilterBarPadding else 0.dp) .toPx() .roundToInt() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index 4f25ee4a..e7ed2fd7 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -3,6 +3,7 @@ package de.mm20.launcher2.ui.launcher.search import androidx.activity.compose.BackHandler import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -10,27 +11,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size 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.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -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.FilterChipDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -38,35 +31,42 @@ 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.AppShortcut +import de.mm20.launcher2.search.Application +import de.mm20.launcher2.search.Article +import de.mm20.launcher2.search.CalendarEvent +import de.mm20.launcher2.search.Contact +import de.mm20.launcher2.search.File +import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.SavableSearchable -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.common.FavoritesTagSelector -import de.mm20.launcher2.ui.component.Banner +import de.mm20.launcher2.search.Website 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.apps.AppResults +import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorResults +import de.mm20.launcher2.ui.launcher.search.calendar.CalendarResults 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.contacts.ContactResults +import de.mm20.launcher2.ui.launcher.search.favorites.SearchFavorites import de.mm20.launcher2.ui.launcher.search.favorites.SearchFavoritesVM +import de.mm20.launcher2.ui.launcher.search.files.FileResults import de.mm20.launcher2.ui.launcher.search.filters.SearchFilters +import de.mm20.launcher2.ui.launcher.search.location.LocationResults +import de.mm20.launcher2.ui.launcher.search.shortcut.ShortcutResults import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterItem +import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterResults import de.mm20.launcher2.ui.launcher.search.website.WebsiteItem +import de.mm20.launcher2.ui.launcher.search.website.WebsiteResults import de.mm20.launcher2.ui.launcher.search.wikipedia.ArticleItem +import de.mm20.launcher2.ui.launcher.search.wikipedia.ArticleResults import de.mm20.launcher2.ui.launcher.sheets.HiddenItemsSheet import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager import de.mm20.launcher2.ui.locals.LocalCardStyle import de.mm20.launcher2.ui.locals.LocalGridSettings import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlin.math.ceil - -private const val PRIORITY_MIN = Int.MAX_VALUE -private const val PRIORITY_MAX = Int.MIN_VALUE @Composable fun SearchColumn( @@ -85,8 +85,6 @@ fun SearchColumn( val favoritesVM: SearchFavoritesVM = viewModel() val favorites by favoritesVM.favorites.collectAsState(emptyList()) - var showWorkProfileApps by remember { mutableStateOf(false) } - val hideFavs by viewModel.hideFavorites val favoritesEnabled by viewModel.favoritesEnabled.collectAsState(false) val apps by viewModel.appResults @@ -101,7 +99,6 @@ fun SearchColumn( val locations by viewModel.locationResults val website by viewModel.websiteResults val hiddenResults by viewModel.hiddenResults - val separateWorkProfile by viewModel.separateWorkProfile.collectAsState(true) val bestMatch by viewModel.bestMatch @@ -119,284 +116,199 @@ fun SearchColumn( val favoritesEditButton by favoritesVM.showEditButton.collectAsState(false) val favoritesTagsExpanded by favoritesVM.tagsExpanded.collectAsState(false) + var showWorkProfileApps by remember { mutableStateOf(false) } + val separateWorkProfile by viewModel.separateWorkProfile.collectAsState(true) + val visibleApps by remember { + derivedStateOf { + when { + !separateWorkProfile -> (apps + workApps).sorted() + workApps.isEmpty() -> apps + apps.isEmpty() -> workApps + showWorkProfileApps -> workApps + else -> apps + } + } + } + + var expandedCategory: SearchCategory? by remember(isSearchEmpty) { mutableStateOf(null) } + + var selectedContactIndex: Int by remember(contacts) { mutableIntStateOf(-1) } + var selectedFileIndex: Int by remember(files) { mutableIntStateOf(-1) } + var selectedCalendarIndex: Int by remember(events) { mutableIntStateOf(-1) } + var selectedLocationIndex: Int by remember(events) { mutableIntStateOf(-1) } + var selectedShortcutIndex: Int by remember(events) { mutableIntStateOf(-1) } + var selectedArticleIndex: Int by remember(events) { mutableIntStateOf(-1) } + var selectedWebsiteIndex: Int by remember(events) { mutableIntStateOf(-1) } + val showFilters by viewModel.showFilters - AnimatedContent(showFilters) { + AnimatedContent( + showFilters, + modifier = modifier.padding(horizontal = 8.dp), + ) { if (it) { BackHandler { viewModel.showFilters.value = false } Box( - modifier = modifier + modifier = Modifier .fillMaxSize() .padding(paddingValues), contentAlignment = if (reverse) Alignment.BottomCenter else Alignment.TopCenter, ) { - LauncherCard( + SearchFilters( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - SearchFilters( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - filters = viewModel.filters.value, - onFiltersChange = { - viewModel.setFilters(it) - } - ) - } + .padding(top = 4.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + MaterialTheme.shapes.medium + ) + .padding(12.dp), + filters = viewModel.filters.value, + onFiltersChange = { + viewModel.setFilters(it) + } + ) } } else { LazyColumn( state = state, - modifier = modifier, userScrollEnabled = userScrollEnabled, contentPadding = paddingValues, reverseLayout = reverse, ) { if (!hideFavs && favoritesEnabled) { - GridResults( - items = favorites.toImmutableList(), - columns = columns, - key = "favorites", + SearchFavorites( + favorites = favorites, + selectedTag = selectedTag, + pinnedTags = pinnedTags, + tagsExpanded = favoritesTagsExpanded, + onSelectTag = { favoritesVM.selectTag(it) }, 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 { - { - FavoritesTagSelector( - tags = pinnedTags, - selectedTag = selectedTag, - editButton = favoritesEditButton, - reverse = reverse, - onSelectTag = { favoritesVM.selectTag(it) }, - scrollState = tagsScrollState, - expanded = favoritesTagsExpanded, - onExpand = { favoritesVM.setTagsExpanded(it) } - ) - } + onExpandTags = { + favoritesVM.setTagsExpanded(it) }, - highlightedItem = bestMatch as? SavableSearchable + editButton = favoritesEditButton ) } - GridResults( - items = if (separateWorkProfile) if ((showWorkProfileApps || apps.isEmpty()) && workApps.isNotEmpty()) workApps.toImmutableList() else apps.toImmutableList() else listOf( - apps, - workApps - ).flatten().sorted().toImmutableList(), + AppResults( + apps = visibleApps, + showTabs = separateWorkProfile && apps.isNotEmpty() && workApps.isNotEmpty(), + highlightedItem = bestMatch as? Application, + selectedTab = if (showWorkProfileApps) 1 else 0, + onSelectedTabChange = { showWorkProfileApps = it == 1 }, columns = columns, - reverse = reverse, - key = "apps", - before = if (separateWorkProfile && workApps.isNotEmpty() && apps.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, - modifier = Modifier.size(FilterChipDefaults.IconSize) - ) - }, - 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, - modifier = Modifier.size(FilterChipDefaults.IconSize) - ) - }, - label = { - Text( - stringResource(R.string.apps_profile_work), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - } else null, - highlightedItem = bestMatch as? SavableSearchable + reverse = reverse ) - 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), - ) - } - } - ) + + if (!isSearchEmpty) { + + ShortcutResults( + shortcuts = appShortcuts, + missingPermission = missingShortcutsPermission, + onPermissionRequest = { + viewModel.requestAppShortcutPermission(context as AppCompatActivity) + }, + onPermissionRequestRejected = { + viewModel.disableAppShortcutSearch() + }, + reverse = reverse, + selectedIndex = selectedShortcutIndex, + onSelect = { selectedShortcutIndex = it }, + highlightedItem = bestMatch as? AppShortcut, + ) + + UnitConverterResults( + converters = unitConverter, + reverse = reverse, + truncate = expandedCategory != SearchCategory.UnitConverter, + onShowAll = { + expandedCategory = SearchCategory.UnitConverter } - } else null, - items = appShortcuts.toImmutableList(), - reverse = reverse, - key = "shortcuts", - highlightedItem = bestMatch as? SavableSearchable - ) - for (conv in unitConverter) { - SingleResult { - UnitConverterItem(unitConverter = conv) - } + ) + + CalculatorResults( + calculator, + reverse = reverse + ) + + CalendarResults( + events = events, + missingPermission = missingCalendarPermission, + onPermissionRequest = { + viewModel.requestCalendarPermission(context as AppCompatActivity) + }, + onPermissionRequestRejected = { + viewModel.disableCalendarSearch() + }, + reverse = reverse, + selectedIndex = selectedCalendarIndex, + onSelect = { selectedCalendarIndex = it }, + highlightedItem = bestMatch as? CalendarEvent, + ) + + ContactResults( + contacts = contacts, + missingPermission = missingContactsPermission, + onPermissionRequest = { + viewModel.requestContactsPermission(context as AppCompatActivity) + }, + onPermissionRequestRejected = { + viewModel.disableContactsSearch() + }, + reverse = reverse, + selectedIndex = selectedContactIndex, + onSelect = { selectedContactIndex = it }, + highlightedItem = bestMatch as? Contact, + ) + + LocationResults( + locations = locations, + missingPermission = missingLocationPermission, + onPermissionRequest = { + viewModel.requestLocationPermission(context as AppCompatActivity) + }, + onPermissionRequestRejected = { + viewModel.disableLocationSearch() + }, + reverse = reverse, + selectedIndex = selectedLocationIndex, + onSelect = { selectedLocationIndex = it }, + highlightedItem = bestMatch as? Location, + ) + ArticleResults( + articles = wikipedia, + selectedIndex = selectedArticleIndex, + onSelect = { selectedArticleIndex = it }, + highlightedItem = bestMatch as? Article, + reverse = reverse, + ) + WebsiteResults( + websites = website, + selectedIndex = selectedWebsiteIndex, + onSelect = { selectedWebsiteIndex = it }, + highlightedItem = bestMatch as? Website, + reverse = reverse, + ) + FileResults( + files = files, + onPermissionRequest = { + viewModel.requestFilesPermission(context as AppCompatActivity) + }, + onPermissionRequestRejected = { + viewModel.disableFilesSearch() + }, + reverse = reverse, + highlightedItem = bestMatch as? File, + missingPermission = missingFilesPermission, + selectedIndex = selectedFileIndex, + onSelect = { + selectedFileIndex = it + } + ) } - 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", - highlightedItem = bestMatch as? SavableSearchable - ) - 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", - highlightedItem = bestMatch as? SavableSearchable - ) - ListResults( - before = if (missingLocationPermission && !isSearchEmpty) { - { - MissingPermissionBanner( - modifier = Modifier.padding(8.dp), - text = stringResource(R.string.missing_permission_location_search), - onClick = { viewModel.requestLocationPermission(context as AppCompatActivity) }, - secondaryAction = { - OutlinedButton(onClick = { - viewModel.disableLocationSearch() - }) { - Text( - stringResource(R.string.turn_off), - ) - } - } - ) - } - } else null, - items = locations.toImmutableList(), - reverse = reverse, - key = "locations", - highlightedItem = bestMatch as? SavableSearchable - ) - for (wiki in wikipedia) { - SingleResult(highlight = bestMatch == wiki) { - ArticleItem(article = wiki) - } - } - for (ws in website) { - SingleResult(highlight = bestMatch == ws) { - 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", - highlightedItem = bestMatch as? SavableSearchable - ) } } @@ -411,170 +323,6 @@ fun SearchColumn( } } -fun LazyListScope.GridResults( - items: ImmutableList, - columns: Int, - reverse: Boolean, - key: String, - before: (@Composable () -> Unit)? = null, - after: (@Composable () -> Unit)? = null, - highlightedItem: SavableSearchable? -) { - 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, - highlightedItem = highlightedItem - ) - } - } - - 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, - columns: Int, - showLabels: Boolean = LocalGridSettings.current.showLabels, - highlightedItem: SavableSearchable? -) { - - Row( - modifier = modifier - ) { - for (item in items) { - GridItem( - modifier = Modifier - .weight(1f) - .padding(4.dp), - item = item, - showLabels = showLabels, - highlight = item.key == highlightedItem?.key - ) - } - for (i in 0 until columns - items.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -fun LazyListScope.ListResults( - items: ImmutableList, - reverse: Boolean, - key: String, - before: (@Composable () -> Unit)? = null, - after: (@Composable () -> Unit)? = null, - highlightedItem: SavableSearchable?, -) { - 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], - highlight = items[it].key == highlightedItem?.key - ) - } - } - 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, - highlight: Boolean -) { - Box( - modifier = modifier.padding( - start = 8.dp, - end = 8.dp, - ) - ) { - ListItem( - modifier = Modifier - .fillMaxWidth(), - item = item, - highlight = highlight - ) - } -} fun LazyListScope.SingleResult( highlight: Boolean = false, @@ -596,31 +344,15 @@ fun LazyListScope.SingleResult( } } -@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() - ) { - 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() - } - } +enum class SearchCategory { + Apps, + Calculator, + Calendar, + Contacts, + Files, + UnitConverter, + Wikipedia, + Website, + Location, + Shortcut, } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt new file mode 100644 index 00000000..2e0cc818 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt @@ -0,0 +1,83 @@ +package de.mm20.launcher2.ui.launcher.search.apps + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import de.mm20.launcher2.search.Application +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.ktx.animateCorners +import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem +import de.mm20.launcher2.ui.launcher.search.common.grid.GridResults +import de.mm20.launcher2.ui.layout.BottomReversed +import de.mm20.launcher2.ui.locals.LocalGridSettings + +fun LazyListScope.AppResults( + apps: List, + showTabs: Boolean, + selectedTab: Int, + onSelectedTabChange: (Int) -> Unit, + highlightedItem: Application? = null, + columns: Int, + reverse: Boolean, +) { + + GridResults( + key = "app", + items = apps, + itemContent = { + GridItem( + item = it, + showLabels = LocalGridSettings.current.showLabels, + highlight = it.key == highlightedItem?.key + ) + }, + before = if (showTabs) { + { + Column( + verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top, + ) { + PrimaryTabRow( + selectedTabIndex = selectedTab, + modifier = Modifier + .fillMaxWidth() + .clip( + MaterialTheme.shapes.medium.animateCorners( + topStart = !reverse, + topEnd = !reverse, + bottomEnd = reverse, + bottomStart = reverse, + ) + ), + divider = {}, + ) { + Tab( + selected = selectedTab == 0, + onClick = { onSelectedTabChange(0) }, + text = { Text(stringResource(R.string.apps_profile_main)) }, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Tab( + selected = selectedTab == 1, + onClick = { onSelectedTabChange(1) }, + text = { Text(stringResource(R.string.apps_profile_work)) }, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider() + } + } + } else null, + reverse = reverse, + columns = columns, + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calculator/CalculatorResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calculator/CalculatorResults.kt new file mode 100644 index 00000000..6285d3f4 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calculator/CalculatorResults.kt @@ -0,0 +1,22 @@ +package de.mm20.launcher2.ui.launcher.search.calculator + +import androidx.compose.foundation.lazy.LazyListScope +import de.mm20.launcher2.search.data.Calculator +import de.mm20.launcher2.ui.launcher.search.common.list.ListItemSurface + +fun LazyListScope.CalculatorResults( + calculator: List, + reverse: Boolean, +) { + if (calculator.isNotEmpty()) { + item(key = "calculator") { + ListItemSurface( + isFirst = true, + isLast = true, + reverse = reverse, + ) { + CalculatorItem(calculator = calculator.first()) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarResults.kt new file mode 100644 index 00000000..851b6abd --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarResults.kt @@ -0,0 +1,59 @@ +package de.mm20.launcher2.ui.launcher.search.calendar + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.search.CalendarEvent +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.CalendarResults( + events: List, + missingPermission: Boolean, + onPermissionRequest: () -> Unit, + onPermissionRequestRejected: () -> Unit, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: CalendarEvent?, + reverse: Boolean, +) { + ListResults( + items = events, + key = "calendar", + reverse = reverse, + selectedIndex = selectedIndex, + itemContent = { calendar, showDetails, index -> + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = calendar, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = highlightedItem?.key == calendar.key + ) + }, + before = if (missingPermission) { + { + MissingPermissionBanner( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.missing_permission_calendar_search), + onClick = onPermissionRequest, + secondaryAction = { + OutlinedButton(onClick = onPermissionRequestRejected) { + Text( + stringResource(R.string.turn_off), + ) + } + } + ) + } + } else null + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt index e01185dc..52e8e5a2 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt @@ -80,6 +80,7 @@ fun GridItem( modifier: Modifier = Modifier, item: SavableSearchable, showLabels: Boolean = true, + labelMaxLines: Int = 1, highlight: Boolean = false ) { val viewModel: SearchableItemVM = listItemViewModel(key = "search-${item.key}") @@ -181,8 +182,9 @@ fun GridItem( text = item.labelOverride ?: item.label, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis + maxLines = labelMaxLines, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onBackground, ) } @@ -223,7 +225,7 @@ fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit .fillMaxSize() .systemBarsPadding() .imePadding() - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .then( if (show.targetState) { Modifier.pointerInput(Unit) { @@ -242,7 +244,7 @@ fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit modifier = Modifier .placeOverlay( origin.translate( - -16.dp.toPixels(), + -8.dp.toPixels(), -WindowInsets.systemBars.union(WindowInsets.ime) .getTop(LocalDensity.current).toFloat() ), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt new file mode 100644 index 00000000..e0d09170 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt @@ -0,0 +1,130 @@ +package de.mm20.launcher2.ui.launcher.search.common.grid + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.LazyListScope +import androidx.compose.material3.MaterialTheme +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.ktx.withCorners +import de.mm20.launcher2.ui.locals.LocalCardStyle +import kotlin.math.ceil + +fun LazyListScope.GridResults( + key: String, + items: List, + itemContent: @Composable (T) -> Unit, + before: @Composable (() -> Unit)? = null, + after: @Composable (() -> Unit)? = null, + columns: Int, + reverse: Boolean = false, +) { + if (before != null) { + item( + key = "$key-before", + ) { + val isTop = !reverse || items.isEmpty() && after == null + val isBottom = reverse || items.isEmpty() && after == null + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity), + MaterialTheme.shapes.medium.withCorners( + topStart = isTop, + topEnd = isTop, + bottomEnd = isBottom, + bottomStart = isBottom, + ) + ) + ) { + before() + } + } + } + + val rows = ceil(items.size / columns.toFloat()).toInt() + items( + rows, + key = { + "$key-$it" + } + ) { + + val isFirst = it == 0 && before == null + val isLast = it == rows - 1 && after == null + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + top = if (reverse && isLast) 8.dp else 0.dp, + bottom = if (!reverse && isLast) 8.dp else 0.dp, + ) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity), + MaterialTheme.shapes.medium.withCorners( + topStart = isFirst && !reverse || isLast && reverse, + topEnd = isFirst && !reverse || isLast && reverse, + bottomEnd = isLast && !reverse || isFirst && reverse, + bottomStart = isLast && !reverse || isFirst && reverse, + ) + ) + .padding( + top = if (it == 0) 8.dp else 0.dp, + bottom = if (it == rows - 1) 8.dp else 0.dp, + start = 4.dp, + end = 4.dp, + ) + ) { + Row { + for (i in 0 until columns) { + val item = items.getOrNull(it * columns + i) + if (item != null) { + Box( + modifier = Modifier + .weight(1f) + .padding(4.dp) + ) { + itemContent(item) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + } + } + } + } + + if (after != null) { + item( + key = "$key-after", + ) { + val isTop = reverse || items.isEmpty() && before == null + val isBottom = !reverse || items.isEmpty() && before == null + Box( + modifier = Modifier + .padding( + top = if (reverse) 8.dp else 0.dp, + bottom = if (!reverse) 8.dp else 0.dp, + ) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity), + MaterialTheme.shapes.medium.withCorners( + topStart = isTop, + topEnd = isTop, + bottomEnd = isBottom, + bottomStart = isBottom, + ) + ) + ) { + after() + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt index dd7e3b43..a21f870a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt @@ -1,8 +1,21 @@ package de.mm20.launcher2.ui.launcher.search.common.list +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow @@ -10,12 +23,13 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import de.mm20.launcher2.search.AppShortcut +import de.mm20.launcher2.search.Article import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.File import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.SavableSearchable -import de.mm20.launcher2.ui.component.InnerCard +import de.mm20.launcher2.search.Website import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM @@ -24,15 +38,18 @@ import de.mm20.launcher2.ui.launcher.search.files.FileItem import de.mm20.launcher2.ui.launcher.search.listItemViewModel import de.mm20.launcher2.ui.launcher.search.location.LocationItem import de.mm20.launcher2.ui.launcher.search.shortcut.AppShortcutItem +import de.mm20.launcher2.ui.launcher.search.website.WebsiteItem +import de.mm20.launcher2.ui.launcher.search.wikipedia.ArticleItem import de.mm20.launcher2.ui.locals.LocalGridSettings @Composable fun ListItem( modifier: Modifier = Modifier, item: SavableSearchable, - highlight: Boolean = false + highlight: Boolean = false, + showDetails: Boolean, + onShowDetails: (Boolean) -> Unit ) { - var showDetails by remember { mutableStateOf(false) } val context = LocalContext.current val viewModel: SearchableItemVM = listItemViewModel(key = "search-${item.key}") @@ -41,104 +58,147 @@ fun ListItem( LaunchedEffect(item, iconSize) { viewModel.init(item, iconSize.toInt()) } - + LaunchedEffect(showDetails) { if (showDetails) viewModel.requestUpdatedSearchable(context) } + val background by animateColorAsState( + if (highlight) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface.copy( + alpha = 0f + ) + ) + val item = viewModel.searchable.collectAsState().value ?: item var bounds by remember { mutableStateOf(Rect.Zero) } - InnerCard( + Box( modifier = modifier + .background(background) .onGloballyPositioned { bounds = it.boundsInWindow() }, - highlight = highlight, - 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 } - ) + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface + ) { + when (item) { + is Contact -> { + ContactItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { onShowDetails(true) }, + onLongClick = { onShowDetails(true) } + ), + contact = item, + showDetails = showDetails, + onBack = { onShowDetails(false) } + ) + } + + is File -> { + FileItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + onShowDetails(true) + } + }, + onLongClick = { onShowDetails(true) } + ), + file = item, + showDetails = showDetails, + onBack = { onShowDetails(false) } + ) + } + + is CalendarEvent -> { + CalendarItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { onShowDetails(true) }, + onLongClick = { onShowDetails(true) } + ) + .padding(top = 4.dp, end = 4.dp, bottom = 4.dp), + calendar = item, + showDetails = showDetails, + onBack = { onShowDetails(false) } + ) + } + + is Location -> { + LocationItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { onShowDetails(true) }, + onLongClick = { onShowDetails(true) }), + location = item, + showDetails = showDetails, + onBack = { onShowDetails(false) } + ) + } + + is AppShortcut -> { + AppShortcutItem( + shortcut = item, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + onShowDetails(true) + } + }, + onLongClick = { onShowDetails(true) } + ), + showDetails = showDetails, + onBack = { onShowDetails(false) } + ) + } + + is Article -> { + ArticleItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + onShowDetails(true) + } + }, + onLongClick = { onShowDetails(true) }), + article = item, + ) + } + + is Website -> { + WebsiteItem( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + enabled = !showDetails, + onClick = { + if (!viewModel.launch(context, bounds)) { + onShowDetails(true) + } + }, + onLongClick = { onShowDetails(true) }), + website = item, + ) + } } - 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 Location -> { - LocationItem( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - enabled = !showDetails, - onClick = { showDetails = true }, - onLongClick = { showDetails = true }), - location = 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 } - ) - } } } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt new file mode 100644 index 00000000..0cedbdea --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt @@ -0,0 +1,159 @@ +package de.mm20.launcher2.ui.launcher.search.common.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +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.shadow +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.ui.ktx.animateCorners +import de.mm20.launcher2.ui.layout.BottomReversed +import de.mm20.launcher2.ui.locals.LocalCardStyle + +fun LazyListScope.ListResults( + key: String, + items: List, + itemContent: @Composable LazyItemScope.(T, Boolean, Int) -> Unit, + before: @Composable (LazyItemScope.() -> Unit)? = null, + after: @Composable (LazyItemScope.() -> Unit)? = null, + reverse: Boolean = false, + selectedIndex: Int = -1, +) { + if (before != null) { + item( + key = "$key-before", + ) { + ListItemSurface( + isFirst = true, + isLast = after == null && items.isEmpty(), + reverse = reverse, + isBeforeExpanded = selectedIndex == 0, + ) { + before() + } + } + } + val rows = items.size + items( + items.size, + key = { + "$key-${items[it].key}" + }, + ) { + val item = items[it] + val showDetails = it == selectedIndex + + ListItemSurface( + isFirst = it == 0 && before == null, + isLast = it == rows - 1 && after == null, + reverse = reverse, + isExpanded = showDetails, + isBeforeExpanded = selectedIndex - 1 == it, + isAfterExpanded = selectedIndex + 1 == it, + ) { + itemContent(item, showDetails, it) + } + } + if (after != null) { + item( + key = "$key-after", + ) { + ListItemSurface( + isFirst = before == null && items.isEmpty(), + isLast = true, + reverse = reverse, + isAfterExpanded = selectedIndex == items.lastIndex, + ) { + after() + } + } + } +} + +@Composable +fun LazyItemScope.ListItemSurface( + isFirst: Boolean = false, + isLast: Boolean = false, + reverse: Boolean = false, + isExpanded: Boolean = false, + isBeforeExpanded: Boolean = false, + isAfterExpanded: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + val transition = updateTransition(isExpanded) + val elevation by transition.animateDp { + if (it) 2.dp else 0.dp + } + val backgroundAlpha by transition.animateFloat { + if (it) 1f else LocalCardStyle.current.opacity + } + + val padding by transition.animateDp { + if (it) 8.dp else 0.dp + } + + val modifier = if (reverse) { + Modifier + .animateItem() + .padding( + bottom = if (!isFirst) padding else 0.dp, + top = if (!isLast) padding else 8.dp + ) + .shadow( + elevation = elevation, + MaterialTheme.shapes.medium.animateCorners( + bottomStart = isFirst || isExpanded || isAfterExpanded, + bottomEnd = isFirst || isExpanded || isAfterExpanded, + topEnd = isLast || isExpanded || isBeforeExpanded, + topStart = isLast || isExpanded || isBeforeExpanded, + ), + true, + ) + } else { + Modifier + .padding( + top = if (!isFirst) padding else 0.dp, + bottom = if (!isLast) padding else 8.dp + ) + .shadow( + elevation = elevation, + MaterialTheme.shapes.medium.animateCorners( + topStart = isFirst || isExpanded || isAfterExpanded, + topEnd = isFirst || isExpanded || isAfterExpanded, + bottomEnd = isLast || isExpanded || isBeforeExpanded, + bottomStart = isLast || isExpanded || isBeforeExpanded, + ), + true, + ) + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(backgroundAlpha)), + verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top + ) { + AnimatedVisibility(!isFirst && !isExpanded && !isAfterExpanded) { + HorizontalDivider() + } + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + content() + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt index 4ee9f742..91310adb 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt @@ -1,12 +1,21 @@ package de.mm20.launcher2.ui.launcher.search.common.list +import androidx.compose.foundation.border 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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.layout.BottomReversed @@ -18,18 +27,25 @@ fun SearchResultList( reverse: Boolean = false, highlightedItem: SavableSearchable? = null ) { + var selectedIndex by remember { mutableIntStateOf(-1) } Column( - modifier = modifier, + modifier = modifier + .clip(MaterialTheme.shapes.small) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.small), verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top ) { - for (item in items) { + for ((i, item) in items.withIndex()) { key(item.key) { + if (i != 0) { + HorizontalDivider() + } ListItem( modifier = Modifier - .fillMaxWidth() - .padding(4.dp), + .fillMaxWidth(), item = item, - highlight = item.key == highlightedItem?.key + highlight = item.key == highlightedItem?.key, + showDetails = selectedIndex == i, + onShowDetails = { selectedIndex = if (it) i else -1} ) } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt index 29e99d10..158cb60d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt @@ -104,9 +104,7 @@ fun ContactItem( verticalAlignment = Alignment.CenterVertically ) { val icon by viewModel.icon.collectAsStateWithLifecycle() - val padding by transition.animateDp(label = "iconPadding") { - if (it) 16.dp else 8.dp - } + val padding = 16.dp ShapedLauncherIcon( size = 48.dp, modifier = Modifier diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactResults.kt new file mode 100644 index 00000000..3775d3b8 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactResults.kt @@ -0,0 +1,59 @@ +package de.mm20.launcher2.ui.launcher.search.contacts + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.search.Contact +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.ContactResults( + contacts: List, + missingPermission: Boolean, + onPermissionRequest: () -> Unit, + onPermissionRequestRejected: () -> Unit, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: Contact?, + reverse: Boolean, +) { + ListResults( + items = contacts, + key = "contact", + reverse = reverse, + selectedIndex = selectedIndex, + itemContent = { contact, showDetails, index -> + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = contact, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = highlightedItem?.key == contact.key + ) + }, + before = if (missingPermission) { + { + MissingPermissionBanner( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.missing_permission_contact_search), + onClick = onPermissionRequest, + secondaryAction = { + OutlinedButton(onClick = onPermissionRequestRejected) { + Text( + stringResource(R.string.turn_off), + ) + } + } + ) + } + } else null + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt new file mode 100644 index 00000000..5262c3a4 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt @@ -0,0 +1,77 @@ +package de.mm20.launcher2.ui.launcher.search.favorites + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Tag +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.search.SavableSearchable +import de.mm20.launcher2.search.data.Tag +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.common.FavoritesTagSelector +import de.mm20.launcher2.ui.component.Banner +import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid +import de.mm20.launcher2.ui.launcher.widgets.favorites.FavoritesWidgetVM + +fun LazyListScope.SearchFavorites( + favorites: List, + pinnedTags: List, + selectedTag: String?, + tagsExpanded: Boolean, + onExpandTags: (Boolean) -> Unit, + onSelectTag: (String?) -> Unit, + editButton: Boolean, + reverse: Boolean, +) { + item( + key = "favorites", + ) { + Column( + modifier = Modifier + .padding( + top = if (reverse) 8.dp else 0.dp, + bottom = if (reverse) 0.dp else 8.dp, + ) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) + ) { + if (favorites.isNotEmpty()) { + SearchResultGrid(favorites) + } else { + 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, + ) + } + if (pinnedTags.isNotEmpty() || editButton) { + FavoritesTagSelector( + tags = pinnedTags, + selectedTag = selectedTag, + editButton = editButton, + reverse = false, + onSelectTag = onSelectTag, + scrollState = rememberScrollState(), + expanded = tagsExpanded, + onExpand = onExpandTags, + ) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileResults.kt new file mode 100644 index 00000000..7e787a4d --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileResults.kt @@ -0,0 +1,59 @@ +package de.mm20.launcher2.ui.launcher.search.files + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.search.File +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.FileResults( + files: List, + missingPermission: Boolean, + onPermissionRequest: () -> Unit, + onPermissionRequestRejected: () -> Unit, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: File? = null, + reverse: Boolean, +) { + ListResults( + items = files, + key = "file", + reverse = reverse, + selectedIndex = selectedIndex, + itemContent = { file, showDetails, index -> + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = file, + showDetails = showDetails, + onShowDetails = { onSelect(if (it) index else -1) }, + highlight = highlightedItem?.key == file.key + ) + }, + before = if (missingPermission) { + { + MissingPermissionBanner( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.missing_permission_files_search), + onClick = onPermissionRequest, + secondaryAction = { + OutlinedButton(onClick = onPermissionRequestRejected) { + Text( + stringResource(R.string.turn_off), + ) + } + } + ) + } + } else null + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt index 76f22496..7b9d3c14 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt @@ -156,7 +156,7 @@ fun LocationItem( enter = slideIn { IntOffset(-it.width, 0) } + fadeIn(), exit = slideOut { IntOffset(-it.width, 0) } + fadeOut(), ) - .padding(8.dp), + .padding(12.dp), size = 48.dp, icon = { icon }, badge = { badge }, @@ -201,7 +201,7 @@ fun LocationItem( } Compass( targetHeading = targetHeading, - modifier = Modifier.padding(end = 8.dp) then + modifier = Modifier.padding(end = 12.dp) then if (!showMap) { Modifier.sharedBounds( rememberSharedContentState("compass"), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationResults.kt new file mode 100644 index 00000000..3f2cc678 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationResults.kt @@ -0,0 +1,59 @@ +package de.mm20.launcher2.ui.launcher.search.location + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.search.Location +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.LocationResults( + locations: List, + missingPermission: Boolean, + onPermissionRequest: () -> Unit, + onPermissionRequestRejected: () -> Unit, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: Location?, + reverse: Boolean, +) { + ListResults( + items = locations, + key = "location", + reverse = reverse, + selectedIndex = selectedIndex, + itemContent = { location, showDetails, index -> + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = location, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = highlightedItem?.key == location.key + ) + }, + before = if (missingPermission) { + { + MissingPermissionBanner( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.missing_permission_location_search), + onClick = onPermissionRequest, + secondaryAction = { + OutlinedButton(onClick = onPermissionRequestRejected) { + Text( + stringResource(R.string.turn_off), + ) + } + } + ) + } + } else null + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutResults.kt new file mode 100644 index 00000000..cd11be9b --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutResults.kt @@ -0,0 +1,59 @@ +package de.mm20.launcher2.ui.launcher.search.shortcut + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.search.AppShortcut +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.ShortcutResults( + shortcuts: List, + missingPermission: Boolean, + onPermissionRequest: () -> Unit, + onPermissionRequestRejected: () -> Unit, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: AppShortcut?, + reverse: Boolean, +) { + ListResults( + items = shortcuts, + key = "shortcut", + reverse = reverse, + selectedIndex = selectedIndex, + itemContent = { shortcut, showDetails, index -> + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = shortcut, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = highlightedItem?.key == shortcut.key + ) + }, + before = if (missingPermission) { + { + MissingPermissionBanner( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.missing_permission_appshortcuts_search), + onClick = onPermissionRequest, + secondaryAction = { + OutlinedButton(onClick = onPermissionRequestRejected) { + Text( + stringResource(R.string.turn_off), + ) + } + } + ) + } + } else null + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterItem.kt index 068e2d0b..3adfd950 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterItem.kt @@ -140,21 +140,3 @@ fun UnitConverterItem( } } } - -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 - } -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterResults.kt new file mode 100644 index 00000000..ac9fa072 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterResults.kt @@ -0,0 +1,252 @@ +package de.mm20.launcher2.ui.launcher.search.unitconverter + +import android.icu.text.DateFormat +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.FitnessCenter +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.SquareFoot +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Straighten +import androidx.compose.material.icons.rounded.Thermostat +import androidx.compose.material.icons.rounded.Toll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +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.ui.ktx.TextButtonWithTrailingIconContentPadding +import de.mm20.launcher2.ui.launcher.search.common.list.ListItemSurface +import de.mm20.launcher2.unitconverter.Dimension +import java.util.Date +import kotlin.math.min + +fun LazyListScope.UnitConverterResults( + converters: List, + truncate: Boolean, + onShowAll: () -> Unit, + reverse: Boolean, +) { + if (converters.isNotEmpty()) { + val converter = converters.first() + item( + key = "converter-header", + ) { + ListItemSurface( + isFirst = true, + reverse = reverse, + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + Text( + text = converter.inputValue.let { "${it.formattedValue} ${it.formattedName}" }, + style = MaterialTheme.typography.titleLarge, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + if (converter is CurrencyUnitConverter) { + var showDisclaimer by remember { mutableStateOf(false) } + val df = DateFormat.getDateInstance(DateFormat.SHORT) + Row( + modifier = Modifier + .padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${df.format(Date(converter.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(converter.updateTimestamp)) + ) + ) + } + ) + } + } + } + Icon( + imageVector = getDimensionIcon(converter.dimension), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding( + top = 20.dp, + bottom = 20.dp, + start = 16.dp, + end = 18.dp + ) + .size(24.dp) + ) + } + } + } + val count = if (truncate) min(5, converter.values.size) else converter.values.size + items( + count, + key = { "converter-${converter.values[it].symbol}" } + ) { + val value = converter.values[it] + ListItemSurface( + isLast = it == converter.values.lastIndex, + reverse = reverse, + ) { + Column { + val clipboardManager = LocalClipboardManager.current + Row( + modifier = Modifier + .clickable { + clipboardManager.setText(buildAnnotatedString { + append(value.value.toString()) + append(" ") + append(value.symbol) + }) + } + .padding( + start = 16.dp, + end = 12.dp, + top = 12.dp, + bottom = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.widthIn(min = 48.dp), + text = value.formattedValue, + style = MaterialTheme.typography.labelLarge + ) + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + text = value.formattedName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.shapes.extraSmall, + ) + .height(36.dp) + .widthIn(min = 36.dp) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + textAlign = TextAlign.Center, + text = value.symbol, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + ) + } + } + } + } + } + if (truncate && converter.values.size > 5) { + item( + key = "converter-footer" + ) { + ListItemSurface( + isLast = true, + reverse = reverse, + ) { + TextButton( + modifier = Modifier + .align(Alignment.End) + .padding(4.dp), + onClick = onShowAll, + contentPadding = ButtonDefaults.TextButtonWithTrailingIconContentPadding, + ) { + Text(stringResource(R.string.unit_converter_show_all)) + Icon( + Icons.AutoMirrored.Rounded.ArrowForward, + null, + modifier = Modifier + .padding(start = ButtonDefaults.IconSpacing) + .size(ButtonDefaults.IconSize) + ) + } + } + } + } + } +} + +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 + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteResults.kt new file mode 100644 index 00000000..082566c8 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteResults.kt @@ -0,0 +1,29 @@ +package de.mm20.launcher2.ui.launcher.search.website + +import androidx.compose.foundation.lazy.LazyListScope +import de.mm20.launcher2.search.Website +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.WebsiteResults( + websites: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: Website?, + reverse: Boolean, +) { + ListResults( + key = "website", + items = websites, + itemContent = { website, showDetails, index -> + ListItem( + item = website, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = website.key == highlightedItem?.key, + ) + }, + selectedIndex = selectedIndex, + reverse = reverse, + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleItem.kt index 4e7b1cca..6c589233 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleItem.kt @@ -61,9 +61,7 @@ fun ArticleItem( } Column( - modifier = modifier.clickable { - viewModel.launch(context) - } + modifier = modifier ) { if (!article.imageUrl.isNullOrEmpty()) { AsyncImage( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleResults.kt new file mode 100644 index 00000000..6bd6f7d7 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/ArticleResults.kt @@ -0,0 +1,29 @@ +package de.mm20.launcher2.ui.launcher.search.wikipedia + +import androidx.compose.foundation.lazy.LazyListScope +import de.mm20.launcher2.search.Article +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListResults + +fun LazyListScope.ArticleResults( + articles: List
, + selectedIndex: Int, + onSelect: (Int) -> Unit, + highlightedItem: Article?, + reverse: Boolean, +) { + ListResults( + key = "article", + items = articles, + itemContent = { article, showDetails, index -> + ListItem( + item = article, + showDetails = showDetails, + onShowDetails = { onSelect(if(it) index else -1) }, + highlight = article.key == highlightedItem?.key, + ) + }, + selectedIndex = selectedIndex, + reverse = reverse, + ) +} \ No newline at end of file