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"
+```