Refactor weather widget

This commit is contained in:
MM20 2021-12-19 21:58:19 +01:00
parent 2ee50459a9
commit b9054f04cd
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
8 changed files with 256 additions and 166 deletions

View File

@ -21,6 +21,7 @@ import de.mm20.launcher2.websites.websitesModule
import de.mm20.launcher2.widgets.widgetsModule
import de.mm20.launcher2.wikipedia.wikipediaModule
import de.mm20.launcher2.database.databaseModule
import de.mm20.launcher2.weather.weatherModule
import kotlinx.coroutines.*
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@ -67,6 +68,7 @@ class LauncherApplication : Application(), CoroutineScope {
musicModule,
searchModule,
unitConverterModule,
weatherModule,
websitesModule,
widgetsModule,
wikipediaModule

View File

@ -1,17 +1,17 @@
package de.mm20.launcher2.database
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
import androidx.room.Transaction
import de.mm20.launcher2.database.entities.ForecastEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface WeatherDao {
@Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC")
fun getForecasts(): LiveData<List<ForecastEntity>>
fun getForecasts(): Flow<List<ForecastEntity>>
@Insert(onConflict = REPLACE)
fun insertAll(forecasts: List<ForecastEntity>)

View File

@ -20,10 +20,8 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -31,11 +29,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidgetWM
import de.mm20.launcher2.ui.weather.AnimatedWeatherIcon
import de.mm20.launcher2.ui.weather.WeatherIcon
import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.WeatherViewModel
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.SimpleDateFormat
@ -44,155 +42,153 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun WeatherWidget() {
val viewModel: WeatherViewModel = viewModel()
val weatherData by viewModel.forecasts.observeAsState(initial = emptyList())
var selectedDayIndex by remember { mutableStateOf(0) }
var selectedForecastIndex by remember { mutableStateOf(0) }
var detailsExpanded by remember { mutableStateOf(false) }
val viewModel: WeatherWidgetWM = viewModel()
val selectedForecast by viewModel.currentForecast.observeAsState()
if (weatherData.isNotEmpty() && weatherData.size <= selectedDayIndex) {
selectedDayIndex = 0
return
}
val imperialUnits by viewModel.imperialUnits.observeAsState(false)
if (weatherData.isNotEmpty() && weatherData[selectedDayIndex].hourlyForecasts.size <= selectedForecastIndex) {
selectedForecastIndex = 0
return
}
if (weatherData.isEmpty()) {
val forecast = selectedForecast ?: run {
NoData()
return
}
val selectedForecast = weatherData[selectedDayIndex].hourlyForecasts[selectedForecastIndex]
val imperialUnits = LauncherPreferences.instance.imperialUnits
Column {
Row {
Column(
modifier = Modifier
.padding(start = 16.dp, top = 16.dp)
.weight(1f)
) {
Text(
text = selectedForecast.location,
style = MaterialTheme.typography.titleMedium
)
Text(
text = convertTemperature(
imperialUnits = imperialUnits,
temp = selectedForecast.temperature
).toString() + "°",
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = selectedForecast.condition,
style = MaterialTheme.typography.bodySmall
)
CurrentWeather(forecast, imperialUnits)
Row(
modifier = Modifier
.clickable(onClick = {
detailsExpanded = !detailsExpanded
})
.padding(vertical = 12.dp)
) {
Text(
text = stringResource(id = if (detailsExpanded) R.string.weather_widget_hide_details else R.string.weather_widget_show_details),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
AnimatedVisibility(visible = detailsExpanded) {
Column(
modifier = Modifier.fillMaxWidth()
) {
WeatherDetailRow(
title = stringResource(id = R.string.weather_humidity),
value = "${selectedForecast.humidity.roundToInt()} %"
)
WeatherDetailRow(
title = stringResource(id = R.string.weather_wind),
value = formatWindSpeed(imperialUnits, selectedForecast)
)
val precipitation = formatPrecipitation(imperialUnits, selectedForecast)
if (precipitation != null) {
WeatherDetailRow(
title = stringResource(id = R.string.weather_precipitation),
value = precipitation
)
}
}
}
}
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End
) {
val context = LocalContext.current
AnimatedWeatherIcon(
modifier = Modifier.padding(all = 16.dp),
icon = weatherIconById(selectedForecast.icon),
night = selectedForecast.night
)
Spacer(modifier = Modifier.weight(1f))
Surface(
shape = RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
modifier = Modifier.align(Alignment.End)
) {
Text(
text = "${selectedForecast.provider} (${
formatTime(
LocalContext.current,
selectedForecast.updateTime
)
})",
style = TextStyle(
fontSize = 10.sp
),
modifier = Modifier
.clickable(onClick = {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(selectedForecast.providerUrl)
?: return@clickable
}
context.tryStartActivity(intent)
})
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp, end = 16.dp)
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
WeatherDaySelector(
days = weatherData,
selectedDay = weatherData[selectedDayIndex],
onDaySelected = {
selectedDayIndex = it
selectedForecastIndex = 0
},
modifier = Modifier.weight(1f)
)
val dailyForecasts by viewModel.dailyForecasts.observeAsState(emptyList())
val selectedDayForecast by viewModel.currentDailyForecast.observeAsState()
selectedDayForecast?.let {
WeatherDaySelector(
days = dailyForecasts,
selectedDay = it,
onDaySelected = {
viewModel.selectDay(it)
},
modifier = Modifier.weight(1f)
)
}
val currentDayForecasts by viewModel.currentDayForecasts.observeAsState(emptyList())
WeatherTimeSelector(
forecasts = weatherData[selectedDayIndex].hourlyForecasts,
selectedForecast = selectedForecast,
forecasts = currentDayForecasts,
selectedForecast = forecast,
imperialUnits = imperialUnits,
onTimeSelected = {
selectedForecastIndex = it
viewModel.selectForecast(it)
}
)
}
}
}
@Composable
fun CurrentWeather(forecast: Forecast, imperialUnits: Boolean) {
var detailsExpanded by remember { mutableStateOf(false) }
Row {
Column(
modifier = Modifier
.padding(start = 16.dp, top = 16.dp)
.weight(1f)
) {
Text(
text = forecast.location,
style = MaterialTheme.typography.titleMedium
)
Text(
text = convertTemperature(
imperialUnits = imperialUnits,
temp = forecast.temperature
).toString() + "°",
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = forecast.condition,
style = MaterialTheme.typography.bodySmall
)
Row(
modifier = Modifier
.clickable(onClick = {
detailsExpanded = !detailsExpanded
})
.padding(vertical = 12.dp)
) {
Text(
text = stringResource(id = if (detailsExpanded) R.string.weather_widget_hide_details else R.string.weather_widget_show_details),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
AnimatedVisibility(visible = detailsExpanded) {
Column(
modifier = Modifier.fillMaxWidth()
) {
WeatherDetailRow(
title = stringResource(id = R.string.weather_humidity),
value = "${forecast.humidity.roundToInt()} %"
)
WeatherDetailRow(
title = stringResource(id = R.string.weather_wind),
value = formatWindSpeed(imperialUnits, forecast)
)
val precipitation = formatPrecipitation(imperialUnits, forecast)
if (precipitation != null) {
WeatherDetailRow(
title = stringResource(id = R.string.weather_precipitation),
value = precipitation
)
}
}
}
}
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End
) {
val context = LocalContext.current
AnimatedWeatherIcon(
modifier = Modifier.padding(all = 16.dp),
icon = weatherIconById(forecast.icon),
night = forecast.night
)
Spacer(modifier = Modifier.weight(1f))
Surface(
shape = RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
modifier = Modifier.align(Alignment.End)
) {
Text(
text = "${forecast.provider} (${
formatTime(
LocalContext.current,
forecast.updateTime
)
})",
style = TextStyle(
fontSize = 10.sp
),
modifier = Modifier
.clickable(onClick = {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(forecast.providerUrl)
?: return@clickable
}
context.tryStartActivity(intent)
})
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp, end = 16.dp)
)
}
}
}
}
@Composable
fun WeatherDetailRow(title: String, value: String) {
Row {
@ -299,12 +295,12 @@ fun WeatherTimeSelector(
modifier: Modifier = Modifier,
forecasts: List<Forecast>,
selectedForecast: Forecast,
imperialUnits: Boolean,
onTimeSelected: (Int) -> Unit
) {
val menuExpanded = remember { mutableStateOf(false) }
val dateFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
val imperialUnits = LauncherPreferences.instance.imperialUnits
Row(
modifier = modifier
@ -455,7 +451,8 @@ fun NoData() {
.wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Rounded.LightMode,
Icon(
imageVector = Icons.Rounded.LightMode,
contentDescription = "",
modifier = Modifier
.padding(24.dp)

View File

@ -0,0 +1,71 @@
package de.mm20.launcher2.ui.launcher.widgets.weather
import androidx.lifecycle.*
import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.WeatherRepository
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.math.min
class WeatherWidgetWM : ViewModel(), KoinComponent {
private val weatherRepository: WeatherRepository by inject()
private var selectedDayIndex = 0
set(value) {
field = min(value, forecasts.lastIndex)
selectedForecastIndex = min(
selectedForecastIndex,
forecasts[value].hourlyForecasts.lastIndex
)
currentDayForecasts.postValue(forecasts[value].hourlyForecasts)
currentDailyForecast.postValue(forecasts[value])
currentForecast.postValue(getCurrentlySelectedForecast())
}
private var selectedForecastIndex = 0
set(value) {
field = min(value, forecasts[selectedDayIndex].hourlyForecasts.lastIndex)
currentForecast.postValue(getCurrentlySelectedForecast())
}
private val forecastsFlow = weatherRepository.forecasts
private var forecasts: List<DailyForecast> = emptyList()
set(value) {
field = value
selectedDayIndex = 0
selectedForecastIndex = 0
dailyForecasts.postValue(value)
}
init {
viewModelScope.launch {
forecastsFlow.collectLatest {
forecasts = it
}
}
}
val currentForecast = MutableLiveData<Forecast?>(getCurrentlySelectedForecast())
val dailyForecasts = MutableLiveData<List<DailyForecast>>(emptyList())
val currentDayForecasts = MutableLiveData<List<Forecast>>(emptyList())
val currentDailyForecast = MutableLiveData<DailyForecast>(null)
val imperialUnits = MutableLiveData(LauncherPreferences.instance.imperialUnits)
fun selectDay(index: Int) {
selectedDayIndex = min(index, forecasts.lastIndex)
}
fun selectForecast(index: Int) {
selectedForecastIndex = index
}
private fun getCurrentlySelectedForecast(): Forecast? {
return forecasts.getOrNull(selectedDayIndex)?.hourlyForecasts?.getOrNull(selectedForecastIndex)
}
}

View File

@ -42,8 +42,8 @@ dependencies {
implementation(libs.androidx.work)
implementation(libs.okhttp)
implementation(libs.bundles.retrofit)
implementation(libs.suncalc)
implementation(libs.koin.android)
implementation(project(":database"))
implementation(project(":ktx"))

View File

@ -0,0 +1,8 @@
package de.mm20.launcher2.weather
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val weatherModule = module {
single { WeatherRepository(androidContext(), get()) }
}

View File

@ -2,31 +2,35 @@ package de.mm20.launcher2.weather
import android.content.Context
import android.util.Log
import androidx.lifecycle.MediatorLiveData
import androidx.work.*
import de.mm20.launcher2.database.AppDatabase
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.*
import java.util.concurrent.TimeUnit
class WeatherRepository(
val context: Context
val context: Context,
val database: AppDatabase,
) {
val forecasts = MediatorLiveData<List<DailyForecast>>()
init {
forecasts.addSource(AppDatabase.getInstance(context).weatherDao().getForecasts()) { entities ->
forecasts.value = sortDailyForecasts(entities.map { Forecast(it) })
val forecasts = database.weatherDao().getForecasts()
.map { it.map { Forecast(it) } }
.map {
groupForecastsPerDay(it)
}
val weatherRequest = PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES)
init {
val weatherRequest =
PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES)
.build()
WorkManager.getInstance().enqueueUniquePeriodicWork("weather",
ExistingPeriodicWorkPolicy.KEEP, weatherRequest)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather",
ExistingPeriodicWorkPolicy.KEEP, weatherRequest
)
}
private fun sortDailyForecasts(forecasts: List<Forecast>): List<DailyForecast> {
private fun groupForecastsPerDay(forecasts: List<Forecast>): List<DailyForecast> {
val dailyForecasts = mutableListOf<DailyForecast>()
val calendar = Calendar.getInstance()
var currentDay = 0
@ -35,12 +39,16 @@ class WeatherRepository(
calendar.timeInMillis = fc.timestamp
if (currentDay != calendar.get(Calendar.DAY_OF_YEAR)) {
if (currentDayForecasts.isNotEmpty()) {
dailyForecasts.add(DailyForecast(
dailyForecasts.add(
DailyForecast(
timestamp = currentDayForecasts.first().timestamp,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature ?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature ?: 0.0,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature
?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature
?: 0.0,
hourlyForecasts = currentDayForecasts
))
)
)
currentDayForecasts = mutableListOf()
}
currentDay = calendar.get(Calendar.DAY_OF_YEAR)
@ -48,12 +56,16 @@ class WeatherRepository(
currentDayForecasts.add(fc)
}
if (currentDayForecasts.isNotEmpty()) {
dailyForecasts.add(DailyForecast(
dailyForecasts.add(
DailyForecast(
timestamp = currentDayForecasts.first().timestamp,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature ?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature ?: 0.0,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature
?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature
?: 0.0,
hourlyForecasts = currentDayForecasts
))
)
)
}
return dailyForecasts
}
@ -63,8 +75,8 @@ class WeatherRepository(
val provider = WeatherProvider.getInstance(context) ?: return
if (provider.isUpdateRequired()) {
val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java)
.addTag("weather")
.build()
.addTag("weather")
.build()
WorkManager.getInstance(context).enqueue(weatherRequest)
} else {
Log.d("MM20", "No weather update required")
@ -72,7 +84,8 @@ class WeatherRepository(
}
}
class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
class WeatherUpdateWorker(val context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val provider = WeatherProvider.getInstance(context) ?: return Result.failure()
if (!provider.isAvailable()) return Result.failure()
@ -83,7 +96,8 @@ class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : Coro
Result.retry()
} else {
Log.d("MM20", "Weather update succeeded")
AppDatabase.getInstance(applicationContext).weatherDao().replaceAll(weatherData.map { it.toDatabaseEntity() })
AppDatabase.getInstance(applicationContext).weatherDao()
.replaceAll(weatherData.map { it.toDatabaseEntity() })
Result.success()
}
}

View File

@ -4,19 +4,17 @@ import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class WeatherViewModel(application: Application) : AndroidViewModel(application) {
class WeatherViewModel(application: Application) : AndroidViewModel(application), KoinComponent {
private val repository = WeatherRepository(application)
private val repository : WeatherRepository by inject()
init {
requestUpdate(application)
}
val forecasts: LiveData<List<DailyForecast>> by lazy {
repository.forecasts
}
fun requestUpdate(context: Context) {
repository.requestUpdate(context)
}