diff --git a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index f7a31567..5a6a4986 100644 --- a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -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 diff --git a/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt b/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt index 9bd7bfc7..1cf80871 100644 --- a/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt +++ b/database/src/main/java/de/mm20/launcher2/database/WeatherDao.kt @@ -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> + fun getForecasts(): Flow> @Insert(onConflict = REPLACE) fun insertAll(forecasts: List) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/widget/WeatherWidget.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt similarity index 71% rename from ui/src/main/java/de/mm20/launcher2/ui/widget/WeatherWidget.kt rename to ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt index d69ccee9..17b8a053 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/widget/WeatherWidget.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt @@ -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, 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) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetWM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetWM.kt new file mode 100644 index 00000000..7db3198b --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetWM.kt @@ -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 = emptyList() + set(value) { + field = value + selectedDayIndex = 0 + selectedForecastIndex = 0 + dailyForecasts.postValue(value) + } + + init { + viewModelScope.launch { + forecastsFlow.collectLatest { + forecasts = it + } + } + } + + val currentForecast = MutableLiveData(getCurrentlySelectedForecast()) + val dailyForecasts = MutableLiveData>(emptyList()) + val currentDayForecasts = MutableLiveData>(emptyList()) + val currentDailyForecast = MutableLiveData(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) + } +} \ No newline at end of file diff --git a/weather/build.gradle.kts b/weather/build.gradle.kts index 0df87ef6..f1fdf3ca 100644 --- a/weather/build.gradle.kts +++ b/weather/build.gradle.kts @@ -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")) diff --git a/weather/src/main/java/de/mm20/launcher2/weather/Module.kt b/weather/src/main/java/de/mm20/launcher2/weather/Module.kt new file mode 100644 index 00000000..8db832d0 --- /dev/null +++ b/weather/src/main/java/de/mm20/launcher2/weather/Module.kt @@ -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()) } +} \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt b/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt index f17ba38a..59af3e49 100644 --- a/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt +++ b/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt @@ -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>() - - 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): List { + private fun groupForecastsPerDay(forecasts: List): List { val dailyForecasts = mutableListOf() 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() } } diff --git a/weather/src/main/java/de/mm20/launcher2/weather/WeatherViewModel.kt b/weather/src/main/java/de/mm20/launcher2/weather/WeatherViewModel.kt index b2f82c05..94d1fd7b 100644 --- a/weather/src/main/java/de/mm20/launcher2/weather/WeatherViewModel.kt +++ b/weather/src/main/java/de/mm20/launcher2/weather/WeatherViewModel.kt @@ -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> by lazy { - repository.forecasts - } - fun requestUpdate(context: Context) { repository.requestUpdate(context) }