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