Start adding support for multiple locations

This commit is contained in:
Henry Hiles 2023-05-02 11:50:35 -04:00
parent b8ab0605e8
commit b32701b138
35 changed files with 377 additions and 231 deletions

View file

@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
kotlin("plugin.serialization") version "1.8.10"
}
android {
@ -72,7 +73,7 @@ dependencies {
implementation("androidx.compose.material3:material3:1.1.0-rc01")
implementation("androidx.activity:activity-compose:1.7.1")
implementation("androidx.core:core-ktx:1.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.3")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
@ -110,7 +111,7 @@ dependencies {
// Retrofit
val retrofitVersion = "2.9.0"
implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
implementation("com.squareup.retrofit2:converter-moshi:$retrofitVersion")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Accompanist
val accompanistVersion = "0.30.0"

View file

@ -5,13 +5,15 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.henryhiles.qweather.domain.remote.GeocodingApi
import com.henryhiles.qweather.domain.remote.WeatherApi
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient.Builder
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.create
private fun isNetworkAvailable(context: Context): Boolean {
@ -29,6 +31,9 @@ private fun isNetworkAvailable(context: Context): Boolean {
}
}
private val contentType = "application/json".toMediaType()
private val json = Json { ignoreUnknownKeys = true }
val appModule = module {
fun provideWeatherApi(context: Context): WeatherApi {
val cacheControlInterceptor = Interceptor { chain ->
@ -56,7 +61,7 @@ val appModule = module {
return Retrofit.Builder()
.baseUrl("https://api.open-meteo.com")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(json.asConverterFactory(contentType))
.build()
.create()
}
@ -64,7 +69,7 @@ val appModule = module {
fun provideGeocodingApi(): GeocodingApi {
return Retrofit.Builder()
.baseUrl("https://geocoding-api.open-meteo.com")
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(json.asConverterFactory(contentType))
.build()
.create()
}

View file

@ -0,0 +1,10 @@
package com.henryhiles.qweather.domain.geocoding
import kotlinx.serialization.Serializable
@Serializable
data class GeocodingData(
val location: String,
val longitude: Float,
val latitude: Float
)

View file

@ -41,9 +41,7 @@ abstract class BasePreferenceManager(
getter: (key: String, defaultValue: T) -> T,
private val setter: (key: String, newValue: T) -> Unit
) {
@Suppress("RedundantSetter")
var value by mutableStateOf(getter(key, defaultValue))
private set
private var value by mutableStateOf(getter(key, defaultValue))
operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
@ -102,7 +100,6 @@ abstract class BasePreferenceManager(
setter = ::putColor
)
protected inline fun <reified E : Enum<E>> enumPreference(
key: String,
defaultValue: E

View file

@ -0,0 +1,14 @@
package com.henryhiles.qweather.domain.mappers
import com.henryhiles.qweather.domain.remote.GeocodingDto
import com.henryhiles.qweather.domain.geocoding.GeocodingData
fun GeocodingDto.toGeocodingData(): List<GeocodingData> {
return results.map {
GeocodingData(
location = "${it.city}, ${it.admin}, ${it.country}",
longitude = it.longitude,
latitude = it.latitude,
)
}
}

View file

@ -12,7 +12,7 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
fun HourlyWeatherDataDto.toHourlyWeatherDataMap(): List<HourlyWeatherData> {
fun HourlyWeatherDataDto.toHourlyWeatherData(): List<HourlyWeatherData> {
return time.subList(0, 24).mapIndexed { index, time ->
HourlyWeatherData(
time = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME),
@ -20,12 +20,12 @@ fun HourlyWeatherDataDto.toHourlyWeatherDataMap(): List<HourlyWeatherData> {
apparentTemperature = apparentTemperature[index].roundToInt(),
windSpeed = windSpeed[index].roundToInt(),
precipitationProbability = precipitationProbability.getOrNull(index),
weatherType = WeatherType.fromWMO(weatherCode[index])
weatherType = WeatherType.fromWMO(weatherCode[index]),
)
}
}
fun DailyWeatherDataDto.toDailyWeatherDataMap(): List<DailyWeatherData> {
fun DailyWeatherDataDto.toDailyWeatherData(): List<DailyWeatherData> {
return date.mapIndexed { index, date ->
DailyWeatherData(
date = LocalDate.parse(date, DateTimeFormatter.ISO_DATE),
@ -41,13 +41,16 @@ fun DailyWeatherDataDto.toDailyWeatherDataMap(): List<DailyWeatherData> {
}
fun WeatherDto.toHourlyWeatherInfo(): HourlyWeatherInfo {
val weatherDataMap = hourlyWeatherData.toHourlyWeatherDataMap()
val weatherDataMap = hourlyWeatherData.toHourlyWeatherData()
val now = LocalDateTime.now()
val currentWeatherData = weatherDataMap.find {
it.time.hour == now.hour
}
return HourlyWeatherInfo(
weatherData = weatherDataMap,
currentWeatherData = currentWeatherData
currentWeatherData = currentWeatherData,
highTemperature = weatherDataMap.maxBy { it.temperature }.temperature,
lowTemperature = weatherDataMap.minBy { it.temperature }.temperature,
precipitationProbability = weatherDataMap.maxBy { it.precipitationProbability ?: 0}.precipitationProbability
)
}

View file

@ -1,24 +1,26 @@
package com.henryhiles.qweather.domain.remote
import com.squareup.moshi.Json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DailyWeatherDataDto(
@field:Json(name = "time")
@SerialName("time")
val date: List<String>,
@field:Json(name = "weathercode")
@SerialName("weathercode")
val weatherCode: List<Int>,
@field:Json(name = "precipitation_probability_max")
val precipitationProbabilityMax: List<Int>,
@field:Json(name = "precipitation_sum")
@SerialName("precipitation_probability_max")
val precipitationProbabilityMax: List<Int?>,
@SerialName("precipitation_sum")
val precipitationSum: List<Float>,
@field:Json(name = "windspeed_10m_max")
@SerialName("windspeed_10m_max")
val windSpeedMax: List<Float>,
@field:Json(name = "temperature_2m_max")
@SerialName("temperature_2m_max")
val temperatureMax: List<Float>,
@field:Json(name = "temperature_2m_min")
@SerialName("temperature_2m_min")
val temperatureMin: List<Float>,
@field:Json(name = "apparent_temperature_max")
@SerialName("apparent_temperature_max")
val apparentTemperatureMax: List<Float>,
@field:Json(name = "apparent_temperature_min")
@SerialName("apparent_temperature_min")
val apparentTemperatureMin: List<Float>
)

View file

@ -4,8 +4,9 @@ import retrofit2.http.GET
import retrofit2.http.Query
interface GeocodingApi {
@GET("v1/search?count=10")
@GET("v1/search")
suspend fun getGeocodingData(
@Query("name") location: String,
@Query("count") count: Int = 10
): GeocodingDto
}

View file

@ -1,8 +1,8 @@
package com.henryhiles.qweather.domain.remote
import com.squareup.moshi.Json
import kotlinx.serialization.Serializable
@Serializable
data class GeocodingDto(
@field:Json(name = "results")
val results: List<GeocodingLocationDto>
val results: List<GeocodingLocationDto> = listOf()
)

View file

@ -1,16 +1,15 @@
package com.henryhiles.qweather.domain.remote
import com.squareup.moshi.Json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GeocodingLocationDto(
@field:Json(name = "name")
@SerialName("name")
val city: String,
@field:Json(name = "country")
val country: String,
@field:Json(name = "admin1")
@SerialName("admin1")
val admin: String,
@field:Json(name = "latitude")
val latitude: Float,
@field:Json(name = "longitude")
val longitude: Float
)

View file

@ -1,18 +1,19 @@
package com.henryhiles.qweather.domain.remote
import com.squareup.moshi.Json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HourlyWeatherDataDto(
@field:Json(name = "time")
val time: List<String>,
@field:Json(name = "temperature_2m")
@SerialName("temperature_2m")
val temperature: List<Float>,
@field:Json(name = "apparent_temperature")
@SerialName("apparent_temperature")
val apparentTemperature: List<Float>,
@field:Json(name = "weathercode")
@SerialName("weathercode")
val weatherCode: List<Int>,
@field:Json(name = "precipitation_probability")
val precipitationProbability: List<Int>,
@field:Json(name = "windspeed_10m")
@SerialName("precipitation_probability")
val precipitationProbability: List<Int?>,
@SerialName("windspeed_10m")
val windSpeed: List<Float>,
)

View file

@ -1,11 +1,13 @@
package com.henryhiles.qweather.domain.remote
import com.squareup.moshi.Json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WeatherDto(
@field:Json(name = "hourly")
@SerialName("hourly")
val hourlyWeatherData: HourlyWeatherDataDto,
@field:Json(name = "daily")
@SerialName("daily")
val dailyWeatherData: DailyWeatherDataDto
)

View file

@ -1,14 +1,15 @@
package com.henryhiles.qweather.domain.repository
import com.henryhiles.qweather.domain.mappers.toGeocodingData
import com.henryhiles.qweather.domain.remote.GeocodingApi
import com.henryhiles.qweather.domain.remote.GeocodingLocationDto
import com.henryhiles.qweather.domain.util.Resource
import com.henryhiles.qweather.domain.geocoding.GeocodingData
class GeocodingRepository(private val api: GeocodingApi) {
suspend fun getGeocodingData(location: String): Resource<List<GeocodingLocationDto>> {
suspend fun getGeocodingData(location: String): Resource<List<GeocodingData>> {
return try {
Resource.Success(
data = api.getGeocodingData(location = location).results
data = api.getGeocodingData(location = location).toGeocodingData()
)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -1,6 +1,6 @@
package com.henryhiles.qweather.domain.repository
import com.henryhiles.qweather.domain.mappers.toDailyWeatherDataMap
import com.henryhiles.qweather.domain.mappers.toDailyWeatherData
import com.henryhiles.qweather.domain.mappers.toHourlyWeatherInfo
import com.henryhiles.qweather.domain.remote.WeatherApi
import com.henryhiles.qweather.domain.util.Resource
@ -43,7 +43,7 @@ class WeatherRepository(private val api: WeatherApi) {
) else api.getWeatherDataWithoutCache(
lat = lat,
long = long
)).dailyWeatherData.toDailyWeatherDataMap()
)).dailyWeatherData.toDailyWeatherData()
)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -2,5 +2,8 @@ package com.henryhiles.qweather.domain.weather
data class HourlyWeatherInfo(
val weatherData: List<HourlyWeatherData>,
val currentWeatherData: HourlyWeatherData?
val currentWeatherData: HourlyWeatherData?,
val highTemperature: Int,
val lowTemperature: Int,
val precipitationProbability: Int?
)

View file

@ -7,7 +7,6 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.SlideTransition
@ -32,11 +31,10 @@ class QWeatherActivity : ComponentActivity() {
Theme.LIGHT -> false
Theme.DARK -> true
}
val isLocationSet = location.location != ""
val isLocationSet = location.getLocations().isNotEmpty()
WeatherAppTheme(darkTheme = isDark, monet = prefs.monet) {
Surface(modifier = Modifier.fillMaxSize()) {
Text(text = location.location)
Navigator(
screen = if (isLocationSet) MainScreen() else LocationPickerScreen(),
onBackPressed = {

View file

@ -10,7 +10,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@Composable
inline fun <reified E : Enum<E>> EnumRadioController(
@ -19,7 +18,6 @@ inline fun <reified E : Enum<E>> EnumRadioController(
crossinline onChoiceSelected: (E) -> Unit
) {
var choice by remember { mutableStateOf(default) }
val ctx = LocalContext.current
Column {
enumValues<E>().forEach {

View file

@ -0,0 +1,18 @@
package com.henryhiles.qweather.presentation.components
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Divider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun VerticalDivider(modifier: Modifier = Modifier) {
Divider(
modifier = modifier
.fillMaxHeight()
.width(1.dp)
)
}

View file

@ -0,0 +1,71 @@
package com.henryhiles.qweather.presentation.components.location
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import com.henryhiles.qweather.R
import com.henryhiles.qweather.presentation.screen.LocationPickerScreen
import com.henryhiles.qweather.presentation.screenmodel.LocationPreferenceManager
import org.koin.androidx.compose.get
@Composable
fun LocationsDrawer(drawerState: DrawerState, children: @Composable () -> Unit) {
val location: LocationPreferenceManager = get()
val navigator = LocalNavigator.current?.parent
ModalNavigationDrawer(drawerContent = {
ModalDrawerSheet {
Column(modifier = Modifier.padding(16.dp)) {
val locations = location.getLocations()
Text(
text = stringResource(id = R.string.locations),
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(16.dp))
locations.forEachIndexed { index, data ->
NavigationDrawerItem(
label = { Text(text = data.location) },
selected = index == location.selectedLocation,
onClick = { location.selectedLocation = index },
badge = {
IconButton(onClick = { location.removeLocation(data) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(
id = R.string.action_delete
)
)
}
}
)
}
Spacer(modifier = Modifier.weight(1f))
NavigationDrawerItem(
label = { Text(text = stringResource(id = R.string.location_add)) },
icon = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.location_add)
)
},
selected = true,
onClick = { navigator?.push(LocationPickerScreen()) },
)
}
}
}, drawerState = drawerState) {
children()
}
}

View file

@ -7,13 +7,14 @@ import androidx.compose.runtime.Composable
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun SmallToolbar(
backButton: Boolean = true,
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
backButton: Boolean = true
navigationIcon: @Composable () -> Unit = { if (backButton) BackButton() },
) {
TopAppBar(
title = title,
navigationIcon = { if (backButton) BackButton() },
navigationIcon = navigationIcon,
actions = actions,
)
}

View file

@ -6,19 +6,18 @@ import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.outlined.Thermostat
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.WindPower
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.henryhiles.qweather.R
import com.henryhiles.qweather.domain.weather.HourlyWeatherData
import java.time.format.DateTimeFormatter
@ -31,7 +30,7 @@ fun WeatherCard(hour: HourlyWeatherData?, location: String, modifier: Modifier =
}
Card(
shape = RoundedCornerShape(8.dp),
modifier = modifier.padding(16.dp)
modifier = modifier
) {
Column(
modifier = Modifier
@ -58,7 +57,7 @@ fun WeatherCard(hour: HourlyWeatherData?, location: String, modifier: Modifier =
Image(
painter = painterResource(id = it.weatherType.iconRes),
contentDescription = "Image of ${it.weatherType.weatherDesc}",
modifier = Modifier.width(200.dp)
modifier = Modifier.height(152.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = "${it.temperature}°C", fontSize = 50.sp)
@ -72,19 +71,19 @@ fun WeatherCard(hour: HourlyWeatherData?, location: String, modifier: Modifier =
WeatherDataDisplay(
value = it.apparentTemperature,
unit = "°C",
icon = Icons.Default.Thermostat,
icon = Icons.Outlined.Thermostat,
description = "Feels like",
)
WeatherDataDisplay(
value = it.precipitationProbability,
unit = "%",
icon = ImageVector.vectorResource(id = R.drawable.ic_drop),
icon = Icons.Outlined.WaterDrop,
description = "Chance of precipitation"
)
WeatherDataDisplay(
value = it.windSpeed,
unit = "km/h",
icon = ImageVector.vectorResource(id = R.drawable.ic_wind),
icon = Icons.Outlined.WindPower,
description = "Wind Speed",
)
}

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Water
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.WindPower
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -15,11 +16,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.henryhiles.qweather.R
import com.henryhiles.qweather.domain.weather.DailyWeatherData
import java.time.format.DateTimeFormatter
@ -71,7 +69,7 @@ fun WeatherDay(dailyWeatherData: DailyWeatherData, expanded: Boolean, onExpand:
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 0.dp, 16.dp, 16.dp),
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center
) {
WeatherDataDisplay(
@ -80,7 +78,6 @@ fun WeatherDay(dailyWeatherData: DailyWeatherData, expanded: Boolean, onExpand:
icon = Icons.Outlined.WaterDrop,
description = "Chance of rain"
)
Spacer(modifier = Modifier.width(16.dp))
WeatherDataDisplay(
value = dailyWeatherData.windSpeedMax,
@ -88,12 +85,11 @@ fun WeatherDay(dailyWeatherData: DailyWeatherData, expanded: Boolean, onExpand:
icon = Icons.Outlined.Water,
description = "Precipitation Amount"
)
Spacer(modifier = Modifier.width(16.dp))
WeatherDataDisplay(
value = dailyWeatherData.windSpeedMax,
unit = "km/h",
icon = ImageVector.vectorResource(id = R.drawable.ic_wind),
icon = Icons.Outlined.WindPower,
description = "Wind Speed"
)
}

View file

@ -1,14 +1,12 @@
package com.henryhiles.qweather.presentation.components.weather
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.henryhiles.qweather.presentation.screenmodel.HourlyWeatherState
import java.time.LocalDateTime
@ -19,24 +17,15 @@ fun WeatherForecast(
onChangeSelected: (Int) -> Unit
) {
state.hourlyWeatherInfo?.weatherData?.let {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(text = "Today", fontSize = 20.sp)
Spacer(modifier = Modifier.height(16.dp))
val rowState = rememberLazyListState(LocalDateTime.now().hour)
LazyRow(state = rowState) {
itemsIndexed(it) { index, data ->
WeatherHour(
data = data,
modifier = Modifier
.padding(horizontal = 8.dp),
onChangeSelected = { onChangeSelected(index) }
)
}
val rowState = rememberLazyListState(LocalDateTime.now().hour)
LazyRow(state = rowState, modifier = modifier) {
itemsIndexed(it) { index, data ->
WeatherHour(
data = data,
modifier = Modifier
.padding(horizontal = 8.dp),
onChangeSelected = { onChangeSelected(index) }
)
}
}
}

View file

@ -37,13 +37,11 @@ fun WeatherHour(
horizontalAlignment = CenterHorizontally
) {
Text(text = formattedTime)
Image(
painter = painterResource(id = it.weatherType.iconRes),
contentDescription = "Image of ${it.weatherType.weatherDesc}",
modifier = Modifier.width(40.dp)
)
Text(text = "${it.temperature}°C")
}
}

View file

@ -0,0 +1,43 @@
package com.henryhiles.qweather.presentation.components.weather
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.henryhiles.qweather.R
import com.henryhiles.qweather.presentation.components.VerticalDivider
import com.henryhiles.qweather.presentation.screenmodel.HourlyWeatherState
@Composable
fun WeatherToday(state: HourlyWeatherState) {
state.hourlyWeatherInfo?.let {
Row(
modifier = Modifier
.height(24.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.weather_high, it.highTemperature),
)
VerticalDivider(modifier = Modifier.padding(horizontal = 8.dp))
Text(
text = stringResource(id = R.string.weather_low, it.lowTemperature)
)
VerticalDivider(modifier = Modifier.padding(horizontal = 8.dp))
Text(
text = it.precipitationProbability?.let {
stringResource(
id = R.string.weather_precipitation,
it
)
} ?: stringResource(
id = R.string.unknown
)
)
}
}
}

View file

@ -15,7 +15,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -24,6 +23,7 @@ import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import com.henryhiles.qweather.R
import com.henryhiles.qweather.domain.geocoding.GeocodingData
import com.henryhiles.qweather.presentation.components.navigation.SmallToolbar
import com.henryhiles.qweather.presentation.screenmodel.LocationPickerScreenModel
@ -32,24 +32,20 @@ class LocationPickerScreen : Screen {
@Composable
override fun Content() {
val screenModel: LocationPickerScreenModel = getScreenModel()
var latitude by remember { mutableStateOf(screenModel.prefs.latitude) }
var longitude by remember { mutableStateOf(screenModel.prefs.longitude) }
var location by remember { mutableStateOf(screenModel.prefs.location) }
var location by remember {
mutableStateOf<GeocodingData?>(null)
}
var locationSearch by remember { mutableStateOf("") }
var isAboutOpen by remember { mutableStateOf(false) }
val navigator = LocalNavigator.current
val context = LocalContext.current
Scaffold(modifier = Modifier.imePadding(),
floatingActionButton = {
FloatingActionButton(onClick = {
if (location == "") isAboutOpen = true
else {
screenModel.prefs.location = location
screenModel.prefs.latitude = latitude
screenModel.prefs.longitude = longitude
location?.let {
screenModel.prefs.addLocation(it)
navigator?.push(MainScreen())
}
} ?: kotlin.run { isAboutOpen = true }
}) {
Icon(
imageVector = Icons.Default.Check,
@ -57,32 +53,32 @@ class LocationPickerScreen : Screen {
)
}
}) {
screenModel.state.error?.let {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
title = { Text(text = stringResource(id = R.string.error)) },
text = {
SelectionContainer {
Text(
text = it,
Column {
SmallToolbar(
title = { Text(text = stringResource(id = R.string.location_choose)) },
actions = {
IconButton(
onClick = { isAboutOpen = true }) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = stringResource(id = R.string.help_screen)
)
}
},
)
} ?: kotlin.run {
Column {
SmallToolbar(
title = { Text(text = stringResource(id = R.string.location_choose)) },
actions = {
IconButton(
onClick = { isAboutOpen = true }) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = stringResource(id = R.string.help_screen)
})
screenModel.state.error?.let {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
title = { Text(text = stringResource(id = R.string.error)) },
text = {
SelectionContainer {
Text(
text = it,
)
}
})
},
)
} ?: kotlin.run {
Column(modifier = Modifier.padding(16.dp)) {
if (isAboutOpen) AlertDialog(
title = { Text(text = stringResource(id = R.string.location_choose)) },
@ -134,27 +130,16 @@ class LocationPickerScreen : Screen {
) else screenModel.state.locations?.let {
LazyColumn {
items(it) {
val locationText by remember {
mutableStateOf(
context.getString(
R.string.location_string,
it.city, it.admin, it.country
)
)
}
val selected = it == location
Spacer(modifier = Modifier.height(8.dp))
Card(modifier = Modifier.clickable {
location = locationText
longitude = it.longitude
latitude = it.latitude
}) {
Card(modifier = Modifier.clickable { location = it }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (location == locationText) {
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(
@ -164,7 +149,7 @@ class LocationPickerScreen : Screen {
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = locationText)
Text(text = it.location)
}
}
}

View file

@ -1,38 +1,67 @@
package com.henryhiles.qweather.presentation.screen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.tab.TabNavigator
import com.henryhiles.qweather.R
import com.henryhiles.qweather.domain.util.NavigationTab
import com.henryhiles.qweather.presentation.components.location.LocationsDrawer
import com.henryhiles.qweather.presentation.components.navigation.BottomBar
import com.henryhiles.qweather.presentation.components.navigation.SmallToolbar
import com.henryhiles.qweather.presentation.screenmodel.LocationPreferenceManager
import com.henryhiles.qweather.presentation.tabs.TodayTab
import kotlinx.coroutines.launch
import org.koin.androidx.compose.get
class MainScreen : Screen {
@Composable
override fun Content() {
val drawerState =
rememberDrawerState(initialValue = DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
TabNavigator(tab = TodayTab) {
Scaffold(
topBar = {
SmallToolbar(
title = { Text(text = "QWeather") },
actions = {
(it.current as? NavigationTab)?.Actions()
LocationsDrawer(drawerState = drawerState) {
Scaffold(
topBar = {
SmallToolbar(
title = { Text(text = stringResource(R.string.app_name)) },
actions = {
(it.current as? NavigationTab)?.Actions()
}
) {
IconButton(onClick = {
coroutineScope.launch {
with(drawerState) { if (isOpen) close() else open() }
}
}) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.location_picker_open)
)
}
}
)
},
bottomBar = {
BottomBar(navigator = it)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
CurrentScreen()
},
bottomBar = {
BottomBar(navigator = it)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
CurrentScreen()
}
}
}
}

View file

@ -19,20 +19,20 @@ data class DailyWeatherState(
class DailyWeatherScreenModel(
private val repository: WeatherRepository,
private val location: LocationPreferenceManager
locationPreferenceManager: LocationPreferenceManager
) : ScreenModel {
var state by mutableStateOf(DailyWeatherState())
private set
val location = locationPreferenceManager.getSelectedLocation()
fun loadWeatherInfo(cache: Boolean = true) {
coroutineScope.launch {
state = state.copy(isLoading = true, error = null)
state = when (val result =
repository.getDailyWeatherData(
lat = location.latitude,
long = location.longitude,
cache = cache
)) {
state = when (val result = repository.getDailyWeatherData(
lat = location.latitude,
long = location.longitude,
cache = cache
)) {
is Resource.Success -> {
state.copy(
dailyWeatherData = result.data,

View file

@ -19,11 +19,13 @@ data class HourlyWeatherState(
class HourlyWeatherScreenModel(
private val repository: WeatherRepository,
val location: LocationPreferenceManager,
locationPreferenceManager: LocationPreferenceManager,
) : ScreenModel {
var state by mutableStateOf(HourlyWeatherState())
private set
val location = locationPreferenceManager.getSelectedLocation()
fun loadWeatherInfo(cache: Boolean = true) {
coroutineScope.launch {
state = state.copy(isLoading = true, error = null, selected = null)

View file

@ -6,23 +6,46 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import com.henryhiles.qweather.domain.geocoding.GeocodingData
import com.henryhiles.qweather.domain.manager.BasePreferenceManager
import com.henryhiles.qweather.domain.remote.GeocodingLocationDto
import com.henryhiles.qweather.domain.repository.GeocodingRepository
import com.henryhiles.qweather.domain.util.Resource
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class LocationPickerState(
val locations: List<GeocodingLocationDto>? = null,
val locations: List<GeocodingData>? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
class LocationPreferenceManager(context: Context) :
BasePreferenceManager(context.getSharedPreferences("location", Context.MODE_PRIVATE)) {
var latitude by floatPreference("lat", 0f)
var longitude by floatPreference("long", 0f)
var location by stringPreference("string")
private var locations by stringPreference(
"locations",
Json.encodeToString(value = listOf<GeocodingData>())
)
var selectedLocation by intPreference("selected_location", 0)
fun getSelectedLocation(): GeocodingData {
return getLocations()[selectedLocation]
}
fun getLocations(): List<GeocodingData> {
return Json.decodeFromString(string = locations)
}
fun addLocation(location: GeocodingData) {
val currentLocations = getLocations()
locations = Json.encodeToString(value = currentLocations + location)
}
fun removeLocation(location: GeocodingData) {
val currentLocations = getLocations()
locations = Json.encodeToString(value = currentLocations - location)
}
}
class LocationPickerScreenModel(

View file

@ -20,6 +20,7 @@ import com.henryhiles.qweather.R
import com.henryhiles.qweather.domain.util.NavigationTab
import com.henryhiles.qweather.presentation.components.weather.WeatherCard
import com.henryhiles.qweather.presentation.components.weather.WeatherForecast
import com.henryhiles.qweather.presentation.components.weather.WeatherToday
import com.henryhiles.qweather.presentation.screenmodel.HourlyWeatherScreenModel
object TodayTab : NavigationTab {
@ -77,6 +78,7 @@ object TodayTab : NavigationTab {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)
) {
WeatherCard(
hour = weatherViewModel.state.selected?.let {
@ -84,7 +86,7 @@ object TodayTab : NavigationTab {
} ?: weatherViewModel.state.hourlyWeatherInfo?.currentWeatherData,
location = weatherViewModel.location.location
)
Spacer(modifier = Modifier.height(16.dp))
WeatherToday(state = weatherViewModel.state)
WeatherForecast(
state = weatherViewModel.state
) { weatherViewModel.setSelected(it) }

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="149.86dp"
android:height="249.77dp"
android:viewportWidth="149.86"
android:viewportHeight="249.77">
<path
android:fillColor="#FF000000"
android:pathData="M74.93,249.77c41.32,0 74.93,-28.71 74.93,-64 0,-34 -75.48,-178 -78.7,-184.1a3.12,3.12 0,0 0,-5.62 0.2C62.87,8 0,151.88 0,185.77 0,221.06 33.61,249.77 74.93,249.77ZM68.66,10.38c14.36,27.76 75,146.61 75,175.39 0,31.84 -30.82,57.75 -68.69,57.75S6.24,217.61 6.24,185.77C6.24,157 56.53,38.52 68.66,10.38ZM13.11,190.91a3.12,3.12 0,0 1,2.62 -3.55A3.15,3.15 0,0 1,19.28 190c2.64,17.47 15.64,31.71 34.78,38.09a3.12,3.12 0,1 1,-2 5.93C31,227 16.06,210.46 13.11,190.91Z"/>
</vector>

View file

@ -1,33 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="212.3dp"
android:height="62.44dp"
android:viewportWidth="212.3"
android:viewportHeight="62.44">
<path
android:fillColor="#FF000000"
android:pathData="M209.18,6.24H193.57a3.12,3.12 0,0 1,0 -6.24h15.61a3.12,3.12 0,1 1,0 6.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M181.08,6.24H178A3.12,3.12 0,0 1,178 0h3.12a3.12,3.12 0,0 1,0 6.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M165.47,6.24H3.12A3.12,3.12 0,0 1,3.12 0H165.47a3.12,3.12 0,1 1,0 6.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M181.08,43.71H31.22a3.13,3.13 0,0 1,0 -6.25H181.08a3.13,3.13 0,0 1,0 6.25Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M165.47,62.44H106.15a3.12,3.12 0,0 1,0 -6.24h59.32a3.12,3.12 0,1 1,0 6.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M93.66,62.44H90.54a3.12,3.12 0,0 1,0 -6.24h3.12a3.12,3.12 0,1 1,0 6.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M78.05,62.44H46.83a3.12,3.12 0,1 1,0 -6.24H78.05a3.12,3.12 0,1 1,0 6.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M196.69,25h-153a3.13,3.13 0,0 1,0 -6.25h153a3.13,3.13 0,0 1,0 6.25Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M31.22,25H15.61a3.13,3.13 0,0 1,0 -6.25H31.22a3.13,3.13 0,0 1,0 6.25Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="277.86dp"
android:height="199.81dp"
android:viewportWidth="277.86"
android:viewportHeight="199.81">
<path
android:fillColor="#FF000000"
android:pathData="M277.86,57.76c0,27.69 -26.67,48.39 -50.53,48.39L50,106.15a3.12,3.12 0,0 1,0 -6.24L227.33,99.91c20.48,0 44.29,-18.42 44.29,-42.15 0,-23.24 -17.06,-42.15 -38,-42.15 -13.64,0 -25.89,10.36 -32.79,27.72A3.12,3.12 0,1 1,195 41c7.87,-19.82 22.3,-31.65 38.59,-31.65C258,9.37 277.86,31.07 277.86,57.76ZM237.28,93.66a3,3 0,0 0,1.14 -0.22C252,88.06 260,80.06 265.17,66.68a3.12,3.12 0,0 0,-5.83 -2.24c-4.51,11.74 -11.23,18.46 -23.21,23.2a3.12,3.12 0,0 0,1.15 6ZM122.8,125.06a3.13,3.13 0,0 0,-0.87 -0.18L34.34,124.88a3.13,3.13 0,0 0,0 6.25h84.59c18.77,0 34,13.3 34,29.66 0,15.47 -14.56,32.78 -34,32.78 -13.79,0 -25.44,-10.15 -30.85,-20.22a3.13,3.13 0,0 0,-5.5 3c6.3,11.69 20,23.49 36.35,23.49 21.46,0 40.3,-18.23 40.3,-39C159.23,142.15 143.21,126.8 122.8,125.06ZM12.8,78.06a3.12,3.12 0,0 0,3.13 3.12h76.8a3,3 0,0 0,0.61 -0.12c18,-1.24 37.8,-16 37.8,-34.8s-18,-36.88 -37,-36.88c-12.83,0 -27.36,9.71 -33.08,22.1a3.12,3.12 0,0 0,5.67 2.62c4.7,-10.19 17,-18.48 27.41,-18.48 15.52,0 30.75,15.18 30.75,30.64 0,15.89 -18.6,28.68 -34,28.68h-75A3.13,3.13 0,0 0,12.79 78.05ZM108.64,6.18C120.73,8.69 135,23 137.43,35a3.13,3.13 0,0 0,3.06 2.5,3.52 3.52,0 0,0 0.63,-0.06 3.12,3.12 0,0 0,2.43 -3.68C140.65,19.42 124.3,3.06 109.91,0.06a3.13,3.13 0,1 0,-1.27 6.12ZM215.52,168.59c-7.34,0 -16,-6.1 -19.36,-13.6a3.12,3.12 0,1 0,-5.71 2.53c4.39,9.87 15.17,17.32 25.07,17.32 14.39,0 28,-14 28,-28.85s-15,-26.29 -28.57,-27.26a3.55,3.55 0,0 0,-0.47 -0.09L196.69,118.64a3.12,3.12 0,0 0,0 6.24h16.45c10.86,0 24.14,9.74 24.14,21.11S226.5,168.59 215.52,168.59ZM184.2,118.64h-3.12a3.12,3.12 0,0 0,0 6.24h3.12a3.12,3.12 0,0 0,0 -6.24ZM168.59,118.64h-15.3a3.12,3.12 0,1 0,0 6.24h15.3a3.12,3.12 0,0 0,0 -6.24ZM37.46,99.91L34.34,99.91a3.12,3.12 0,0 0,0 6.24h3.12a3.12,3.12 0,1 0,0 -6.24ZM25,103a3.12,3.12 0,0 0,-3.13 -3.12L3.12,99.88a3.12,3.12 0,0 0,0 6.24L21.85,106.12A3.13,3.13 0,0 0,25 103ZM43.73,137.34a3.13,3.13 0,0 0,0 6.25L78.05,143.59a3.13,3.13 0,0 0,0 -6.25Z"/>
</vector>

View file

@ -8,11 +8,12 @@
<string name="action_apply">Apply</string>
<string name="action_confirm">Confirm</string>
<string name="action_open_about">About</string>
<string name="action_delete">Delete</string>
<string name="action_search">Search</string>
<string name="action_reload">Reload</string>
<string name="action_try_again">Try Again</string>
<string name="selected">Selected</string>x
<string name="selected">Selected</string>
<string name="help_screen">How do I use this screen?</string>
<string name="help_location_picker">Please search a location, then tap a result. Then tap the apply button in the bottom left corner.</string>
@ -27,16 +28,21 @@
<string name="settings_location_description">Location to fetch data from</string>
<string name="location">Location</string>
<string name="location_string">%1$s, %2$s, %3$s</string>
<string name="location_auto_pick">Auto-pick location</string>
<string name="locations">Locations</string>
<string name="location_add">Add Location</string>
<string name="location_picker_open">Open location picker</string>
<string name="location_choose">Choose a Location</string>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="weather_high">High: %1$d°C</string>
<string name="weather_low">Low: %1$d°C</string>
<string name="weather_precipitation">Precipitation: %1$d&#65130;</string>
<string name="unknown">Unknown</string>
<string name="error">An error occurred</string>
<string name="error_location">Couldn\'t retrieve location. Make sure to grant permission and enable GPS.</string>
<string name="error_location">"Couldn't retrieve location. Make sure to grant permission and enable GPS."</string>
</resources>