From a9f2f6939876892c9b3c02671597085679402a9d Mon Sep 17 00:00:00 2001 From: Louis Date: Sat, 17 Oct 2026 13:52:42 +0200 Subject: [PATCH] build: S02 locations and S03 search Co-Authored-By: Claude Opus 5.5 --- .../app/wetter/ui/screens/LocationsScreen.kt | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 wetter/app/src/main/java/app/wetter/ui/screens/LocationsScreen.kt diff --git a/wetter/app/src/main/java/app/wetter/ui/screens/LocationsScreen.kt b/wetter/app/src/main/java/app/wetter/ui/screens/LocationsScreen.kt new file mode 100644 index 0000000..117ff7b --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/screens/LocationsScreen.kt @@ -0,0 +1,237 @@ +package app.wetter.ui.screens + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.wetter.R +import app.wetter.data.Graph +import app.wetter.data.LocationEntity +import app.wetter.data.Place +import app.wetter.data.Settings +import app.wetter.data.Units +import app.wetter.data.conditionOf +import app.wetter.ui.components.conditionLabel +import app.wetter.ui.scene.SceneSpec +import app.wetter.ui.scene.WeatherIcon +import app.wetter.ui.theme.WRadius +import app.wetter.ui.theme.WSpace +import app.wetter.ui.theme.WType +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private sealed interface SearchState { + data object Idle : SearchState + data object Loading : SearchState + data class Results(val places: List) : SearchState + data object Error : SearchState +} + +/** S02 saved locations + S03 search in one screen: typing swaps the list for results. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationsScreen(settings: Settings, onBack: () -> Unit, onOpen: (Long) -> Unit, onPreview: (Place) -> Unit) { + val repo = Graph.repo + val locations by repo.locations.collectAsState(initial = emptyList()) + var query by rememberSaveable { mutableStateOf("") } + var state by remember { mutableStateOf(SearchState.Idle) } + var attempt by remember { mutableStateOf(0) } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val units = remember(settings) { Units(settings) } + val deletedText = stringResource(R.string.deleted, "%s") + val undoText = stringResource(R.string.undo) + + LaunchedEffect(query, attempt) { + if (query.trim().length < 2) { state = SearchState.Idle; return@LaunchedEffect } + delay(300) + state = SearchState.Loading + state = runCatching { SearchState.Results(repo.search(query)) }.getOrElse { SearchState.Error } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.locations)) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { pad -> + Column(Modifier.padding(pad).fillMaxSize()) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = WSpace.s16), + placeholder = { Text(stringResource(R.string.search_hint)) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, + trailingIcon = { + if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Rounded.Close, null) } + }, + singleLine = true, + shape = RoundedCornerShape(WRadius.pill), + ) + if (state == SearchState.Loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = WSpace.s32, vertical = WSpace.s4)) + else Spacer(Modifier.height(WSpace.s12)) + + when (val s = state) { + is SearchState.Results -> if (s.places.isEmpty()) { + Text(stringResource(R.string.search_empty), Modifier.padding(WSpace.s24), style = WType.body) + } else LazyColumn(contentPadding = PaddingValues(bottom = WSpace.s24)) { + items(s.places, key = { "${it.lat},${it.lon}" }) { p -> + Column( + Modifier.fillMaxWidth().clickable { onPreview(p) }.padding(horizontal = WSpace.s24, vertical = WSpace.s12) + ) { + Text(p.name, style = WType.body) + Text(listOfNotNull(p.admin, p.countryCode).joinToString(", "), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + SearchState.Error -> Column(Modifier.padding(WSpace.s24)) { + Text(stringResource(R.string.search_error), style = WType.body) + TextButton(onClick = { attempt++ }) { Text(stringResource(R.string.retry)) } + } + else -> LazyColumn( + contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, bottom = WSpace.s24), + verticalArrangement = Arrangement.spacedBy(WSpace.s8), + ) { + items(locations, key = { it.id }) { loc -> + val name = loc.name.ifBlank { stringResource(R.string.current_location) } + val delete = { + scope.launch { + repo.delete(loc.id) + val r = snackbar.showSnackbar(deletedText.format(name), undoText, duration = SnackbarDuration.Short) + if (r == SnackbarResult.ActionPerformed) repo.save(Place(loc.name, loc.admin, loc.countryCode, loc.lat, loc.lon, loc.timezone)) + } + Unit + } + if (loc.isCurrent) CityRow(loc, name, units) { onOpen(loc.id) } + else SwipeRow(onDelete = delete) { + val deleteLabel = stringResource(R.string.delete) + CityRow(loc, name, units, Modifier.semantics { + customActions = listOf(CustomAccessibilityAction(deleteLabel) { delete(); true }) + }) { onOpen(loc.id) } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeRow(onDelete: () -> Unit, content: @Composable () -> Unit) { + val state = rememberSwipeToDismissBoxState() + LaunchedEffect(state.currentValue) { + if (state.currentValue != SwipeToDismissBoxValue.Settled) { + onDelete() + state.snapTo(SwipeToDismissBoxValue.Settled) + } + } + SwipeToDismissBox( + state = state, + backgroundContent = { + val color by animateColorAsState( + if (state.targetValue == SwipeToDismissBoxValue.Settled) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.error, + label = "swipe", + ) + Box( + Modifier.fillMaxSize().clip(RoundedCornerShape(WRadius.lg)).background(color).padding(horizontal = WSpace.s24), + contentAlignment = Alignment.CenterEnd, + ) { Icon(Icons.Rounded.Delete, null, tint = MaterialTheme.colorScheme.onError) } + }, + ) { content() } +} + +@Composable +private fun CityRow(loc: LocationEntity, name: String, units: Units, modifier: Modifier = Modifier, onClick: () -> Unit) { + val forecast by remember(loc.id) { Graph.repo.forecast(loc.id) }.collectAsState(initial = null) + val f = forecast + val spec = f?.let { SceneSpec(conditionOf(it.current.code), it.current.isDay) } ?: SceneSpec(app.wetter.data.Condition.CLOUDY, true) + val fg = spec.onScene + Row( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(WRadius.lg)) + .background(Brush.horizontalGradient(listOf(spec.top, spec.bottom))) + .clickable(onClick = onClick) + .padding(horizontal = WSpace.s20, vertical = WSpace.s16), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (loc.isCurrent) { Icon(Icons.Rounded.LocationOn, null, Modifier.size(16.dp), tint = fg); Spacer(Modifier.width(4.dp)) } + Text(name, style = WType.title, color = fg, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (f != null) { + Text( + stringResource(conditionLabel(conditionOf(f.current.code), f.current.isDay)) + + (f.today?.let { " ยท ${units.temp(it.tMax)} / ${units.temp(it.tMin)}" } ?: ""), + style = WType.bodySm, color = fg, + ) + } + } + if (f != null) { + WeatherIcon(conditionOf(f.current.code), f.current.isDay, 36.dp) + Spacer(Modifier.width(WSpace.s8)) + Text(units.temp(f.current.temp), style = WType.metric, color = fg) + } + } +}