build: S02 locations and S03 search
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user