diff --git a/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt b/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..87fb571 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/screens/HomeScreen.kt @@ -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)) } + } + } +}