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

View File

@ -1,17 +1,17 @@
package de.mm20.launcher2.database package de.mm20.launcher2.database
import androidx.lifecycle.LiveData
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import de.mm20.launcher2.database.entities.ForecastEntity import de.mm20.launcher2.database.entities.ForecastEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface WeatherDao { interface WeatherDao {
@Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC") @Query("SELECT * FROM ${ForecastEntity.TABLE_NAME} ORDER BY timestamp ASC")
fun getForecasts(): LiveData<List<ForecastEntity>> fun getForecasts(): Flow<List<ForecastEntity>>
@Insert(onConflict = REPLACE) @Insert(onConflict = REPLACE)
fun insertAll(forecasts: List<ForecastEntity>) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.ktx.tryStartActivity
import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.ui.R 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.AnimatedWeatherIcon
import de.mm20.launcher2.ui.weather.WeatherIcon import de.mm20.launcher2.ui.weather.WeatherIcon
import de.mm20.launcher2.weather.DailyForecast import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.WeatherViewModel
import java.text.DateFormat import java.text.DateFormat
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -44,155 +42,153 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalAnimationApi::class) @OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
fun WeatherWidget() { fun WeatherWidget() {
val viewModel: WeatherViewModel = viewModel() val viewModel: WeatherWidgetWM = 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 selectedForecast by viewModel.currentForecast.observeAsState()
if (weatherData.isNotEmpty() && weatherData.size <= selectedDayIndex) { val imperialUnits by viewModel.imperialUnits.observeAsState(false)
selectedDayIndex = 0
return
}
if (weatherData.isNotEmpty() && weatherData[selectedDayIndex].hourlyForecasts.size <= selectedForecastIndex) { val forecast = selectedForecast ?: run {
selectedForecastIndex = 0
return
}
if (weatherData.isEmpty()) {
NoData() NoData()
return return
} }
val selectedForecast = weatherData[selectedDayIndex].hourlyForecasts[selectedForecastIndex]
val imperialUnits = LauncherPreferences.instance.imperialUnits
Column { Column {
Row { CurrentWeather(forecast, imperialUnits)
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
)
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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
WeatherDaySelector( val dailyForecasts by viewModel.dailyForecasts.observeAsState(emptyList())
days = weatherData, val selectedDayForecast by viewModel.currentDailyForecast.observeAsState()
selectedDay = weatherData[selectedDayIndex], selectedDayForecast?.let {
onDaySelected = { WeatherDaySelector(
selectedDayIndex = it days = dailyForecasts,
selectedForecastIndex = 0 selectedDay = it,
}, onDaySelected = {
modifier = Modifier.weight(1f) viewModel.selectDay(it)
) },
modifier = Modifier.weight(1f)
)
}
val currentDayForecasts by viewModel.currentDayForecasts.observeAsState(emptyList())
WeatherTimeSelector( WeatherTimeSelector(
forecasts = weatherData[selectedDayIndex].hourlyForecasts, forecasts = currentDayForecasts,
selectedForecast = selectedForecast, selectedForecast = forecast,
imperialUnits = imperialUnits,
onTimeSelected = { 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 @Composable
fun WeatherDetailRow(title: String, value: String) { fun WeatherDetailRow(title: String, value: String) {
Row { Row {
@ -299,12 +295,12 @@ fun WeatherTimeSelector(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forecasts: List<Forecast>, forecasts: List<Forecast>,
selectedForecast: Forecast, selectedForecast: Forecast,
imperialUnits: Boolean,
onTimeSelected: (Int) -> Unit onTimeSelected: (Int) -> Unit
) { ) {
val menuExpanded = remember { mutableStateOf(false) } val menuExpanded = remember { mutableStateOf(false) }
val dateFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) } val dateFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
val imperialUnits = LauncherPreferences.instance.imperialUnits
Row( Row(
modifier = modifier modifier = modifier
@ -455,7 +451,8 @@ fun NoData() {
.wrapContentHeight(), .wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(imageVector = Icons.Rounded.LightMode, Icon(
imageVector = Icons.Rounded.LightMode,
contentDescription = "", contentDescription = "",
modifier = Modifier modifier = Modifier
.padding(24.dp) .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.androidx.work)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.bundles.retrofit) implementation(libs.bundles.retrofit)
implementation(libs.suncalc) implementation(libs.suncalc)
implementation(libs.koin.android)
implementation(project(":database")) implementation(project(":database"))
implementation(project(":ktx")) 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.content.Context
import android.util.Log import android.util.Log
import androidx.lifecycle.MediatorLiveData
import androidx.work.* import androidx.work.*
import de.mm20.launcher2.database.AppDatabase import de.mm20.launcher2.database.AppDatabase
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class WeatherRepository( class WeatherRepository(
val context: Context val context: Context,
val database: AppDatabase,
) { ) {
val forecasts = MediatorLiveData<List<DailyForecast>>() val forecasts = database.weatherDao().getForecasts()
.map { it.map { Forecast(it) } }
init { .map {
groupForecastsPerDay(it)
forecasts.addSource(AppDatabase.getInstance(context).weatherDao().getForecasts()) { entities ->
forecasts.value = sortDailyForecasts(entities.map { Forecast(it) })
} }
val weatherRequest = PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES) init {
val weatherRequest =
PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES)
.build() .build()
WorkManager.getInstance().enqueueUniquePeriodicWork("weather", WorkManager.getInstance(context).enqueueUniquePeriodicWork(
ExistingPeriodicWorkPolicy.KEEP, weatherRequest) "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 dailyForecasts = mutableListOf<DailyForecast>()
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
var currentDay = 0 var currentDay = 0
@ -35,12 +39,16 @@ class WeatherRepository(
calendar.timeInMillis = fc.timestamp calendar.timeInMillis = fc.timestamp
if (currentDay != calendar.get(Calendar.DAY_OF_YEAR)) { if (currentDay != calendar.get(Calendar.DAY_OF_YEAR)) {
if (currentDayForecasts.isNotEmpty()) { if (currentDayForecasts.isNotEmpty()) {
dailyForecasts.add(DailyForecast( dailyForecasts.add(
DailyForecast(
timestamp = currentDayForecasts.first().timestamp, timestamp = currentDayForecasts.first().timestamp,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature ?: 0.0, minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature ?: 0.0, ?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature
?: 0.0,
hourlyForecasts = currentDayForecasts hourlyForecasts = currentDayForecasts
)) )
)
currentDayForecasts = mutableListOf() currentDayForecasts = mutableListOf()
} }
currentDay = calendar.get(Calendar.DAY_OF_YEAR) currentDay = calendar.get(Calendar.DAY_OF_YEAR)
@ -48,12 +56,16 @@ class WeatherRepository(
currentDayForecasts.add(fc) currentDayForecasts.add(fc)
} }
if (currentDayForecasts.isNotEmpty()) { if (currentDayForecasts.isNotEmpty()) {
dailyForecasts.add(DailyForecast( dailyForecasts.add(
DailyForecast(
timestamp = currentDayForecasts.first().timestamp, timestamp = currentDayForecasts.first().timestamp,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature ?: 0.0, minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature ?: 0.0, ?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature
?: 0.0,
hourlyForecasts = currentDayForecasts hourlyForecasts = currentDayForecasts
)) )
)
} }
return dailyForecasts return dailyForecasts
} }
@ -63,8 +75,8 @@ class WeatherRepository(
val provider = WeatherProvider.getInstance(context) ?: return val provider = WeatherProvider.getInstance(context) ?: return
if (provider.isUpdateRequired()) { if (provider.isUpdateRequired()) {
val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java) val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java)
.addTag("weather") .addTag("weather")
.build() .build()
WorkManager.getInstance(context).enqueue(weatherRequest) WorkManager.getInstance(context).enqueue(weatherRequest)
} else { } else {
Log.d("MM20", "No weather update required") 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 { override suspend fun doWork(): Result {
val provider = WeatherProvider.getInstance(context) ?: return Result.failure() val provider = WeatherProvider.getInstance(context) ?: return Result.failure()
if (!provider.isAvailable()) return Result.failure() if (!provider.isAvailable()) return Result.failure()
@ -83,7 +96,8 @@ class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : Coro
Result.retry() Result.retry()
} else { } else {
Log.d("MM20", "Weather update succeeded") 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() Result.success()
} }
} }

View File

@ -4,19 +4,17 @@ import android.app.Application
import android.content.Context import android.content.Context
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData 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 { init {
requestUpdate(application) requestUpdate(application)
} }
val forecasts: LiveData<List<DailyForecast>> by lazy {
repository.forecasts
}
fun requestUpdate(context: Context) { fun requestUpdate(context: Context) {
repository.requestUpdate(context) repository.requestUpdate(context)
} }