From 67a5fd25de1faa7d19b8aee03f970782c3becf22 Mon Sep 17 00:00:00 2001 From: Christoph <47949835+shtrophic@users.noreply.github.com> Date: Tue, 1 Apr 2025 18:02:29 +0200 Subject: [PATCH] LocationItem: group departures by line (#1295) * group departures by line if more than one line depart from given stop * code cleanup * preselect line with nextDeparture * add horizontal divider to improve legibility * change LineFilterChip styling * sort by lineType, then by lineName, animateScrollIntoView * Show departure in minutes on single tap also: - replace `remember(time)` with `key(time)` where applicable to keep "in ... minutes" up to date - only animate lineFilterChips into view once per card composition - show old list for up to two lines - disable departure list `onLongPress` - ditch `detectDragGestures` since it does not do anything anyway... somehow scrolling the departure list will also scroll the search bar. * set containerColor to secondary in lineFilterChip to improve legibility for white-ish primary colors * More color rigamarole * OCD code tweaks * Don't use extension functions * Split LocationItem into multiple subcomponents * Adjust line colors * Always use filter chips view for departures * Limit departures to 8 per line, make list not scrollable * Adjust spacing * Add transition * Improve line sorting * Fix line chip spacing * detectTabGestures -> combinedClickable --------- Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com> --- .../java/de/mm20/launcher2/ui/ktx/Color.kt | 11 + .../launcher/search/location/LocationItem.kt | 964 +++++++++++------- .../locations/LocationsSettingsScreenVM.kt | 1 - core/i18n/src/main/res/values-de/strings.xml | 1 + core/i18n/src/main/res/values/strings.xml | 1 + .../launcher2/search/location/Departure.kt | 50 +- .../search/location/OpeningSchedule.kt | 10 + 7 files changed, 668 insertions(+), 370 deletions(-) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt index 35ba8416..a43e540a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.ui.ktx import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import hct.Hct import kotlin.math.atan2 import kotlin.math.roundToInt @@ -20,6 +21,14 @@ fun Color.Companion.hct(hue: Float, chroma: Float, tone: Float): Color { return Color(hct.toInt()) } +fun Color.atTone(tone: Int): Color { + return Color( + Hct.fromInt(this.toArgb()).apply { + this.tone = tone.toDouble() + }.toInt() + ) +} + val Color.hue: Float get() { val r = this.red / 255f @@ -28,3 +37,5 @@ val Color.hue: Float // sqrt(3) return atan2(1.7320508f * (g - b), 2f * r - g - b) } + +fun android.graphics.Color.toComposeColor() = Color(this.toArgb()) \ 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 a6d78c6f..f7b07a00 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 @@ -1,5 +1,6 @@ package de.mm20.launcher2.ui.launcher.search.location +import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedContent @@ -14,29 +15,36 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkOut import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.NavigateNext @@ -60,13 +68,19 @@ import androidx.compose.material.icons.rounded.Tram import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard 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.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -77,23 +91,25 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.times import androidx.compose.ui.util.fastFilterNotNull -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import blend.Blend.harmonize import coil.compose.AsyncImage @@ -104,9 +120,11 @@ import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.isOpen import de.mm20.launcher2.search.location.Attribution import de.mm20.launcher2.search.location.Departure +import de.mm20.launcher2.search.location.LineNameComparator import de.mm20.launcher2.search.location.LineType import de.mm20.launcher2.search.location.OpeningHours import de.mm20.launcher2.search.location.OpeningSchedule +import de.mm20.launcher2.search.location.isNotEmpty import de.mm20.launcher2.ui.base.LocalTime import de.mm20.launcher2.ui.component.DefaultToolbarAction import de.mm20.launcher2.ui.component.MarqueeText @@ -114,24 +132,27 @@ import de.mm20.launcher2.ui.component.RatingBar 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.atTone import de.mm20.launcher2.ui.ktx.blendIntoViewScale import de.mm20.launcher2.ui.ktx.metersToLocalizedString +import de.mm20.launcher2.ui.ktx.toComposeColor import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.listItemViewModel 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.LocalGridSettings -import de.mm20.launcher2.ui.locals.LocalSnackbarHostState import de.mm20.launcher2.ui.modifier.scale +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.emptyFlow import java.time.Duration import java.time.LocalDateTime import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.time.format.TextStyle import java.util.Locale import kotlin.math.pow +import java.time.format.TextStyle as JavaTextStyle @Composable fun LocationItem( @@ -169,7 +190,6 @@ fun LocationItem( val imperialUnits by viewModel.imperialUnits.collectAsState() val showMap by viewModel.showMap.collectAsState() - val isUpToDate by viewModel.isUpToDate.collectAsState() val distance = userLocation?.distanceTo(location.toAndroidLocation()) @@ -196,7 +216,7 @@ fun LocationItem( modifier = Modifier .weight(1f) .padding(horizontal = 8.dp), - verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + verticalArrangement = Arrangement.Center, ) { Text( text = location.labelOverride ?: location.label, @@ -207,16 +227,17 @@ fun LocationItem( this@AnimatedContent ) ) - val category = location.category - val formattedDistance = distance?.metersToLocalizedString( - context, imperialUnits - ) - val isOpenString = location.openingSchedule?.isOpen()?.let { stringResource(if (it) R.string.location_open else R.string.location_closed) } - if (category != null || formattedDistance != null || isOpenString != null) { + val formattedDistance = + distance?.metersToLocalizedString(context, imperialUnits) + val isOpenString = location.openingSchedule?.isOpen() + ?.let { stringResource(if (it) R.string.location_open else R.string.location_closed) } + val sublabel = listOf(location.category, formattedDistance, isOpenString) + .fastFilterNotNull() + .joinToString(" • ") + + if (sublabel.isNotBlank()) { Text( - listOf(category, formattedDistance, isOpenString) - .fastFilterNotNull() - .joinToString(" • "), + sublabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier @@ -331,7 +352,7 @@ fun LocationItem( location.userRating!!, modifier = Modifier .padding(top = 6.dp) - .offset(-2.dp) + .offset((-2).dp) ) } @@ -388,7 +409,7 @@ fun LocationItem( context.tryStartActivity( Intent( Intent.ACTION_VIEW, - Uri.parse(attribution.url) + attribution.url!!.toUri() ) ) } @@ -399,256 +420,23 @@ fun LocationItem( val openingSchedule = location.openingSchedule val departures = remember(location.departures) { - location.departures - ?.sortedBy { it.time } + location.departures?.sortedBy { it.time } } if (departures != null) { - val time = LocalTime.current - val nextDeparture = remember(time) { - departures.firstOrNull { - it.time.plus(it.delay ?: Duration.ZERO).isAfter(ZonedDateTime.now()) - } - } - if (nextDeparture != null) { - var showDepartureList by remember(departures) { - mutableStateOf(false) - } - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, top = 12.dp), - shape = MaterialTheme.shapes.small, - onClick = { showDepartureList = !showDepartureList } - ) { - val listState = rememberLazyListState() - - AnimatedContent(showDepartureList) { showList -> - if (!showList) { - Row( - Modifier - .padding(12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - nextDeparture.LineIcon(Modifier.padding(end = 8.dp)) - val lastStop = nextDeparture.lastStop - if (lastStop != null) { - MarqueeText( - modifier = Modifier.weight(1f), - text = lastStop, - style = MaterialTheme.typography.labelMedium, - iterations = Int.MAX_VALUE, - repeatDelayMillis = 0, - velocity = 20.dp, - fadeLeft = 5.dp, - fadeRight = 5.dp, - ) - } - - val formattedTime = remember(time) { - val timeLeft = Duration.between( - java.time.LocalTime.now(), - nextDeparture.time + (nextDeparture.delay - ?: Duration.ZERO) - ).toMinutes().toInt() - if (timeLeft < 1) - context.getString(R.string.departure_time_now) - else - context.resources.getQuantityString(R.plurals.departure_time_in, timeLeft, timeLeft) - } - - Text( - text = formattedTime, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(end = 12.dp) - ) - Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) - } - } else { - val longestLine = remember(departures) { - departures.maxOfOrNull { it.line.length } - } - LazyColumn( - state = listState, - modifier = modifier - .heightIn(max = 192.dp) - .padding(12.dp) - .fillMaxWidth() - .pointerInput(Unit) { detectDragGestures { _, _ -> } }, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - itemsIndexed( - departures, - key = { idx, _ -> idx }) { idx, it -> - it.LazyColumnPart( - lineWidth = longestLine, - Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = - listState.layoutInfo.blendIntoViewScale( - idx - ) - } - ) - } - } - } - } - } - } + Departures( + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, top = 12.dp), + departures = departures, + ) } - if (openingSchedule is OpeningSchedule.TwentyFourSeven || (openingSchedule is OpeningSchedule.Hours && openingSchedule.openingHours.isNotEmpty())) { - var showOpeningSchedule by remember(openingSchedule) { - mutableStateOf(false) - } - OutlinedCard( + if (openingSchedule?.isNotEmpty() == true) { + OpeningSchedule( modifier = Modifier .fillMaxWidth() .padding(start = 12.dp, end = 12.dp, top = 12.dp), - shape = MaterialTheme.shapes.small, - onClick = { - if (openingSchedule !is OpeningSchedule.TwentyFourSeven) { - showOpeningSchedule = !showOpeningSchedule - } - } - ) { - AnimatedContent(showOpeningSchedule) { showSchedule -> - if (!showSchedule) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - when (openingSchedule) { - is OpeningSchedule.TwentyFourSeven -> { - Text( - text = stringResource(R.string.location_open_24_7), - style = MaterialTheme.typography.labelMedium, - ) - } - - is OpeningSchedule.Hours -> { - val text = remember(openingSchedule) { - val currentOpeningTime = - openingSchedule.getCurrentOpeningHours() - val timeFormat = - DateTimeFormatter.ofLocalizedTime( - FormatStyle.SHORT - ) - return@remember if (currentOpeningTime != null) { - val isSameDay = - currentOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek - val formattedTime = - timeFormat.format(currentOpeningTime.startTime + currentOpeningTime.duration) - val closingTime = if (isSameDay) { - context.getString( - R.string.location_closes, - formattedTime - ) - } else { - val dow = - currentOpeningTime.dayOfWeek.getDisplayName( - TextStyle.SHORT, - Locale.getDefault() - ) - context.getString( - R.string.location_closes_other_day, - dow, - formattedTime - ) - } - "${context.getString(R.string.location_open)} • $closingTime" - } else { - val nextOpeningTime = - openingSchedule.getNextOpeningHours() - val isSameDay = - nextOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek - val formattedTime = - timeFormat.format(nextOpeningTime.startTime) - val openingTime = if (isSameDay) { - context.getString( - R.string.location_opens, - formattedTime - ) - } else { - val dow = - nextOpeningTime.dayOfWeek.getDisplayName( - TextStyle.SHORT, - Locale.getDefault() - ) - context.getString( - R.string.location_opens_other_day, - dow, - formattedTime - ) - } - "${context.getString(R.string.location_closed)} • $openingTime" - } - } - - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.weight(1f) - ) - - Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) - } - } - } - } else if (openingSchedule is OpeningSchedule.Hours) { - Column( - modifier = Modifier.padding(vertical = 6.dp) - ) { - val groups = remember(openingSchedule) { - openingSchedule.openingHours - .groupBy { it.dayOfWeek }.entries - .sortedBy { it.key } - } - - for (group in groups) { - Row( - modifier = Modifier.padding( - vertical = 2.dp, - horizontal = 12.dp - ), - ) { - Text( - modifier = Modifier.weight(1f), - text = group.key.getDisplayName( - TextStyle.FULL, - Locale.getDefault() - ), - style = MaterialTheme.typography.bodySmall, - ) - val times = remember(group.value) { - val dateFormatter = - DateTimeFormatter.ofLocalizedTime( - FormatStyle.SHORT - ) - group.value.sortedBy { it.startTime } - .joinToString(separator = ", ") { - "${it.startTime.format(dateFormatter)}–${ - it.startTime.plus( - it.duration - ).format(dateFormatter) - }" - } - } - Text( - text = times, - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - } - - } + openingSchedule = openingSchedule, + ) } Row( @@ -658,7 +446,7 @@ fun LocationItem( ) { val navigationIntent = Intent( Intent.ACTION_VIEW, - Uri.parse("google.navigation:q=${location.latitude},${location.longitude}") + "google.navigation:q=${location.latitude},${location.longitude}".toUri() ) val canResolveNavigationIntent = remember { null != context.packageManager.resolveActivity(navigationIntent, 0) @@ -676,13 +464,14 @@ fun LocationItem( } ) } - location.phoneNumber?.let { + if (location.phoneNumber != null) { AssistChip( modifier = Modifier.padding(end = 12.dp), onClick = { context.tryStartActivity( Intent( - Intent.ACTION_DIAL, Uri.parse("tel:$it") + Intent.ACTION_DIAL, + "tel:${location.phoneNumber}".toUri() ) ) }, @@ -696,13 +485,13 @@ fun LocationItem( ) } - location.websiteUrl?.let { + if (location.websiteUrl != null) { AssistChip( modifier = Modifier.padding(end = 12.dp), onClick = { context.tryStartActivity( Intent( - Intent.ACTION_VIEW, Uri.parse(it) + Intent.ACTION_VIEW, location.websiteUrl!!.toUri() ) ) }, @@ -749,23 +538,22 @@ fun LocationItem( } val sheetManager = LocalBottomSheetManager.current - val lifecycleOwner = LocalLifecycleOwner.current - val snackbarHostState = LocalSnackbarHostState.current - toolbarActions.add(DefaultToolbarAction( - label = stringResource(R.string.menu_customize), - icon = Icons.Rounded.Tune, - action = { sheetManager.showCustomizeSearchableModal(location) } - )) + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_customize), + icon = Icons.Rounded.Tune, + action = { sheetManager.showCustomizeSearchableModal(location) } + )) - location.fixMeUrl?.let { + if (location.fixMeUrl != null) { toolbarActions += DefaultToolbarAction( label = stringResource(id = R.string.menu_bugreport), icon = Icons.Rounded.BugReport, ) { context.tryStartActivity( Intent( - Intent.ACTION_VIEW, Uri.parse(location.fixMeUrl) + Intent.ACTION_VIEW, location.fixMeUrl!!.toUri() ) ) } @@ -773,12 +561,13 @@ fun LocationItem( Toolbar( modifier = Modifier.fillMaxWidth(), - leftActions = listOf(DefaultToolbarAction( - label = stringResource(id = R.string.menu_back), - icon = Icons.AutoMirrored.Rounded.ArrowBack - ) { - onBack() - }), + leftActions = listOf( + DefaultToolbarAction( + label = stringResource(id = R.string.menu_back), + icon = Icons.AutoMirrored.Rounded.ArrowBack + ) { + onBack() + }), rightActions = toolbarActions, ) } @@ -788,7 +577,346 @@ fun LocationItem( } @Composable -fun Compass( +private fun Departures( + modifier: Modifier = Modifier, + departures: List, +) { + val context = LocalContext.current + + val nextDeparture = key(LocalTime.current) { + departures.firstOrNull { + it.time.plus(it.delay ?: Duration.ZERO).isAfter(ZonedDateTime.now()) + } + } + var animateFilterChipsOnce by remember { mutableStateOf(true) } + if (nextDeparture != null) { + var showDepartureList by remember { mutableStateOf(false) } + OutlinedCard( + modifier = modifier, + shape = MaterialTheme.shapes.small, + onClick = { showDepartureList = true } + ) { + val listState = rememberLazyListState() + + AnimatedContent(showDepartureList) { showList -> + if (!showList) { + Row( + Modifier + .padding(12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + LineIcon( + departure = nextDeparture, + Modifier.padding(end = 8.dp) + ) + val lastStop = nextDeparture.lastStop + if (lastStop != null) { + MarqueeText( + modifier = Modifier.weight(1f), + text = lastStop, + style = MaterialTheme.typography.labelMedium, + iterations = Int.MAX_VALUE, + repeatDelayMillis = 0, + velocity = 20.dp, + fadeLeft = 5.dp, + fadeRight = 5.dp, + ) + } + + Text( + text = key(LocalTime.current) { + departureInMinutes(context, nextDeparture) + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(end = 12.dp) + ) + Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) + } + } else { + val (lines, groupedDepartures) = remember(departures) { + val dict = departures.groupBy { it.line to it.type } + dict.keys.toList().sortedWith { (line1, type1), (line2, type2) -> + if (type1 != type2) compareValues(type1?.ordinal, type2?.ordinal) + else LineNameComparator.compare(line1, line2) + } to dict + } + + var showMinutes by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { showMinutes = !showMinutes }, + onLongClick = { showDepartureList = false }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(bottom = 8.dp) + ) { + + Column { + val filterChipListState = + rememberLazyListState() + var selectedLine by remember { + mutableStateOf( + nextDeparture.line to nextDeparture.type + ) + } + LaunchedEffect(Unit) { + val itemIdx = lines.indexOf(selectedLine) + if (itemIdx != -1) { + if (animateFilterChipsOnce) { + delay(500) + filterChipListState.animateScrollToItem( + itemIdx + ) + animateFilterChipsOnce = false + } else + filterChipListState.scrollToItem( + itemIdx + ) + } + } + LazyRow( + state = filterChipListState, + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + itemsIndexed( + lines, + key = { idx, _ -> idx } + ) { idx, it -> + val (lineName, _) = it + val firstDeparture = + groupedDepartures[it]?.first() + if (firstDeparture != null) { + LineFilterChip( + lineName = lineName, + lineColor = firstDeparture.lineColor?.toComposeColor(), + lineType = firstDeparture.type, + selected = selectedLine == it, + onClick = { + selectedLine = it + }, + modifier = Modifier + .padding( + top = 12.dp, + bottom = 12.dp, + start = 4.dp, + end = 4.dp, + ) + .graphicsLayer { + alpha = + filterChipListState.layoutInfo + .blendIntoViewScale( + idx, + 0.5f + ) + } + ) + } + } + } + AnimatedContent( + selectedLine, + transitionSpec = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + .togetherWith(fadeOut(animationSpec = tween(90))) + } + ) { line -> + + val selectedDepartures = groupedDepartures[line] + + if (selectedDepartures != null) { + Column( + modifier = Modifier.padding(horizontal = 12.dp) + ) { + for (i in 0.. 0) { + HorizontalDivider() + } + val dep = selectedDepartures[i] + DepartureRow( + departure = dep, + lineWidth = remember( + selectedDepartures + ) { + selectedDepartures.maxOfOrNull { it.line.length } + }, + withIcon = false, + minutesInsteadOfTime = showMinutes, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun OpeningSchedule( + modifier: Modifier = Modifier, + openingSchedule: OpeningSchedule, +) { + val context = LocalContext.current + + var showOpeningSchedule by remember(openingSchedule) { mutableStateOf(false) } + OutlinedCard( + modifier = modifier, + shape = MaterialTheme.shapes.small, + onClick = { + if (openingSchedule !is OpeningSchedule.TwentyFourSeven) { + showOpeningSchedule = !showOpeningSchedule + } + } + ) { + AnimatedContent(showOpeningSchedule) { showSchedule -> + if (!showSchedule) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (openingSchedule) { + is OpeningSchedule.TwentyFourSeven -> { + Text( + text = stringResource(R.string.location_open_24_7), + style = MaterialTheme.typography.labelMedium, + ) + } + + is OpeningSchedule.Hours -> { + val text = remember(openingSchedule) { + val currentOpeningTime = + openingSchedule.getCurrentOpeningHours() + val timeFormat = + DateTimeFormatter.ofLocalizedTime( + FormatStyle.SHORT + ) + return@remember if (currentOpeningTime != null) { + val isSameDay = + currentOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek + val formattedTime = + timeFormat.format(currentOpeningTime.startTime + currentOpeningTime.duration) + val closingTime = if (isSameDay) { + context.getString( + R.string.location_closes, + formattedTime + ) + } else { + val dow = + currentOpeningTime.dayOfWeek.getDisplayName( + JavaTextStyle.SHORT, + Locale.getDefault() + ) + context.getString( + R.string.location_closes_other_day, + dow, + formattedTime + ) + } + "${context.getString(R.string.location_open)} • $closingTime" + } else { + val nextOpeningTime = + openingSchedule.getNextOpeningHours() + val isSameDay = + nextOpeningTime.dayOfWeek == LocalDateTime.now().dayOfWeek + val formattedTime = + timeFormat.format(nextOpeningTime.startTime) + val openingTime = if (isSameDay) { + context.getString( + R.string.location_opens, + formattedTime + ) + } else { + val dow = + nextOpeningTime.dayOfWeek.getDisplayName( + JavaTextStyle.SHORT, + Locale.getDefault() + ) + context.getString( + R.string.location_opens_other_day, + dow, + formattedTime + ) + } + "${context.getString(R.string.location_closed)} • $openingTime" + } + } + + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.weight(1f) + ) + + Icon(Icons.AutoMirrored.Rounded.NavigateNext, null) + } + } + } + } else if (openingSchedule is OpeningSchedule.Hours) { + Column( + modifier = Modifier.padding(vertical = 6.dp) + ) { + val groups = remember(openingSchedule) { + openingSchedule.openingHours + .groupBy { it.dayOfWeek }.entries + .sortedBy { it.key } + } + + for (group in groups) { + Row( + modifier = Modifier.padding( + vertical = 2.dp, + horizontal = 12.dp + ), + ) { + Text( + modifier = Modifier.weight(1f), + text = group.key.getDisplayName( + JavaTextStyle.FULL, + Locale.getDefault() + ), + style = MaterialTheme.typography.bodySmall, + ) + val times = remember(group.value) { + val dateFormatter = + DateTimeFormatter.ofLocalizedTime( + FormatStyle.SHORT + ) + group.value.sortedBy { it.startTime } + .joinToString(separator = ", ") { + "${it.startTime.format(dateFormatter)}–${ + it.startTime.plus( + it.duration + ).format(dateFormatter) + }" + } + } + Text( + text = times, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + + } +} + +@Composable +private fun Compass( targetHeading: Float?, modifier: Modifier = Modifier, size: Dp = 48.dp @@ -890,74 +1018,131 @@ private fun OpeningSchedule.Hours.getNextOpeningHours(): OpeningHours { } @Composable -fun Departure.LineIcon( - modifier: Modifier +fun LineMarqueeText( + lineName: String, + lineForeground: Color, + style: TextStyle, + modifier: Modifier = Modifier +) = MarqueeText( + text = lineName, + style = style, + color = lineForeground, + textAlign = TextAlign.Center, + fadeLeft = 2.5.dp, + fadeRight = 2.5.dp, + iterations = Int.MAX_VALUE, + repeatDelayMillis = 0, + spacing = MarqueeSpacing(10.dp), + velocity = 20.dp, + modifier = modifier +) + +@Composable +fun LineTypeIcon( + lineType: LineType?, + tint: Color, + modifier: Modifier = Modifier +) = Icon( + imageVector = when (lineType) { + LineType.Bus -> Icons.Rounded.DirectionsBus + LineType.Tram -> Icons.Rounded.Tram + LineType.Subway -> Icons.Rounded.Subway + LineType.Monorail -> Icons.Rounded.DirectionsTransit + LineType.CommuterTrain -> Icons.Rounded.DirectionsRailway + LineType.Train, LineType.RegionalTrain, LineType.HighSpeedTrain -> Icons.Rounded.Train + LineType.Boat -> Icons.Rounded.DirectionsBoat + LineType.CableCar -> Icons.Rounded.CableCar + LineType.Airplane -> Icons.Rounded.AirplanemodeActive + null -> Icons.Rounded.Commute + }, + contentDescription = lineType?.name, // TODO localize (maybe) with ?.let{ stringResource("departure_line_type_$it") } + modifier = modifier, + tint = tint +) + +@Composable +fun LineFilterChip( + lineName: String, + lineColor: Color?, + lineType: LineType?, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, ) { - val harmonizeArgb = MaterialTheme.colorScheme.primary.toArgb() - var (lineBg, lineFg) = if (lineColor != null) { - val bg = Color( - harmonize(lineColor!!.toArgb(), harmonizeArgb) - ) - val fg = Color( - harmonize( - if (0.5f < bg.luminance()) Color.Black.toArgb() else Color.White.toArgb(), - harmonizeArgb - ) - ) - bg to fg - } else { - MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer - } + val scale = 0.875f - val hasDeparted = ZonedDateTime.now().isAfter(time + (delay ?: Duration.ZERO)) + val dark = LocalDarkTheme.current + val color = + if (lineColor == null) MaterialTheme.colorScheme.primary + else Color(harmonize(lineColor.toArgb(), MaterialTheme.colorScheme.primary.toArgb())) - if (hasDeparted) { - val hsv = FloatArray(3) - android.graphics.Color.colorToHSV(lineBg.toArgb(), hsv) - val (h, s, v) = hsv - lineBg = Color.hsv(h, s / 2f, v, lineBg.alpha) - } + InputChip( + selected = selected, + onClick = onClick, + label = { + Text(lineName, style = MaterialTheme.typography.labelMedium) + }, + avatar = { + Box( + modifier = Modifier + .background(color.atTone(if (dark) 80 else 40)) + .clip(CircleShape) + .requiredSize( + InputChipDefaults.AvatarSize * scale + ) + ) { + LineTypeIcon( + lineType = lineType, + tint = color.atTone(if (dark) 20 else 100), + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedLabelColor = color.atTone(if (dark) 90 else 30), + selectedContainerColor = color.atTone(if (dark) 30 else 90), + ), + modifier = modifier.height(FilterChipDefaults.Height * scale) + ) +} + +@Composable +fun LineIcon( + lineName: String, + lineType: LineType?, + lineColor: android.graphics.Color?, + hasDeparted: Boolean, + modifier: Modifier = Modifier +) { + val dark = LocalDarkTheme.current + val color = + if (lineColor == null) MaterialTheme.colorScheme.primary + else Color(harmonize(lineColor.toArgb(), MaterialTheme.colorScheme.primary.toArgb())) Row( modifier = modifier .wrapContentWidth(Alignment.Start) .background( - lineBg, + color.atTone(if (dark) 80 else 40), MaterialTheme.shapes.small ) .padding(top = 4.dp, bottom = 4.dp, start = 4.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = when (type) { - LineType.Bus -> Icons.Rounded.DirectionsBus - LineType.Tram -> Icons.Rounded.Tram - LineType.Subway -> Icons.Rounded.Subway - LineType.Monorail -> Icons.Rounded.DirectionsTransit - LineType.CommuterTrain -> Icons.Rounded.DirectionsRailway - LineType.Train, LineType.RegionalTrain, LineType.HighSpeedTrain -> Icons.Rounded.Train - LineType.Boat -> Icons.Rounded.DirectionsBoat - LineType.CableCar -> Icons.Rounded.CableCar - LineType.Airplane -> Icons.Rounded.AirplanemodeActive - null -> Icons.Rounded.Commute - }, - contentDescription = type?.name, // TODO localize (maybe) with ?.let{ stringResource("departure_line_type_$it") } - tint = lineFg, - modifier = Modifier + val foregroundColor = color.atTone(if (dark) 20 else 100) + LineTypeIcon( + lineType, + foregroundColor, + Modifier .padding(end = 2.dp) - .size(16.dp), + .size(16.dp) ) - MarqueeText( - text = line, + LineMarqueeText( + lineName, + foregroundColor, style = MaterialTheme.typography.labelSmall, - color = lineFg, - textAlign = TextAlign.Center, - fadeLeft = 2.5.dp, - fadeRight = 2.5.dp, - iterations = Int.MAX_VALUE, - repeatDelayMillis = 0, - spacing = MarqueeSpacing(10.dp), - velocity = 20.dp, modifier = Modifier .wrapContentSize() .widthIn(max = 34.dp) @@ -966,31 +1151,53 @@ fun Departure.LineIcon( } @Composable -fun Departure.LazyColumnPart( +fun LineIcon( + departure: Departure, + modifier: Modifier = Modifier, +) = LineIcon( + lineName = departure.line, + lineType = departure.type, + lineColor = departure.lineColor, + hasDeparted = ZonedDateTime.now().isAfter(departure.time + (departure.delay ?: Duration.ZERO)), + modifier = modifier +) + + +@Composable +fun DepartureRow( + departure: Departure, lineWidth: Int?, - modifier: Modifier + withIcon: Boolean, + minutesInsteadOfTime: Boolean, + modifier: Modifier = Modifier, ) { + val context = LocalContext.current Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(0.8f) + // HACK: the extents of this row should be properly calculated. + // but how? + modifier = Modifier.fillMaxWidth(0.66f) ) { - LineIcon( - Modifier - .padding(end = 8.dp) - .widthIn( - min = if (lineWidth == null) 0.dp - else max(64.dp, lineWidth * 8.dp) - ) - ) - if (lastStop != null) { + if (withIcon) { + LineIcon( + departure = departure, + Modifier + .padding(end = 8.dp) + .widthIn( + min = if (lineWidth == null) 0.dp + else max(64.dp, lineWidth * 8.dp) + ) + ) + } + if (departure.lastStop != null) { MarqueeText( - text = lastStop!!, + text = departure.lastStop!!, style = MaterialTheme.typography.labelMedium, iterations = Int.MAX_VALUE, repeatDelayMillis = 0, @@ -1005,23 +1212,29 @@ fun Departure.LazyColumnPart( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = time.format( - DateTimeFormatter.ofPattern( - "HH:mm", - Locale.getDefault() + text = if (minutesInsteadOfTime) { + key(LocalTime.current) { departureInMinutes(context, departure) } + } else { + departure.time.format( + DateTimeFormatter.ofPattern( + "HH:mm", + Locale.getDefault() + ) ) - ), + }, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(end = 2.dp) ) - val delayMinutes = delay?.toMinutes() - if (null != delayMinutes && 0L < delayMinutes) { - Text( - text = "+$delayMinutes", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - fontSize = TextUnit(2f, TextUnitType.Em), - ) + if (!minutesInsteadOfTime) { + val delayMinutes = departure.delay?.toMinutes() + if (null != delayMinutes && 0L < delayMinutes) { + Text( + text = "+$delayMinutes", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + fontSize = TextUnit(2f, TextUnitType.Em), + ) + } } } } @@ -1063,4 +1276,27 @@ private fun Attribution( ) } } +} + +private fun departureInMinutes(context: Context, departure: Departure): String { + val delayedDepartureTime = + departure.time + (departure.delay + ?: Duration.ZERO) + val now = ZonedDateTime.now() + + if (delayedDepartureTime < now) + return context.getString(R.string.departure_time_departed) + + val timeLeft = + Duration.between(now, delayedDepartureTime) + .toMinutes().toInt() + return if (timeLeft < 1) { + context.getString(R.string.departure_time_now) + } else { + context.resources.getQuantityString( + R.plurals.departure_time_in, + timeLeft, + timeLeft + ) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt index cc5cc8b6..2970b777 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt @@ -6,7 +6,6 @@ import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugins.PluginService import de.mm20.launcher2.preferences.search.LocationSearchSettings import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import org.koin.core.component.KoinComponent import org.koin.core.component.inject diff --git a/core/i18n/src/main/res/values-de/strings.xml b/core/i18n/src/main/res/values-de/strings.xml index 64f7bd6d..b587968c 100644 --- a/core/i18n/src/main/res/values-de/strings.xml +++ b/core/i18n/src/main/res/values-de/strings.xml @@ -820,4 +820,5 @@ Zum Anruf Tippen Beim Tippen auf eine Telefonnummer diese direkt Anrufen + abgefahren \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index ac357c5b..e57c7b4f 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -1024,4 +1024,5 @@ in one minute in %1$d minutes + departed \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt b/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt index 93b9ae14..d564076f 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/search/location/Departure.kt @@ -32,16 +32,56 @@ data class Departure( val lineColor: Color?, ) +/** + * Compares two line names. The line naems are split into parts of numbers or letters, then + * each segment is compared. + */ +object LineNameComparator : Comparator { + + // Split line into parts, e.g. "11A" -> ["11", "A"], "S1" -> ["S", "1"], "40-X" -> ["40", "X"] + private val regex = Regex("\\p{L}+|[0-9]+") + + override fun compare(a: String, b: String): Int { + if (a == b) return 0 + val aParts = regex.findAll(a).toList() + val bParts = regex.findAll(b).toList() + + for (i in 0 until minOf(aParts.size, bParts.size)) { + val aPart = aParts[i].value + val bPart = bParts[i].value + + if (aPart == bPart) continue + + val thisPartNumber = aPart.toIntOrNull() + val otherPartNumber = bPart.toIntOrNull() + + if (thisPartNumber != null && otherPartNumber != null) { + // both parts are numbers, compare them as numbers + return thisPartNumber.compareTo(otherPartNumber) + } + + // one part is a number, the other is a string. numbers are automatically less than strings + return aPart.compareTo(bPart) + } + + // 11 < 11A + return aParts.size.compareTo(bParts.size) + } + +} + + +// implicit ordering by ordinal enum class LineType { - Bus, - Tram, Subway, + Tram, + Bus, + Boat, + Monorail, + CableCar, CommuterTrain, RegionalTrain, Train, HighSpeedTrain, - Boat, - Monorail, - CableCar, Airplane, } \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt b/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt index 34f22835..fb897290 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/search/location/OpeningSchedule.kt @@ -27,4 +27,14 @@ sealed interface OpeningSchedule { data object TwentyFourSeven : OpeningSchedule @Serializable data class Hours(@Serializable val openingHours: List) : OpeningSchedule +} + +/** + * Checks whether the [OpeningSchedule] has at least one opening hour. + */ +fun OpeningSchedule.isNotEmpty(): Boolean { + return when (this) { + is OpeningSchedule.Hours -> openingHours.isNotEmpty() + OpeningSchedule.TwentyFourSeven -> true + } } \ No newline at end of file