diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9cc4c6..567f0ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,11 +13,11 @@ val gitVersionName = providers.exec { }.standardOutput.asText.map { it.trim().removePrefix("v").ifEmpty { "0.0.0" } } android { - namespace = "com.wassupluke.simpleweather" + namespace = "com.wassupluke.widgets" compileSdk = 36 defaultConfig { - applicationId = "com.wassupluke.simpleweather" + applicationId = "com.wassupluke.widgets" minSdk = 26 targetSdk = 36 versionCode = gitVersionCode.get() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6fb6cc4..d3def14 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -30,7 +31,8 @@ + android:exported="true" + android:label="@string/widget_temperature_label"> @@ -39,5 +41,21 @@ android:resource="@xml/weather_widget_info" /> + + + + + + + + + + + diff --git a/app/src/main/java/com/wassupluke/simpleweather/data/WeatherDataStore.kt b/app/src/main/java/com/wassupluke/widgets/data/WeatherDataStore.kt similarity index 87% rename from app/src/main/java/com/wassupluke/simpleweather/data/WeatherDataStore.kt rename to app/src/main/java/com/wassupluke/widgets/data/WeatherDataStore.kt index f48a592..a33ba4d 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/data/WeatherDataStore.kt +++ b/app/src/main/java/com/wassupluke/widgets/data/WeatherDataStore.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.data +package com.wassupluke.widgets.data import android.content.Context import android.graphics.Color as AndroidColor @@ -38,7 +38,7 @@ fun parseColorSafe(colorString: String): Int? { val Context.dataStore: DataStore by preferencesDataStore(name = "weather_settings") object WeatherDataStore { - const val DEFAULT_TEMP_UNIT = "C" + const val DEFAULT_TEMP_UNIT = "F" const val DEFAULT_INTERVAL_MINUTES = 60 val USE_DEVICE_LOCATION = booleanPreferencesKey("use_device_location") @@ -53,4 +53,9 @@ object WeatherDataStore { val WIDGET_TEXT_COLOR = stringPreferencesKey("widget_text_color") val WIDGET_TAP_PACKAGE = stringPreferencesKey("widget_tap_package") val WIDGET_DYNAMIC_COLOR = booleanPreferencesKey("widget_dynamic_color") + val FONT_SIZE = intPreferencesKey("font_size") + val ALARM_WIDGET_TAP_PACKAGE = stringPreferencesKey("alarm_widget_tap_package") + val ALARM_TEXT = stringPreferencesKey("alarm_text") + + const val DEFAULT_FONT_SIZE = 48 } diff --git a/app/src/main/java/com/wassupluke/simpleweather/data/WeatherRepository.kt b/app/src/main/java/com/wassupluke/widgets/data/WeatherRepository.kt similarity index 93% rename from app/src/main/java/com/wassupluke/simpleweather/data/WeatherRepository.kt rename to app/src/main/java/com/wassupluke/widgets/data/WeatherRepository.kt index 6750793..0725ad6 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/data/WeatherRepository.kt +++ b/app/src/main/java/com/wassupluke/widgets/data/WeatherRepository.kt @@ -1,10 +1,10 @@ -package com.wassupluke.simpleweather.data +package com.wassupluke.widgets.data import android.content.Context import android.location.Geocoder import androidx.datastore.preferences.core.edit -import com.wassupluke.simpleweather.data.api.NetworkModule -import com.wassupluke.simpleweather.data.api.OpenMeteoService +import com.wassupluke.widgets.data.api.NetworkModule +import com.wassupluke.widgets.data.api.OpenMeteoService import java.util.Locale class WeatherRepository( diff --git a/app/src/main/java/com/wassupluke/simpleweather/data/api/NetworkModule.kt b/app/src/main/java/com/wassupluke/widgets/data/api/NetworkModule.kt similarity index 95% rename from app/src/main/java/com/wassupluke/simpleweather/data/api/NetworkModule.kt rename to app/src/main/java/com/wassupluke/widgets/data/api/NetworkModule.kt index 8044239..25f48f6 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/data/api/NetworkModule.kt +++ b/app/src/main/java/com/wassupluke/widgets/data/api/NetworkModule.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.data.api +package com.wassupluke.widgets.data.api import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.serialization.json.Json diff --git a/app/src/main/java/com/wassupluke/simpleweather/data/api/OpenMeteoService.kt b/app/src/main/java/com/wassupluke/widgets/data/api/OpenMeteoService.kt similarity index 93% rename from app/src/main/java/com/wassupluke/simpleweather/data/api/OpenMeteoService.kt rename to app/src/main/java/com/wassupluke/widgets/data/api/OpenMeteoService.kt index 595a298..333ea25 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/data/api/OpenMeteoService.kt +++ b/app/src/main/java/com/wassupluke/widgets/data/api/OpenMeteoService.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.data.api +package com.wassupluke.widgets.data.api import retrofit2.http.GET import retrofit2.http.Query diff --git a/app/src/main/java/com/wassupluke/simpleweather/data/api/WeatherApiModels.kt b/app/src/main/java/com/wassupluke/widgets/data/api/WeatherApiModels.kt similarity index 92% rename from app/src/main/java/com/wassupluke/simpleweather/data/api/WeatherApiModels.kt rename to app/src/main/java/com/wassupluke/widgets/data/api/WeatherApiModels.kt index fc077de..da45c64 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/data/api/WeatherApiModels.kt +++ b/app/src/main/java/com/wassupluke/widgets/data/api/WeatherApiModels.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.data.api +package com.wassupluke.widgets.data.api import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/wassupluke/simpleweather/ui/MainActivity.kt b/app/src/main/java/com/wassupluke/widgets/ui/MainActivity.kt similarity index 84% rename from app/src/main/java/com/wassupluke/simpleweather/ui/MainActivity.kt rename to app/src/main/java/com/wassupluke/widgets/ui/MainActivity.kt index 83dfa9c..e25a34f 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/ui/MainActivity.kt +++ b/app/src/main/java/com/wassupluke/widgets/ui/MainActivity.kt @@ -1,24 +1,25 @@ -package com.wassupluke.simpleweather.ui +package com.wassupluke.widgets.ui import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.os.Bundle -import androidx.core.content.ContextCompat import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.wassupluke.simpleweather.data.WeatherDataStore -import com.wassupluke.simpleweather.data.WeatherRepository -import com.wassupluke.simpleweather.data.dataStore -import com.wassupluke.simpleweather.ui.settings.SettingsScreen -import com.wassupluke.simpleweather.ui.settings.SettingsViewModel -import com.wassupluke.simpleweather.ui.theme.SimpleWeatherTheme -import com.wassupluke.simpleweather.worker.WorkScheduler -import com.google.android.gms.location.LocationServices import androidx.lifecycle.lifecycleScope +import com.google.android.gms.location.LocationServices +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.WeatherRepository +import com.wassupluke.widgets.data.dataStore +import com.wassupluke.widgets.ui.settings.SettingsScreen +import com.wassupluke.widgets.ui.settings.SettingsViewModel +import com.wassupluke.widgets.ui.theme.SimpleWeatherTheme +import com.wassupluke.widgets.worker.WorkScheduler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() setContent { SimpleWeatherTheme { diff --git a/app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsScreen.kt b/app/src/main/java/com/wassupluke/widgets/ui/settings/SettingsScreen.kt similarity index 68% rename from app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsScreen.kt rename to app/src/main/java/com/wassupluke/widgets/ui/settings/SettingsScreen.kt index e32df8d..ecd6ed7 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/wassupluke/widgets/ui/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.ui.settings +package com.wassupluke.widgets.ui.settings import android.Manifest import android.content.Intent @@ -35,9 +35,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.wassupluke.simpleweather.R -import com.wassupluke.simpleweather.data.parseColorSafe -import com.wassupluke.simpleweather.ui.theme.SimpleWeatherTheme +import com.wassupluke.widgets.R +import com.wassupluke.widgets.data.parseColorSafe +import com.wassupluke.widgets.ui.theme.SimpleWeatherTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -66,6 +66,8 @@ internal fun SettingsScreenContent( onSetWidgetTextColor: (String) -> Unit, onSetWidgetTapPackage: (String) -> Unit, onSetWidgetDynamicColor: (Boolean) -> Unit, + onSetFontSize: (Int) -> Unit, + onSetAlarmWidgetTapPackage: (String) -> Unit, ) { var locationInput by remember { mutableStateOf("") } var locationInputInitialized by remember { mutableStateOf(false) } @@ -77,6 +79,7 @@ internal fun SettingsScreenContent( } var showAppPicker by remember { mutableStateOf(false) } + var showAlarmAppPicker by remember { mutableStateOf(false) } val context = LocalContext.current val installedApps by produceState(emptyList()) { @@ -117,7 +120,7 @@ internal fun SettingsScreenContent( ) { Spacer(Modifier.height(8.dp)) - Text(stringResource(R.string.title_location), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.settings_location_title), style = MaterialTheme.typography.titleSmall) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -127,7 +130,7 @@ internal fun SettingsScreenContent( else onDisableDeviceLocation() } ) { - Text(stringResource(R.string.label_use_device_location), modifier = Modifier.weight(1f)) + Text(stringResource(R.string.settings_location_device_label), modifier = Modifier.weight(1f)) Switch( checked = uiState.useDeviceLocation, onCheckedChange = { use -> @@ -140,7 +143,7 @@ internal fun SettingsScreenContent( OutlinedTextField( value = locationInput, onValueChange = { locationInput = it }, - label = { Text(stringResource(R.string.hint_location_input)) }, + label = { Text(stringResource(R.string.settings_location_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), @@ -164,7 +167,7 @@ internal fun SettingsScreenContent( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text(stringResource(R.string.title_temperature_unit), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.settings_temperature_unit_title), style = MaterialTheme.typography.titleSmall) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { listOf("C", "F").forEachIndexed { index, unit -> SegmentedButton( @@ -178,17 +181,17 @@ internal fun SettingsScreenContent( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text(stringResource(R.string.title_update_interval), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.settings_update_interval_title), style = MaterialTheme.typography.titleSmall) val intervalOptions = listOf( - 15 to stringResource(R.string.interval_15min), - 30 to stringResource(R.string.interval_30min), - 60 to stringResource(R.string.interval_1hr), - 180 to stringResource(R.string.interval_3hr), - 360 to stringResource(R.string.interval_6hr) + 15 to stringResource(R.string.settings_interval_15min), + 30 to stringResource(R.string.settings_interval_30min), + 60 to stringResource(R.string.settings_interval_1hr), + 180 to stringResource(R.string.settings_interval_3hr), + 360 to stringResource(R.string.settings_interval_6hr) ) var intervalExpanded by remember { mutableStateOf(false) } - val selectedLabel = intervalOptions.firstOrNull { it.first == uiState.updateIntervalMinutes }?.second ?: stringResource(R.string.interval_1hr) + val selectedLabel = intervalOptions.firstOrNull { it.first == uiState.updateIntervalMinutes }?.second ?: stringResource(R.string.settings_interval_1hr) ExposedDropdownMenuBox( expanded = intervalExpanded, @@ -198,7 +201,7 @@ internal fun SettingsScreenContent( value = selectedLabel, onValueChange = {}, readOnly = true, - label = { Text(stringResource(R.string.label_interval)) }, + label = { Text(stringResource(R.string.settings_interval_label)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = intervalExpanded) }, modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) @@ -229,7 +232,7 @@ internal fun SettingsScreenContent( .fillMaxWidth() .clickable { onSetWidgetDynamicColor(!uiState.widgetDynamicColor) } ) { - Text(stringResource(R.string.label_dynamic_color), modifier = Modifier.weight(1f)) + Text(stringResource(R.string.settings_dynamic_color_label), modifier = Modifier.weight(1f)) Switch( checked = uiState.widgetDynamicColor, onCheckedChange = { onSetWidgetDynamicColor(it) } @@ -238,7 +241,7 @@ internal fun SettingsScreenContent( } if (!uiState.widgetDynamicColor) { - Text(stringResource(R.string.title_widget_text_color), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.settings_text_color_title), style = MaterialTheme.typography.titleSmall) var colorInput by remember { mutableStateOf(uiState.widgetTextColor) } LaunchedEffect(uiState.widgetTextColor) { colorInput = uiState.widgetTextColor } @@ -257,8 +260,8 @@ internal fun SettingsScreenContent( OutlinedTextField( value = colorInput, onValueChange = { colorInput = it }, - label = { Text(stringResource(R.string.label_text_color)) }, - placeholder = { Text(stringResource(R.string.hint_color_input)) }, + label = { Text(stringResource(R.string.settings_text_color_label)) }, + placeholder = { Text(stringResource(R.string.settings_color_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), @@ -282,7 +285,7 @@ internal fun SettingsScreenContent( if (previewColor == null && uiState.widgetTextColor.isNotEmpty()) { Text( - text = stringResource(R.string.error_invalid_color), + text = stringResource(R.string.settings_color_error), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -291,8 +294,24 @@ internal fun SettingsScreenContent( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text(stringResource(R.string.settings_font_size_title), style = MaterialTheme.typography.titleSmall) + Slider( + value = uiState.fontSize.toFloat(), + onValueChange = { onSetFontSize(it.toInt()) }, + valueRange = 12f..96f, + steps = 83, + modifier = Modifier.fillMaxWidth() + ) Text( - stringResource(R.string.title_widget_tap_action), + text = "${uiState.fontSize}sp", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + stringResource(R.string.settings_weather_tap_title), style = MaterialTheme.typography.titleSmall ) @@ -337,14 +356,14 @@ internal fun SettingsScreenContent( } else if (uiState.widgetTapPackage.isNotEmpty()) { Spacer(Modifier.size(40.dp).padding(end = 8.dp)) Text( - text = stringResource(R.string.label_selected_app_not_found), + text = stringResource(R.string.settings_tap_app_missing_label), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.error ) } else { Spacer(Modifier.size(40.dp).padding(end = 8.dp)) Text( - text = stringResource(R.string.label_widget_tap_none), + text = stringResource(R.string.settings_tap_none_label), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -372,7 +391,7 @@ internal fun SettingsScreenContent( ) { Spacer(Modifier.size(40.dp).padding(end = 12.dp)) Text( - text = stringResource(R.string.label_widget_tap_none), + text = stringResource(R.string.settings_tap_none_label), modifier = Modifier.weight(1f) ) if (uiState.widgetTapPackage.isEmpty()) { @@ -418,6 +437,132 @@ internal fun SettingsScreenContent( } } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text(stringResource(R.string.settings_alarm_tap_title), style = MaterialTheme.typography.titleSmall) + + val selectedAlarmAppInfo = remember(uiState.alarmWidgetTapPackage) { + if (uiState.alarmWidgetTapPackage.isEmpty()) null + else runCatching { + context.packageManager.getApplicationInfo(uiState.alarmWidgetTapPackage, 0) + }.getOrNull() + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { showAlarmAppPicker = true } + .padding(vertical = 8.dp) + ) { + if (selectedAlarmAppInfo != null) { + val icon by produceState(null, uiState.alarmWidgetTapPackage) { + value = withContext(Dispatchers.IO) { + runCatching { + context.packageManager + .getApplicationIcon(uiState.alarmWidgetTapPackage) + .toBitmap() + .asImageBitmap() + }.getOrNull() + } + } + if (icon != null) { + Image( + bitmap = icon!!, + contentDescription = null, + modifier = Modifier.size(40.dp).padding(end = 8.dp) + ) + } else { + Spacer(Modifier.size(40.dp).padding(end = 8.dp)) + } + Text( + text = selectedAlarmAppInfo.loadLabel(context.packageManager).toString(), + modifier = Modifier.weight(1f) + ) + } else if (uiState.alarmWidgetTapPackage.isNotEmpty()) { + Spacer(Modifier.size(40.dp).padding(end = 8.dp)) + Text( + text = stringResource(R.string.settings_tap_app_missing_label), + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.error + ) + } else { + Spacer(Modifier.size(40.dp).padding(end = 8.dp)) + Text( + text = stringResource(R.string.settings_tap_none_label), + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (showAlarmAppPicker) { + ModalBottomSheet(onDismissRequest = { showAlarmAppPicker = false }) { + LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSetAlarmWidgetTapPackage("") + showAlarmAppPicker = false + } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Spacer(Modifier.size(40.dp).padding(end = 12.dp)) + Text( + text = stringResource(R.string.settings_tap_none_label), + modifier = Modifier.weight(1f) + ) + if (uiState.alarmWidgetTapPackage.isEmpty()) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + items(installedApps) { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSetAlarmWidgetTapPackage(entry.pkg) + showAlarmAppPicker = false + } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + if (entry.icon != null) { + Image( + bitmap = entry.icon, + contentDescription = null, + modifier = Modifier.size(40.dp).padding(end = 12.dp) + ) + } else { + Spacer(Modifier.size(40.dp).padding(end = 12.dp)) + } + Text(entry.label, modifier = Modifier.weight(1f)) + if (entry.pkg == uiState.alarmWidgetTapPackage) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + Spacer(Modifier.height(16.dp)) } } @@ -449,6 +594,8 @@ fun SettingsScreen( onSetWidgetTextColor = { viewModel.setWidgetTextColor(it) }, onSetWidgetTapPackage = { viewModel.setWidgetTapPackage(it) }, onSetWidgetDynamicColor = { viewModel.setWidgetDynamicColor(it) }, + onSetFontSize = { viewModel.setFontSize(it) }, + onSetAlarmWidgetTapPackage = { viewModel.setAlarmWidgetTapPackage(it) }, ) } @@ -476,6 +623,8 @@ private fun SettingsScreenEmptyPreview() { onSetWidgetTextColor = {}, onSetWidgetTapPackage = {}, onSetWidgetDynamicColor = {}, + onSetFontSize = {}, + onSetAlarmWidgetTapPackage = {}, ) } } @@ -497,6 +646,8 @@ private fun SettingsScreenDeviceLocationPreview() { onSetWidgetTextColor = {}, onSetWidgetTapPackage = {}, onSetWidgetDynamicColor = {}, + onSetFontSize = {}, + onSetAlarmWidgetTapPackage = {}, ) } } @@ -522,6 +673,8 @@ private fun SettingsScreenManualLocationPreview() { onSetWidgetTextColor = {}, onSetWidgetTapPackage = {}, onSetWidgetDynamicColor = {}, + onSetFontSize = {}, + onSetAlarmWidgetTapPackage = {}, ) } } diff --git a/app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/wassupluke/widgets/ui/settings/SettingsViewModel.kt similarity index 80% rename from app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModel.kt rename to app/src/main/java/com/wassupluke/widgets/ui/settings/SettingsViewModel.kt index 8a34e80..2b90793 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/wassupluke/widgets/ui/settings/SettingsViewModel.kt @@ -1,16 +1,17 @@ -package com.wassupluke.simpleweather.ui.settings +package com.wassupluke.widgets.ui.settings import android.app.Application import androidx.datastore.preferences.core.edit import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.wassupluke.simpleweather.data.WeatherDataStore -import com.wassupluke.simpleweather.data.WeatherRepository -import com.wassupluke.simpleweather.data.dataStore -import com.wassupluke.simpleweather.data.resolveDynamicColor +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.WeatherRepository +import com.wassupluke.widgets.data.dataStore +import com.wassupluke.widgets.data.resolveDynamicColor import androidx.glance.appwidget.updateAll -import com.wassupluke.simpleweather.widget.WeatherWidget -import com.wassupluke.simpleweather.worker.WorkScheduler +import com.wassupluke.widgets.widget.AlarmWidget +import com.wassupluke.widgets.widget.WeatherWidget +import com.wassupluke.widgets.worker.WorkScheduler import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* @@ -25,7 +26,9 @@ data class SettingsUiState( val updateIntervalMinutes: Int = WeatherDataStore.DEFAULT_INTERVAL_MINUTES, val widgetTextColor: String = "white", val widgetDynamicColor: Boolean = false, - val widgetTapPackage: String = "" + val widgetTapPackage: String = "", + val fontSize: Int = WeatherDataStore.DEFAULT_FONT_SIZE, + val alarmWidgetTapPackage: String = "" ) class SettingsViewModel( @@ -44,7 +47,9 @@ class SettingsViewModel( updateIntervalMinutes = prefs[WeatherDataStore.UPDATE_INTERVAL_MINUTES] ?: WeatherDataStore.DEFAULT_INTERVAL_MINUTES, widgetTextColor = prefs[WeatherDataStore.WIDGET_TEXT_COLOR] ?: "white", widgetDynamicColor = prefs.resolveDynamicColor(), - widgetTapPackage = prefs[WeatherDataStore.WIDGET_TAP_PACKAGE] ?: "" + widgetTapPackage = prefs[WeatherDataStore.WIDGET_TAP_PACKAGE] ?: "", + fontSize = prefs[WeatherDataStore.FONT_SIZE] ?: WeatherDataStore.DEFAULT_FONT_SIZE, + alarmWidgetTapPackage = prefs[WeatherDataStore.ALARM_WIDGET_TAP_PACKAGE] ?: "" ) } @@ -70,6 +75,7 @@ class SettingsViewModel( viewModelScope.launch(dispatcher) { context.dataStore.edit { it[WeatherDataStore.WIDGET_TEXT_COLOR] = raw } WeatherWidget().updateAll(context) + AlarmWidget().updateAll(context) } } @@ -77,6 +83,7 @@ class SettingsViewModel( viewModelScope.launch(dispatcher) { context.dataStore.edit { it[WeatherDataStore.WIDGET_DYNAMIC_COLOR] = enabled } WeatherWidget().updateAll(context) + AlarmWidget().updateAll(context) } } @@ -87,6 +94,21 @@ class SettingsViewModel( } } + fun setFontSize(size: Int) { + viewModelScope.launch(dispatcher) { + context.dataStore.edit { it[WeatherDataStore.FONT_SIZE] = size } + WeatherWidget().updateAll(context) + AlarmWidget().updateAll(context) + } + } + + fun setAlarmWidgetTapPackage(pkg: String) { + viewModelScope.launch(dispatcher) { + context.dataStore.edit { it[WeatherDataStore.ALARM_WIDGET_TAP_PACKAGE] = pkg } + AlarmWidget().updateAll(context) + } + } + fun setUseDeviceLocation(use: Boolean) { viewModelScope.launch(dispatcher) { context.dataStore.edit { it[WeatherDataStore.USE_DEVICE_LOCATION] = use } diff --git a/app/src/main/java/com/wassupluke/simpleweather/ui/theme/SimpleWeatherTheme.kt b/app/src/main/java/com/wassupluke/widgets/ui/theme/SimpleWeatherTheme.kt similarity index 52% rename from app/src/main/java/com/wassupluke/simpleweather/ui/theme/SimpleWeatherTheme.kt rename to app/src/main/java/com/wassupluke/widgets/ui/theme/SimpleWeatherTheme.kt index a530558..9569f95 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/ui/theme/SimpleWeatherTheme.kt +++ b/app/src/main/java/com/wassupluke/widgets/ui/theme/SimpleWeatherTheme.kt @@ -1,11 +1,16 @@ -package com.wassupluke.simpleweather.ui.theme +package com.wassupluke.widgets.ui.theme +import android.app.Activity import android.os.Build import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat @Composable fun SimpleWeatherTheme(content: @Composable () -> Unit) { @@ -16,6 +21,15 @@ fun SimpleWeatherTheme(content: @Composable () -> Unit) { lightColorScheme() } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + // Set status bar icons to dark since we are using a light theme + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true + } + } + MaterialTheme( colorScheme = colorScheme, content = content diff --git a/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt b/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt new file mode 100644 index 0000000..7e6a3ac --- /dev/null +++ b/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt @@ -0,0 +1,98 @@ +package com.wassupluke.widgets.widget + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.emptyPreferences +import androidx.compose.ui.unit.dp +import androidx.glance.* +import androidx.glance.action.Action +import androidx.glance.action.actionStartActivity +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.GlanceTheme +import androidx.glance.text.* +import androidx.glance.unit.ColorProvider +import com.wassupluke.widgets.R +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.dataStore +import com.wassupluke.widgets.data.parseColorSafe +import com.wassupluke.widgets.data.resolveDynamicColor +import com.wassupluke.widgets.ui.MainActivity + +@SuppressLint("RestrictedApi") +class AlarmWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + val prefs by context.dataStore.data.collectAsState(initial = emptyPreferences()) + val alarmText = prefs[WeatherDataStore.ALARM_TEXT] ?: context.getString(R.string.widget_alarm_none) + val colorString = prefs[WeatherDataStore.WIDGET_TEXT_COLOR] ?: "white" + val dynamicColor = prefs.resolveDynamicColor() + val fontSize = prefs[WeatherDataStore.FONT_SIZE] ?: WeatherDataStore.DEFAULT_FONT_SIZE + + val textColorProvider: ColorProvider = if (dynamicColor) { + GlanceTheme.colors.primary + } else { + val argb = parseColorSafe(colorString) ?: android.graphics.Color.WHITE + ColorProvider(Color(argb)) + } + + val tapPackage = prefs[WeatherDataStore.ALARM_WIDGET_TAP_PACKAGE] + val tapAction: Action = if (!tapPackage.isNullOrEmpty()) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(tapPackage) + if (launchIntent?.component != null) actionStartActivity(launchIntent.component!!) + else actionStartActivity() + } else { + actionStartActivity() + } + + AlarmWidgetContent( + alarmText = alarmText, + textColorProvider = textColorProvider, + tapAction = tapAction, + fontSize = fontSize + ) + } + } + } +} + +@SuppressLint("RestrictedApi") +@Composable +private fun AlarmWidgetContent( + alarmText: String, + textColorProvider: ColorProvider, + tapAction: Action, + fontSize: Int +) { + WidgetRoot(tapAction = tapAction) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + provider = ImageProvider(R.drawable.ic_alarm), + contentDescription = null, + modifier = GlanceModifier.size(fontSize.dp), + colorFilter = ColorFilter.tint(textColorProvider) + ) + Spacer(GlanceModifier.width(4.dp)) + Text( + text = alarmText, + style = TextStyle( + fontSize = fontSize.sp, + fontWeight = FontWeight.Normal, + color = textColorProvider + ) + ) + } + } +} diff --git a/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidgetReceiver.kt b/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidgetReceiver.kt new file mode 100644 index 0000000..ed7f302 --- /dev/null +++ b/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidgetReceiver.kt @@ -0,0 +1,54 @@ +package com.wassupluke.widgets.widget + +import android.app.AlarmManager +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.datastore.preferences.core.edit +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.updateAll +import com.wassupluke.widgets.R +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.dataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +class AlarmWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = AlarmWidget() + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + when (intent.action) { + AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED, + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + AppWidgetManager.ACTION_APPWIDGET_UPDATE -> updateAlarmText(context, intent.action ?: "") + } + } + + private fun updateAlarmText(context: Context, action: String) { + // APPWIDGET_UPDATE: super already called goAsync() internally — calling it again returns null. + // For all other broadcasts, goAsync() is available and needed to keep the process alive. + val pendingResult = if (action != AppWidgetManager.ACTION_APPWIDGET_UPDATE) goAsync() else null + CoroutineScope(Dispatchers.IO).launch { + try { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val nextAlarm = alarmManager.nextAlarmClock + val alarmText = if (nextAlarm == null) { + context.getString(R.string.widget_alarm_none) + } else { + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(nextAlarm.triggerTime)) + } + context.dataStore.edit { it[WeatherDataStore.ALARM_TEXT] = alarmText } + AlarmWidget().updateAll(context) + } finally { + pendingResult?.finish() + } + } + } +} diff --git a/app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidget.kt b/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt similarity index 70% rename from app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidget.kt rename to app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt index 41bb317..595515d 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidget.kt +++ b/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.widget +package com.wassupluke.widgets.widget import android.annotation.SuppressLint import android.content.Context @@ -11,20 +11,19 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.glance.* import androidx.glance.action.Action import androidx.glance.action.actionStartActivity -import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.provideContent -import androidx.glance.layout.* import androidx.glance.GlanceTheme import androidx.glance.text.* import androidx.glance.unit.ColorProvider -import com.wassupluke.simpleweather.data.WeatherDataStore -import com.wassupluke.simpleweather.data.dataStore -import com.wassupluke.simpleweather.data.parseColorSafe -import com.wassupluke.simpleweather.data.resolveDynamicColor -import com.wassupluke.simpleweather.ui.MainActivity +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.dataStore +import com.wassupluke.widgets.data.parseColorSafe +import com.wassupluke.widgets.data.resolveDynamicColor +import com.wassupluke.widgets.ui.MainActivity import kotlin.math.roundToInt +@SuppressLint("RestrictedApi") class WeatherWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { @@ -32,7 +31,7 @@ class WeatherWidget : GlanceAppWidget() { GlanceTheme { val prefs by context.dataStore.data.collectAsState(initial = emptyPreferences()) val tempCelsius = prefs[WeatherDataStore.LAST_TEMP_CELSIUS] - val unit = prefs[WeatherDataStore.TEMP_UNIT] ?: "C" + val unit = prefs[WeatherDataStore.TEMP_UNIT] ?: WeatherDataStore.DEFAULT_TEMP_UNIT val colorString = prefs[WeatherDataStore.WIDGET_TEXT_COLOR] ?: "white" val dynamicColor = prefs.resolveDynamicColor() @@ -46,15 +45,8 @@ class WeatherWidget : GlanceAppWidget() { val textColorProvider: ColorProvider = if (dynamicColor) { GlanceTheme.colors.primary } else { - val resolved = parseColorSafe(colorString)?.let { argb -> - Color( - red = android.graphics.Color.red(argb) / 255f, - green = android.graphics.Color.green(argb) / 255f, - blue = android.graphics.Color.blue(argb) / 255f, - alpha = android.graphics.Color.alpha(argb) / 255f - ) - } ?: Color.White - ColorProvider(resolved) + val argb = parseColorSafe(colorString) ?: android.graphics.Color.WHITE + ColorProvider(Color(argb)) } val tapPackage = prefs[WeatherDataStore.WIDGET_TAP_PACKAGE] @@ -66,10 +58,13 @@ class WeatherWidget : GlanceAppWidget() { actionStartActivity() } + val fontSize = prefs[WeatherDataStore.FONT_SIZE] ?: WeatherDataStore.DEFAULT_FONT_SIZE + WeatherWidgetContent( displayTemp = displayTemp, textColorProvider = textColorProvider, - tapAction = tapAction + tapAction = tapAction, + fontSize = fontSize ) } } @@ -84,18 +79,14 @@ class WeatherWidget : GlanceAppWidget() { private fun WeatherWidgetContent( displayTemp: String, textColorProvider: ColorProvider, - tapAction: Action + tapAction: Action, + fontSize: Int ) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .clickable(tapAction), - contentAlignment = Alignment.Center - ) { + WidgetRoot(tapAction = tapAction) { Text( text = displayTemp, style = TextStyle( - fontSize = 48.sp, + fontSize = fontSize.sp, fontWeight = FontWeight.Normal, color = textColorProvider ) diff --git a/app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidgetReceiver.kt b/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidgetReceiver.kt similarity index 84% rename from app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidgetReceiver.kt rename to app/src/main/java/com/wassupluke/widgets/widget/WeatherWidgetReceiver.kt index 3017bc7..48c0d6d 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidgetReceiver.kt +++ b/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidgetReceiver.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.widget +package com.wassupluke.widgets.widget import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver diff --git a/app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt b/app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt new file mode 100644 index 0000000..e3825e5 --- /dev/null +++ b/app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt @@ -0,0 +1,25 @@ +package com.wassupluke.widgets.widget + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize + +/** + * Standard widget scaffold: centers content and bounds the tap target to the content area, + * so empty widget surface space does not intercept touches. + */ +@Composable +fun WidgetRoot(tapAction: Action, content: @Composable () -> Unit) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box(modifier = GlanceModifier.clickable(tapAction)) { + content() + } + } +} diff --git a/app/src/main/java/com/wassupluke/simpleweather/worker/WeatherFetchWorker.kt b/app/src/main/java/com/wassupluke/widgets/worker/WeatherFetchWorker.kt similarity index 76% rename from app/src/main/java/com/wassupluke/simpleweather/worker/WeatherFetchWorker.kt rename to app/src/main/java/com/wassupluke/widgets/worker/WeatherFetchWorker.kt index f174fd5..9fb6195 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/worker/WeatherFetchWorker.kt +++ b/app/src/main/java/com/wassupluke/widgets/worker/WeatherFetchWorker.kt @@ -1,13 +1,13 @@ -package com.wassupluke.simpleweather.worker +package com.wassupluke.widgets.worker import android.content.Context import androidx.glance.appwidget.updateAll import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import com.wassupluke.simpleweather.data.WeatherDataStore -import com.wassupluke.simpleweather.data.WeatherRepository -import com.wassupluke.simpleweather.data.dataStore -import com.wassupluke.simpleweather.widget.WeatherWidget +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.WeatherRepository +import com.wassupluke.widgets.data.dataStore +import com.wassupluke.widgets.widget.WeatherWidget import kotlinx.coroutines.flow.first class WeatherFetchWorker( diff --git a/app/src/main/java/com/wassupluke/simpleweather/worker/WorkScheduler.kt b/app/src/main/java/com/wassupluke/widgets/worker/WorkScheduler.kt similarity index 95% rename from app/src/main/java/com/wassupluke/simpleweather/worker/WorkScheduler.kt rename to app/src/main/java/com/wassupluke/widgets/worker/WorkScheduler.kt index f13af77..f0a22b2 100644 --- a/app/src/main/java/com/wassupluke/simpleweather/worker/WorkScheduler.kt +++ b/app/src/main/java/com/wassupluke/widgets/worker/WorkScheduler.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.worker +package com.wassupluke.widgets.worker import android.content.Context import androidx.work.* diff --git a/app/src/main/res/drawable/ic_alarm.xml b/app/src/main/res/drawable/ic_alarm.xml new file mode 100644 index 0000000..3335520 --- /dev/null +++ b/app/src/main/res/drawable/ic_alarm.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08b7a63..7cd93fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,30 +1,49 @@ - Simple Weather - Displays current temperature - - Location - Use device location - City, zip code, or lat,lon - - Temperature Unit - Update Interval - Widget Text Color - Dynamic color - Text color - #RRGGBB, #AARRGGBB, or name like red - Invalid color — enter a hex value or name like \"red\" - - Interval - 15 min - 30 min - 1 hr - 3 hr - 6 hr + Widgets - Set + + Temperature + Shows the current temperature on your home screen + Next Alarm + Shows your next scheduled alarm on your home screen + No alarm + + + Location + Use device location + City, zip code, or lat,lon + + + Temperature Unit + + + Update Interval + Interval + 15 min + 30 min + 1 hr + 3 hr + 6 hr + + + Dynamic color + Widget Text Color + Text color + #RRGGBB, #AARRGGBB, or name like red + Invalid color — enter a hex value or name like \"red\" + Widget font size - Widget tap action - None (opens Simple Weather) - Selected app not found + + Weather widget tap action + + + Alarm widget tap action + + + None (opens Widgets) + Selected app not found + + + Set diff --git a/app/src/main/res/xml/alarm_widget_info.xml b/app/src/main/res/xml/alarm_widget_info.xml new file mode 100644 index 0000000..ea916cc --- /dev/null +++ b/app/src/main/res/xml/alarm_widget_info.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/xml/weather_widget_info.xml b/app/src/main/res/xml/weather_widget_info.xml index 0425344..418c54d 100644 --- a/app/src/main/res/xml/weather_widget_info.xml +++ b/app/src/main/res/xml/weather_widget_info.xml @@ -6,5 +6,5 @@ android:targetCellWidth="2" android:targetCellHeight="1" android:updatePeriodMillis="0" - android:description="@string/widget_description" + android:description="@string/widget_temperature_description" android:resizeMode="horizontal|vertical" /> diff --git a/app/src/test/java/com/wassupluke/simpleweather/data/WeatherDataStoreTest.kt b/app/src/test/java/com/wassupluke/widgets/data/WeatherDataStoreTest.kt similarity index 97% rename from app/src/test/java/com/wassupluke/simpleweather/data/WeatherDataStoreTest.kt rename to app/src/test/java/com/wassupluke/widgets/data/WeatherDataStoreTest.kt index ce18898..88939a3 100644 --- a/app/src/test/java/com/wassupluke/simpleweather/data/WeatherDataStoreTest.kt +++ b/app/src/test/java/com/wassupluke/widgets/data/WeatherDataStoreTest.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.data +package com.wassupluke.widgets.data import android.content.Context import androidx.datastore.preferences.core.edit diff --git a/app/src/test/java/com/wassupluke/simpleweather/data/WeatherRepositoryTest.kt b/app/src/test/java/com/wassupluke/widgets/data/WeatherRepositoryTest.kt similarity index 97% rename from app/src/test/java/com/wassupluke/simpleweather/data/WeatherRepositoryTest.kt rename to app/src/test/java/com/wassupluke/widgets/data/WeatherRepositoryTest.kt index b9aa41b..8fbd929 100644 --- a/app/src/test/java/com/wassupluke/simpleweather/data/WeatherRepositoryTest.kt +++ b/app/src/test/java/com/wassupluke/widgets/data/WeatherRepositoryTest.kt @@ -1,9 +1,9 @@ -package com.wassupluke.simpleweather.data +package com.wassupluke.widgets.data import android.content.Context import androidx.datastore.preferences.core.edit import androidx.test.core.app.ApplicationProvider -import com.wassupluke.simpleweather.data.api.* +import com.wassupluke.widgets.data.api.* import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.flow.first diff --git a/app/src/test/java/com/wassupluke/simpleweather/data/api/OpenMeteoServiceTest.kt b/app/src/test/java/com/wassupluke/widgets/data/api/OpenMeteoServiceTest.kt similarity index 97% rename from app/src/test/java/com/wassupluke/simpleweather/data/api/OpenMeteoServiceTest.kt rename to app/src/test/java/com/wassupluke/widgets/data/api/OpenMeteoServiceTest.kt index 293db5f..31c6123 100644 --- a/app/src/test/java/com/wassupluke/simpleweather/data/api/OpenMeteoServiceTest.kt +++ b/app/src/test/java/com/wassupluke/widgets/data/api/OpenMeteoServiceTest.kt @@ -1,4 +1,4 @@ -package com.wassupluke.simpleweather.data.api +package com.wassupluke.widgets.data.api import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/wassupluke/widgets/ui/settings/SettingsViewModelTest.kt similarity index 78% rename from app/src/test/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModelTest.kt rename to app/src/test/java/com/wassupluke/widgets/ui/settings/SettingsViewModelTest.kt index 365aa29..1669a84 100644 --- a/app/src/test/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/wassupluke/widgets/ui/settings/SettingsViewModelTest.kt @@ -1,12 +1,12 @@ -package com.wassupluke.simpleweather.ui.settings +package com.wassupluke.widgets.ui.settings import android.app.Application import android.os.Build import androidx.datastore.preferences.core.edit import androidx.test.core.app.ApplicationProvider -import com.wassupluke.simpleweather.data.WeatherDataStore -import com.wassupluke.simpleweather.data.WeatherRepository -import com.wassupluke.simpleweather.data.dataStore +import com.wassupluke.widgets.data.WeatherDataStore +import com.wassupluke.widgets.data.WeatherRepository +import com.wassupluke.widgets.data.dataStore import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -138,4 +138,35 @@ class SettingsViewModelTest { val state = vm.uiState.filter { !it.widgetDynamicColor }.first() assertEquals(false, state.widgetDynamicColor) } + + @Test + fun `setFontSize writes to DataStore`() = runTest(testDispatcher) { + val vm = SettingsViewModel(application, mockRepository, testDispatcher) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.setFontSize(32) + advanceUntilIdle() + val state = vm.uiState.filter { it.fontSize == 32 }.first() + assertEquals(32, state.fontSize) + } + + @Test + fun `setAlarmWidgetTapPackage writes to DataStore`() = runTest(testDispatcher) { + val vm = SettingsViewModel(application, mockRepository, testDispatcher) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.setAlarmWidgetTapPackage("com.example.clock") + advanceUntilIdle() + val state = vm.uiState.filter { it.alarmWidgetTapPackage == "com.example.clock" }.first() + assertEquals("com.example.clock", state.alarmWidgetTapPackage) + } + + @Test + fun `fontSize defaults to 48`() = runTest(testDispatcher) { + val vm = SettingsViewModel(application, mockRepository, testDispatcher) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + val state = vm.uiState.filter { it.fontSize == 48 }.first() + assertEquals(48, state.fontSize) + } } diff --git a/docs/adding-a-widget.md b/docs/adding-a-widget.md new file mode 100644 index 0000000..f5f832a --- /dev/null +++ b/docs/adding-a-widget.md @@ -0,0 +1,123 @@ +# Adding a New Widget + +## String Resources (`res/values/strings.xml`) + +Follow the `_` convention. Each widget gets a label (shown in the picker) and a description (shown beneath it). + +```xml + +Human Name +Shows X on your home screen + + + widget tap action +``` + +Shared strings (`settings_tap_none_label`, `settings_tap_app_missing_label`, appearance keys) are reused — don't duplicate them. + +## Files to Create + +| File | Purpose | +|-|-| +| `widget/Widget.kt` | Glance widget UI | +| `widget/WidgetReceiver.kt` | `GlanceAppWidgetReceiver` + update trigger | +| `res/xml/_widget_info.xml` | Widget provider metadata | + +### `_widget_info.xml` + +```xml + + +``` + +### `Widget.kt` + +Mirror `WeatherWidget.kt` or `AlarmWidget.kt`. All widgets share these DataStore keys: +- `WIDGET_TEXT_COLOR` + `WIDGET_DYNAMIC_COLOR` + `resolveDynamicColor()` — color +- `FONT_SIZE` / `DEFAULT_FONT_SIZE` — font size +- Widget-specific tap key: `_WIDGET_TAP_PACKAGE` + +### `WidgetReceiver.kt` + +Extend `GlanceAppWidgetReceiver`. Override `onReceive` and call your update function for both `AppWidgetManager.ACTION_APPWIDGET_UPDATE` (first placement) and any domain-specific system broadcasts. + +Use `goAsync()` for any async work: + +```kotlin +private fun updateContent(context: Context) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + // read data, write to DataStore, call Widget().updateAll(context) + } finally { + pendingResult.finish() + } + } +} +``` + +## Files to Modify + +### `AndroidManifest.xml` + +```xml + + + + + + + +``` + +Add any required permissions (e.g. `RECEIVE_BOOT_COMPLETED`) if new broadcasts are needed. + +### `WeatherDataStore.kt` + +Add a tap-package key and any widget-specific cache keys: + +```kotlin +val _WIDGET_TAP_PACKAGE = stringPreferencesKey("_widget_tap_package") +val _DATA = stringPreferencesKey("_data") // if caching display text +``` + +### `SettingsViewModel.kt` + +Add to `SettingsUiState`: +```kotlin +val WidgetTapPackage: String = "" +``` + +Add to `prefsToUiState`: +```kotlin +WidgetTapPackage = prefs[WeatherDataStore._WIDGET_TAP_PACKAGE] ?: "" +``` + +Add method: +```kotlin +fun setWidgetTapPackage(pkg: String) { + viewModelScope.launch(dispatcher) { + context.dataStore.edit { it[WeatherDataStore._WIDGET_TAP_PACKAGE] = pkg } + Widget().updateAll(context) + } +} +``` + +Also add `Widget().updateAll(context)` to `setFontSize`, `setWidgetTextColor`, and `setWidgetDynamicColor` so shared appearance settings propagate to the new widget. + +### `SettingsScreen.kt` + +Add `onSetWidgetTapPackage: (String) -> Unit` parameter to `SettingsScreenContent`. Add a new section (after the existing alarm widget section) using `settings__tap_title` and reusing the existing app-picker pattern. diff --git a/docs/plans/2026-03-30-alarm-widget-design.md b/docs/plans/2026-03-30-alarm-widget-design.md new file mode 100644 index 0000000..9fd6271 --- /dev/null +++ b/docs/plans/2026-03-30-alarm-widget-design.md @@ -0,0 +1,111 @@ +# Alarm Widget + Global Font Size — Design + +**Date:** 2026-03-30 +**Status:** Approved + +## Overview + +Add a new home screen widget that displays the next scheduled system alarm. Extend the Settings page with a global font size slider that applies to all widgets. Each widget gets its own independent tap-target app setting. + +## DataStore Changes (`WeatherDataStore.kt`) + +New keys: + +| Key | Type | Default | Purpose | +|-|-|-|-| +| `FONT_SIZE` | `intPreferencesKey("font_size")` | 48 | Global font size for all widgets | +| `ALARM_WIDGET_TAP_PACKAGE` | `stringPreferencesKey("alarm_widget_tap_package")` | `""` | Tap target for alarm widget (empty = MainActivity) | +| `ALARM_TEXT` | `stringPreferencesKey("alarm_text")` | `"No alarm"` | Cached alarm display string | + +Migration: rename `WIDGET_TAP_PACKAGE` → `WEATHER_WIDGET_TAP_PACKAGE`. On first read, if old key exists, copy value to new key and clear old key. + +`WIDGET_TEXT_COLOR` and `WIDGET_DYNAMIC_COLOR` remain unchanged — already global. + +`WeatherWidget` hardcoded `48.sp` replaced with `FONT_SIZE` value. + +## New Files + +### `widget/AlarmWidget.kt` +- Extends `GlanceAppWidget` +- Reads `WIDGET_TEXT_COLOR`, `WIDGET_DYNAMIC_COLOR`, `FONT_SIZE`, `ALARM_WIDGET_TAP_PACKAGE`, `ALARM_TEXT` from DataStore via `collectAsState()` +- Displays `ALARM_TEXT` using same color/dynamic color logic as `WeatherWidget` (reuse `parseColorSafe()`) +- Tap action: `actionStartActivity` using `ALARM_WIDGET_TAP_PACKAGE`; empty/null falls back to MainActivity +- Wrapped in `GlanceTheme` + +### `widget/AlarmWidgetReceiver.kt` +- Extends `GlanceAppWidgetReceiver` +- Overrides `onReceive()` to handle: + - `android.app.action.NEXT_ALARM_CLOCK_CHANGED` + - `android.intent.action.BOOT_COMPLETED` + - `android.intent.action.TIME_SET` + - `android.intent.action.TIMEZONE_CHANGED` + - `android.appwidget.action.APPWIDGET_UPDATE` (passes to super) +- On alarm-related broadcasts: reads `AlarmManager.nextAlarmClock`, formats with `DateFormat.getTimeInstance(DateFormat.SHORT)` (respects device 12/24h), saves to `ALARM_TEXT` in DataStore, calls `AlarmWidget().updateAll(context)` +- Uses `goAsync()` for the coroutine work + +## Settings Changes + +### `SettingsUiState` +Add fields: `fontSize: Int = 48`, `alarmWidgetTapPackage: String = ""` + +### `SettingsViewModel` +- `onSetFontSize(size: Int)` — writes `FONT_SIZE`; calls `WeatherWidget().updateAll()` and `AlarmWidget().updateAll()` +- `onSetAlarmWidgetTapPackage(pkg: String)` — writes `ALARM_WIDGET_TAP_PACKAGE`; calls `AlarmWidget().updateAll()` +- `onSetWidgetTapPackage` → renamed to `onSetWeatherWidgetTapPackage` + +### `SettingsScreen` +- Font size slider (range 12–96, step 1, default 48) added to the shared widget settings section near existing color controls +- New "Alarm Widget" section with app-picker for `ALARM_WIDGET_TAP_PACKAGE` + +## Manifest Changes (`AndroidManifest.xml`) + +New receiver: +```xml + + + + + + + + + + +``` + +New file: `res/xml/alarm_widget_info.xml` — widget provider metadata (updatePeriodMillis=0, min dimensions, description). + +Add `` if not present. + +## Update Architecture + +``` +System alarm change + │ + ▼ +AlarmWidgetReceiver.onReceive() + │ + ├── AlarmManager.nextAlarmClock + ├── format with DateFormat.SHORT + ├── save to ALARM_TEXT in DataStore + └── AlarmWidget().updateAll(context) + │ + ▼ + Glance re-renders from DataStore state +``` + +## Files to Create/Modify + +| Action | File | +|-|-| +| Modify | `data/WeatherDataStore.kt` | +| Modify | `widget/WeatherWidget.kt` | +| Modify | `ui/settings/SettingsViewModel.kt` | +| Modify | `ui/settings/SettingsScreen.kt` | +| Modify | `ui/MainActivity.kt` (rename tap package references) | +| Modify | `AndroidManifest.xml` | +| Create | `widget/AlarmWidget.kt` | +| Create | `widget/AlarmWidgetReceiver.kt` | +| Create | `res/xml/alarm_widget_info.xml` | diff --git a/docs/plans/2026-03-30-alarm-widget-implementation.md b/docs/plans/2026-03-30-alarm-widget-implementation.md new file mode 100644 index 0000000..5fed76c --- /dev/null +++ b/docs/plans/2026-03-30-alarm-widget-implementation.md @@ -0,0 +1,734 @@ +# Alarm Widget + Global Font Size — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a home screen alarm widget showing the next scheduled system alarm, and a global font size setting that applies to all widgets. + +**Architecture:** A new `AlarmWidget` (Glance) + `AlarmWidgetReceiver` (BroadcastReceiver) reads the next alarm via `AlarmManager.nextAlarmClock`, caches it to DataStore, and re-renders on `NEXT_ALARM_CLOCK_CHANGED`, `BOOT_COMPLETED`, `TIME_SET`, and `TIMEZONE_CHANGED`. Font size and text color are shared DataStore keys consumed by both widgets. + +**Tech Stack:** Kotlin, Glance 1.1.1, DataStore Preferences, AlarmManager, BroadcastReceiver, Robolectric (tests). + +--- + +### Task 1: Add DataStore keys + +**Files:** +- Modify: `app/src/main/java/com/wassupluke/simpleweather/data/WeatherDataStore.kt:40-56` + +**Step 1: Add the four new keys and default constant** + +Add inside the `WeatherDataStore` object after line 55 (`WIDGET_DYNAMIC_COLOR`): + +```kotlin +val FONT_SIZE = intPreferencesKey("font_size") +val ALARM_WIDGET_TAP_PACKAGE = stringPreferencesKey("alarm_widget_tap_package") +val ALARM_TEXT = stringPreferencesKey("alarm_text") + +const val DEFAULT_FONT_SIZE = 48 +``` + +**Step 2: Compile-check** + +```bash +./gradlew :app:compileDebugKotlin +``` +Expected: BUILD SUCCESSFUL + +**Step 3: Commit** + +```bash +git add app/src/main/java/com/wassupluke/simpleweather/data/WeatherDataStore.kt +git commit -m "feat: add FONT_SIZE, ALARM_TEXT, ALARM_WIDGET_TAP_PACKAGE DataStore keys" +``` + +--- + +### Task 2: Add string resources + +**Files:** +- Modify: `app/src/main/res/values/strings.xml` + +**Step 1: Add new strings** + +Find `strings.xml` and add (alongside the existing widget-related strings): + +```xml +Widget font size +Alarm widget +Alarm widget tap action +Simple Alarm Widget +``` + +**Step 2: Compile-check** + +```bash +./gradlew :app:compileDebugKotlin +``` +Expected: BUILD SUCCESSFUL + +**Step 3: Commit** + +```bash +git add app/src/main/res/values/strings.xml +git commit -m "feat: add string resources for alarm widget and font size" +``` + +--- + +### Task 3: Update SettingsUiState and ViewModel + +**Files:** +- Modify: `app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModel.kt` +- Test: `app/src/test/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModelTest.kt` + +**Step 1: Write failing tests** + +Open `SettingsViewModelTest.kt` and add at the end of the class (before the closing `}`): + +```kotlin +@Test +fun `setFontSize writes to DataStore`() = runTest(testDispatcher) { + val vm = SettingsViewModel(application, mockRepository, testDispatcher) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.setFontSize(32) + advanceUntilIdle() + val state = vm.uiState.filter { it.fontSize == 32 }.first() + assertEquals(32, state.fontSize) +} + +@Test +fun `setAlarmWidgetTapPackage writes to DataStore`() = runTest(testDispatcher) { + val vm = SettingsViewModel(application, mockRepository, testDispatcher) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.setAlarmWidgetTapPackage("com.example.clock") + advanceUntilIdle() + val state = vm.uiState.filter { it.alarmWidgetTapPackage == "com.example.clock" }.first() + assertEquals("com.example.clock", state.alarmWidgetTapPackage) +} + +@Test +fun `fontSize defaults to 48`() = runTest(testDispatcher) { + val vm = SettingsViewModel(application, mockRepository, testDispatcher) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + val state = vm.uiState.filter { it.fontSize == 48 }.first() + assertEquals(48, state.fontSize) +} +``` + +**Step 2: Run tests to verify they fail** + +```bash +./gradlew :app:testDebugUnitTest --tests "com.wassupluke.simpleweather.ui.settings.SettingsViewModelTest.setFontSize*" +./gradlew :app:testDebugUnitTest --tests "com.wassupluke.simpleweather.ui.settings.SettingsViewModelTest.setAlarmWidgetTapPackage*" +``` +Expected: FAIL — `setFontSize` and `setAlarmWidgetTapPackage` do not exist yet. + +**Step 3: Update SettingsUiState** + +Change the `data class SettingsUiState` (lines 20-29) to add two new fields: + +```kotlin +data class SettingsUiState( + val useDeviceLocation: Boolean = false, + val locationQuery: String = "", + val locationDisplayName: String = "", + val tempUnit: String = WeatherDataStore.DEFAULT_TEMP_UNIT, + val updateIntervalMinutes: Int = WeatherDataStore.DEFAULT_INTERVAL_MINUTES, + val widgetTextColor: String = "white", + val widgetDynamicColor: Boolean = false, + val widgetTapPackage: String = "", + val fontSize: Int = WeatherDataStore.DEFAULT_FONT_SIZE, + val alarmWidgetTapPackage: String = "" +) +``` + +**Step 4: Update prefsToUiState** + +In `prefsToUiState` (lines 38-49), add two new fields to the returned `SettingsUiState`: + +```kotlin +fontSize = prefs[WeatherDataStore.FONT_SIZE] ?: WeatherDataStore.DEFAULT_FONT_SIZE, +alarmWidgetTapPackage = prefs[WeatherDataStore.ALARM_WIDGET_TAP_PACKAGE] ?: "" +``` + +**Step 5: Add new ViewModel methods** + +Add after `setWidgetTapPackage` (line 88): + +```kotlin +fun setFontSize(size: Int) { + viewModelScope.launch(dispatcher) { + context.dataStore.edit { it[WeatherDataStore.FONT_SIZE] = size } + WeatherWidget().updateAll(context) + AlarmWidget().updateAll(context) + } +} + +fun setAlarmWidgetTapPackage(pkg: String) { + viewModelScope.launch(dispatcher) { + context.dataStore.edit { it[WeatherDataStore.ALARM_WIDGET_TAP_PACKAGE] = pkg } + AlarmWidget().updateAll(context) + } +} +``` + +Also update `setWidgetTextColor` and `setWidgetDynamicColor` to call `AlarmWidget().updateAll(context)` after the existing `WeatherWidget().updateAll(context)` call. Both widgets share the same color settings. + +Add the import at the top of the file: +```kotlin +import com.wassupluke.simpleweather.widget.AlarmWidget +``` + +**Step 6: Run tests to verify they pass** + +```bash +./gradlew :app:testDebugUnitTest --tests "com.wassupluke.simpleweather.ui.settings.SettingsViewModelTest" +``` +Expected: All tests PASS + +**Step 7: Commit** + +```bash +git add app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModel.kt +git add app/src/test/java/com/wassupluke/simpleweather/ui/settings/SettingsViewModelTest.kt +git commit -m "feat: add fontSize and alarmWidgetTapPackage to SettingsViewModel" +``` + +--- + +### Task 4: Update WeatherWidget to use FONT_SIZE + +**Files:** +- Modify: `app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidget.kt` + +**Step 1: Read the font size from prefs** + +In `WeatherWidget.provideGlance`, after the existing prefs reads (around line 38), add: + +```kotlin +val fontSize = prefs[WeatherDataStore.FONT_SIZE] ?: WeatherDataStore.DEFAULT_FONT_SIZE +``` + +**Step 2: Pass fontSize to WeatherWidgetContent** + +Change the `WeatherWidgetContent` call (lines 69-73) to pass fontSize: + +```kotlin +WeatherWidgetContent( + displayTemp = displayTemp, + textColorProvider = textColorProvider, + tapAction = tapAction, + fontSize = fontSize +) +``` + +**Step 3: Update WeatherWidgetContent signature and body** + +Change the function signature (line 84-88) to add the parameter: + +```kotlin +private fun WeatherWidgetContent( + displayTemp: String, + textColorProvider: ColorProvider, + tapAction: Action, + fontSize: Int +) +``` + +Replace the hardcoded `fontSize = 48.sp` (line 98) with: + +```kotlin +fontSize = fontSize.sp, +``` + +**Step 4: Compile-check** + +```bash +./gradlew :app:compileDebugKotlin +``` +Expected: BUILD SUCCESSFUL + +**Step 5: Commit** + +```bash +git add app/src/main/java/com/wassupluke/simpleweather/widget/WeatherWidget.kt +git commit -m "feat: WeatherWidget reads font size from DataStore instead of hardcoded 48sp" +``` + +--- + +### Task 5: Create AlarmWidget + +**Files:** +- Create: `app/src/main/java/com/wassupluke/simpleweather/widget/AlarmWidget.kt` + +**Step 1: Create the file** + +```kotlin +package com.wassupluke.simpleweather.widget + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.emptyPreferences +import androidx.glance.* +import androidx.glance.action.Action +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.* +import androidx.glance.GlanceTheme +import androidx.glance.text.* +import androidx.glance.unit.ColorProvider +import com.wassupluke.simpleweather.data.WeatherDataStore +import com.wassupluke.simpleweather.data.dataStore +import com.wassupluke.simpleweather.data.parseColorSafe +import com.wassupluke.simpleweather.data.resolveDynamicColor +import com.wassupluke.simpleweather.ui.MainActivity + +class AlarmWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + val prefs by context.dataStore.data.collectAsState(initial = emptyPreferences()) + val alarmText = prefs[WeatherDataStore.ALARM_TEXT] ?: "No alarm" + val colorString = prefs[WeatherDataStore.WIDGET_TEXT_COLOR] ?: "white" + val dynamicColor = prefs.resolveDynamicColor() + val fontSize = prefs[WeatherDataStore.FONT_SIZE] ?: WeatherDataStore.DEFAULT_FONT_SIZE + + val textColorProvider: ColorProvider = if (dynamicColor) { + GlanceTheme.colors.primary + } else { + val resolved = parseColorSafe(colorString)?.let { argb -> + Color( + red = android.graphics.Color.red(argb) / 255f, + green = android.graphics.Color.green(argb) / 255f, + blue = android.graphics.Color.blue(argb) / 255f, + alpha = android.graphics.Color.alpha(argb) / 255f + ) + } ?: Color.White + ColorProvider(resolved) + } + + val tapPackage = prefs[WeatherDataStore.ALARM_WIDGET_TAP_PACKAGE] + val tapAction: Action = if (!tapPackage.isNullOrEmpty()) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(tapPackage) + if (launchIntent?.component != null) actionStartActivity(launchIntent.component!!) + else actionStartActivity() + } else { + actionStartActivity() + } + + AlarmWidgetContent( + alarmText = alarmText, + textColorProvider = textColorProvider, + tapAction = tapAction, + fontSize = fontSize + ) + } + } + } +} + +@SuppressLint("RestrictedApi") +@Composable +private fun AlarmWidgetContent( + alarmText: String, + textColorProvider: ColorProvider, + tapAction: Action, + fontSize: Int +) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .clickable(tapAction), + contentAlignment = Alignment.Center + ) { + Text( + text = alarmText, + style = TextStyle( + fontSize = fontSize.sp, + fontWeight = FontWeight.Normal, + color = textColorProvider + ) + ) + } +} +``` + +**Step 2: Compile-check** + +```bash +./gradlew :app:compileDebugKotlin +``` +Expected: BUILD SUCCESSFUL + +**Step 3: Commit** + +```bash +git add app/src/main/java/com/wassupluke/simpleweather/widget/AlarmWidget.kt +git commit -m "feat: add AlarmWidget Glance widget" +``` + +--- + +### Task 6: Create AlarmWidgetReceiver + +**Files:** +- Create: `app/src/main/java/com/wassupluke/simpleweather/widget/AlarmWidgetReceiver.kt` + +**Step 1: Create the file** + +```kotlin +package com.wassupluke.simpleweather.widget + +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import androidx.datastore.preferences.core.edit +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import com.wassupluke.simpleweather.data.WeatherDataStore +import com.wassupluke.simpleweather.data.dataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +class AlarmWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = AlarmWidget() + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + when (intent.action) { + AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED, + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED -> updateAlarmText(context) + } + } + + private fun updateAlarmText(context: Context) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val nextAlarm = alarmManager.nextAlarmClock + val alarmText = if (nextAlarm == null) { + "No alarm" + } else { + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(nextAlarm.triggerTime)) + } + context.dataStore.edit { it[WeatherDataStore.ALARM_TEXT] = alarmText } + AlarmWidget().updateAll(context) + } finally { + pendingResult.finish() + } + } + } +} +``` + +**Step 2: Compile-check** + +```bash +./gradlew :app:compileDebugKotlin +``` +Expected: BUILD SUCCESSFUL + +**Step 3: Commit** + +```bash +git add app/src/main/java/com/wassupluke/simpleweather/widget/AlarmWidgetReceiver.kt +git commit -m "feat: add AlarmWidgetReceiver with alarm change broadcast handling" +``` + +--- + +### Task 7: Widget provider XML and AndroidManifest + +**Files:** +- Create: `app/src/main/res/xml/alarm_widget_info.xml` +- Modify: `app/src/main/AndroidManifest.xml` + +**Step 1: Create alarm_widget_info.xml** + +```xml + + +``` + +**Step 2: Update AndroidManifest.xml** + +Add `RECEIVE_BOOT_COMPLETED` permission after the existing permissions (line 6): + +```xml + +``` + +Add the alarm widget receiver inside `` after the existing `WeatherWidgetReceiver` block (after line 40): + +```xml + + + + + + + + + + +``` + +**Step 3: Full build to verify** + +```bash +./gradlew assembleDebug +``` +Expected: BUILD SUCCESSFUL + +**Step 4: Commit** + +```bash +git add app/src/main/res/xml/alarm_widget_info.xml app/src/main/AndroidManifest.xml +git commit -m "feat: register AlarmWidgetReceiver and alarm_widget_info provider in manifest" +``` + +--- + +### Task 8: Update SettingsScreen UI + +**Files:** +- Modify: `app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsScreen.kt` + +**Step 1: Add new parameters to SettingsScreenContent** + +Add two new lambda parameters to `SettingsScreenContent` after `onSetWidgetDynamicColor` (line 68): + +```kotlin +onSetFontSize: (Int) -> Unit, +onSetAlarmWidgetTapPackage: (String) -> Unit, +``` + +**Step 2: Add showAlarmAppPicker state** + +After the existing `var showAppPicker by remember { mutableStateOf(false) }` (line 79), add: + +```kotlin +var showAlarmAppPicker by remember { mutableStateOf(false) } +``` + +**Step 3: Add font size slider** + +Add the following block after the dynamic color / color section (after the `HorizontalDivider` at line 292, before the weather tap action section): + +```kotlin +HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + +Text(stringResource(R.string.title_font_size), style = MaterialTheme.typography.titleSmall) +Slider( + value = uiState.fontSize.toFloat(), + onValueChange = { onSetFontSize(it.toInt()) }, + valueRange = 12f..96f, + steps = 83, + modifier = Modifier.fillMaxWidth() +) +Text( + text = "${uiState.fontSize}sp", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant +) +``` + +**Step 4: Add alarm widget section** + +Add the following block after the weather widget tap action section (after the existing `showAppPicker` ModalBottomSheet block, before the final `Spacer`): + +```kotlin +HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + +Text(stringResource(R.string.title_alarm_widget), style = MaterialTheme.typography.titleSmall) + +Text( + stringResource(R.string.title_alarm_widget_tap_action), + style = MaterialTheme.typography.titleSmall +) + +val selectedAlarmAppInfo = remember(uiState.alarmWidgetTapPackage) { + if (uiState.alarmWidgetTapPackage.isEmpty()) null + else runCatching { + context.packageManager.getApplicationInfo(uiState.alarmWidgetTapPackage, 0) + }.getOrNull() +} + +Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { showAlarmAppPicker = true } + .padding(vertical = 8.dp) +) { + if (selectedAlarmAppInfo != null) { + val icon by produceState(null, uiState.alarmWidgetTapPackage) { + value = withContext(Dispatchers.IO) { + runCatching { + context.packageManager + .getApplicationIcon(uiState.alarmWidgetTapPackage) + .toBitmap() + .asImageBitmap() + }.getOrNull() + } + } + if (icon != null) { + Image( + bitmap = icon!!, + contentDescription = null, + modifier = Modifier.size(40.dp).padding(end = 8.dp) + ) + } else { + Spacer(Modifier.size(40.dp).padding(end = 8.dp)) + } + Text( + text = selectedAlarmAppInfo.loadLabel(context.packageManager).toString(), + modifier = Modifier.weight(1f) + ) + } else if (uiState.alarmWidgetTapPackage.isNotEmpty()) { + Spacer(Modifier.size(40.dp).padding(end = 8.dp)) + Text( + text = stringResource(R.string.label_selected_app_not_found), + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.error + ) + } else { + Spacer(Modifier.size(40.dp).padding(end = 8.dp)) + Text( + text = stringResource(R.string.label_widget_tap_none), + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +if (showAlarmAppPicker) { + ModalBottomSheet(onDismissRequest = { showAlarmAppPicker = false }) { + LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSetAlarmWidgetTapPackage("") + showAlarmAppPicker = false + } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Spacer(Modifier.size(40.dp).padding(end = 12.dp)) + Text( + text = stringResource(R.string.label_widget_tap_none), + modifier = Modifier.weight(1f) + ) + if (uiState.alarmWidgetTapPackage.isEmpty()) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + items(installedApps) { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSetAlarmWidgetTapPackage(entry.pkg) + showAlarmAppPicker = false + } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + if (entry.icon != null) { + Image( + bitmap = entry.icon, + contentDescription = null, + modifier = Modifier.size(40.dp).padding(end = 12.dp) + ) + } else { + Spacer(Modifier.size(40.dp).padding(end = 12.dp)) + } + Text(entry.label, modifier = Modifier.weight(1f)) + if (entry.pkg == uiState.alarmWidgetTapPackage) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } +} +``` + +**Step 5: Wire up SettingsScreen to ViewModel** + +In the `SettingsScreen` composable (around line 442), add the two new lambda arguments to the `SettingsScreenContent` call: + +```kotlin +onSetFontSize = { viewModel.setFontSize(it) }, +onSetAlarmWidgetTapPackage = { viewModel.setAlarmWidgetTapPackage(it) }, +``` + +**Step 6: Update all preview calls** + +Each `SettingsScreenContent(...)` call in the preview functions needs the two new lambdas added: + +```kotlin +onSetFontSize = {}, +onSetAlarmWidgetTapPackage = {}, +``` + +**Step 7: Full build and tests** + +```bash +./gradlew assembleDebug +./gradlew :app:testDebugUnitTest +``` +Expected: BUILD SUCCESSFUL, all tests PASS + +**Step 8: Commit** + +```bash +git add app/src/main/java/com/wassupluke/simpleweather/ui/settings/SettingsScreen.kt +git commit -m "feat: add font size slider and alarm widget tap-action picker to Settings" +```