build: S01 home and S14 permission prompt

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 912d6f2ac1
commit b359249c59
@@ -0,0 +1,311 @@
package app.wetter.ui.screens
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FormatListBulleted
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.wetter.R
import app.wetter.data.Forecast
import app.wetter.data.Graph
import app.wetter.data.LocationEntity
import app.wetter.data.RefreshResult
import app.wetter.data.Settings
import app.wetter.data.Units
import app.wetter.data.WeatherRepository
import app.wetter.data.conditionOf
import app.wetter.data.intensityOf
import app.wetter.ui.components.BriefCard
import app.wetter.ui.components.DailyCapsules
import app.wetter.ui.components.DetailGrid
import app.wetter.ui.components.HeroBlock
import app.wetter.ui.components.HourlyStrip
import app.wetter.ui.components.NowcastCard
import app.wetter.ui.components.StatusChip
import app.wetter.ui.components.buildBrief
import app.wetter.ui.components.minutesAgo
import app.wetter.ui.scene.SceneSpec
import app.wetter.ui.scene.WeatherScene
import app.wetter.ui.theme.LocalWeatherColors
import app.wetter.ui.theme.WColor
import app.wetter.ui.theme.WSpace
import app.wetter.ui.theme.WType
import kotlinx.coroutines.launch
import java.time.LocalDate
/** S01: one page per saved location, swipe between them. */
@Composable
fun HomeScreen(
settings: Settings,
initialLocationId: Long?,
animate: Boolean,
onOpenLocations: () -> Unit,
onOpenSettings: () -> Unit,
onOpenDay: (Long, LocalDate) -> Unit,
) {
val repo = Graph.repo
val locations by repo.locations.collectAsState(initial = null)
val context = LocalContext.current
var permissionAsked by remember { mutableStateOf(Graph.device.hasPermission()) }
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
permissionAsked = true
scope.launch { repo.updateCurrentLocation()?.let { repo.refresh(it, force = true) } }
}
LaunchedEffect(Unit) { repo.refreshAll() }
val list = locations
when {
list == null -> Box(Modifier.fillMaxSize().background(WColor.sceneClearDayBottom))
list.isEmpty() -> PermissionPrompt(
onAllow = { launcher.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.POST_NOTIFICATIONS)) },
onSearch = onOpenLocations,
asked = permissionAsked && !Graph.device.hasPermission(),
)
else -> {
val start = list.indexOfFirst { it.id == initialLocationId }.coerceAtLeast(0)
val pager = rememberPagerState(initialPage = start) { list.size }
LaunchedEffect(initialLocationId, list.size) {
val i = list.indexOfFirst { it.id == initialLocationId }
if (i >= 0 && i != pager.currentPage) pager.scrollToPage(i)
}
HorizontalPager(pager, Modifier.fillMaxSize(), key = { list[it].id }) { page ->
LocationPage(
location = list[page],
settings = settings,
animate = animate && page == pager.currentPage,
pageCount = list.size,
page = page,
onOpenLocations = onOpenLocations,
onOpenSettings = onOpenSettings,
onOpenDay = { onOpenDay(list[page].id, it) },
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationPage(
location: LocationEntity,
settings: Settings,
animate: Boolean,
pageCount: Int,
page: Int,
onOpenLocations: () -> Unit,
onOpenSettings: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
) {
val repo = Graph.repo
val forecast by remember(location.id) { repo.forecast(location.id) }.collectAsState(initial = null)
var refreshing by remember { mutableStateOf(false) }
var failed by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val refresh: (Boolean) -> Unit = { force ->
scope.launch {
refreshing = force
failed = repo.refresh(location, force) is RefreshResult.Failed
refreshing = false
}
}
LaunchedEffect(location.id) { refresh(false) }
val f = forecast
val name = location.name.ifBlank { stringResource(R.string.current_location) }
ForecastPage(
title = name,
isCurrent = location.isCurrent,
forecast = f,
settings = settings,
animate = animate,
refreshing = refreshing,
failed = failed,
onRefresh = { refresh(true) },
navigation = {
IconButton(onClick = onOpenLocations) { Icon(Icons.Rounded.FormatListBulleted, stringResource(R.string.open_locations)) }
},
actions = {
IconButton(onClick = onOpenSettings) { Icon(Icons.Rounded.Settings, stringResource(R.string.settings)) }
},
pageIndicator = { if (pageCount > 1) PageDots(pageCount, page, it) },
onOpenDay = onOpenDay,
)
}
/** Shared by S01 and the unsaved preview S15. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForecastPage(
title: String,
isCurrent: Boolean,
forecast: Forecast?,
settings: Settings,
animate: Boolean,
refreshing: Boolean,
failed: Boolean,
onRefresh: () -> Unit,
navigation: @Composable () -> Unit,
actions: @Composable () -> Unit,
pageIndicator: @Composable (Color) -> Unit = {},
onOpenDay: ((LocalDate) -> Unit)? = null,
) {
val units = remember(settings) { Units(settings) }
val res = LocalContext.current.resources
val spec = forecast?.let { SceneSpec(conditionOf(it.current.code), it.current.isDay, intensityOf(it.current.code)) }
?: SceneSpec(app.wetter.data.Condition.CLOUDY, true)
val listState = rememberLazyListState()
val scroll by remember { derivedStateOf { if (listState.firstVisibleItemIndex == 0) listState.firstVisibleItemScrollOffset.toFloat() else 2000f } }
val fg = spec.onScene
Box(Modifier.fillMaxSize()) {
WeatherScene(spec, animate = animate && settings.animations, scroll = scroll)
PullToRefreshBox(isRefreshing = refreshing, onRefresh = onRefresh, modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, bottom = WSpace.s32),
verticalArrangement = Arrangement.spacedBy(WSpace.s12),
horizontalAlignment = Alignment.CenterHorizontally,
) {
item(key = "bar") {
Row(Modifier.fillMaxWidth().statusBarsPadding().height(64.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides fg) {
navigation()
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
if (isCurrent) Icon(Icons.Rounded.LocationOn, null, Modifier.size(18.dp), tint = fg)
Text(title, style = WType.headline, color = fg, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
}
actions()
}
}
}
if (forecast == null) {
item(key = "loading") {
Column(Modifier.padding(top = 120.dp), horizontalAlignment = Alignment.CenterHorizontally) {
if (failed) {
Text(stringResource(R.string.loading_error), color = fg, style = WType.body)
TextButton(onClick = onRefresh) { Text(stringResource(R.string.retry), color = fg) }
} else CircularProgressIndicator(color = fg)
}
}
return@LazyColumn
}
item(key = "hero") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
HeroBlock(title, forecast, units, fg, Modifier.widthIn(max = 560.dp))
pageIndicator(fg)
val age = minutesAgo(forecast.fetchedAt)
if (failed || age >= 60) {
StatusChip(
when {
failed -> stringResource(R.string.offline)
age >= 120 -> stringResource(R.string.updated_hours, age / 60)
else -> stringResource(R.string.updated_ago, age)
},
fg,
)
}
Spacer(Modifier.height(WSpace.s16))
}
}
val max = Modifier.widthIn(max = 720.dp)
if (settings.brief) item(key = "brief") { BriefCard(remember(forecast, settings) { buildBrief(res, forecast, units) }, max) }
item(key = "nowcast") { NowcastCard(forecast.minutely, forecast, max) }
item(key = "hourly") { HourlyStrip(forecast.upcomingHours(25), units, max) }
item(key = "daily") { DailyCapsules(forecast.daily, units, max) { d -> onOpenDay?.invoke(d.date) } }
item(key = "details") {
DetailGrid(forecast.current, forecast.today, forecast.air, units, forecast.now, max.navigationBarsPadding())
}
item(key = "attribution") {
Text(stringResource(R.string.attribution), style = WType.labelSm, color = fg.copy(alpha = .8f), textAlign = TextAlign.Center, modifier = Modifier.padding(top = WSpace.s16))
}
}
}
}
}
@Composable
private fun PageDots(count: Int, current: Int, color: Color) {
Row(Modifier.padding(top = WSpace.s4), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
repeat(count) { i ->
Box(
Modifier.height(8.dp).width(if (i == current) 20.dp else 8.dp).clip(RoundedCornerShape(4.dp))
.background(color.copy(alpha = if (i == current) .9f else .35f))
)
}
}
}
@Composable
private fun PermissionPrompt(onAllow: () -> Unit, onSearch: () -> Unit, asked: Boolean) {
val spec = SceneSpec(app.wetter.data.Condition.PARTLY_CLOUDY, true)
Box(Modifier.fillMaxSize()) {
WeatherScene(spec)
Column(
Modifier.align(Alignment.Center).padding(WSpace.s32).clip(RoundedCornerShape(28.dp)).background(LocalWeatherColors.current.card).padding(WSpace.s24),
horizontalAlignment = Alignment.CenterHorizontally,
) {
app.wetter.ui.scene.WeatherIcon(app.wetter.data.Condition.PARTLY_CLOUDY, true, 72.dp)
Spacer(Modifier.height(WSpace.s16))
Text(stringResource(R.string.perm_title), style = WType.headline, color = LocalWeatherColors.current.onCard, textAlign = TextAlign.Center)
Spacer(Modifier.height(WSpace.s8))
Text(stringResource(R.string.perm_body), style = WType.bodySm, color = LocalWeatherColors.current.onCardMuted, textAlign = TextAlign.Center)
Spacer(Modifier.height(WSpace.s24))
if (!asked) Button(onClick = onAllow) { Text(stringResource(R.string.perm_allow)) }
FilledTonalButton(onClick = onSearch) { Text(stringResource(R.string.perm_search)) }
}
}
}