build: S01 home and S14 permission prompt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user