build: S02 locations and S03 search

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 13:52:42 +02:00
co-authored by Claude Opus 5.5
parent b359249c59
commit a9f2f69398
@@ -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<Place>) : 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>(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)
}
}
}