diff --git a/activity_main_patch.diff b/activity_main_patch.diff
new file mode 100644
index 0000000..92b385f
--- /dev/null
+++ b/activity_main_patch.diff
@@ -0,0 +1,19 @@
+--- app/src/main/res/layout/activity_main.xml
++++ app/src/main/res/layout/activity_main.xml
+@@ -1246,6 +1246,16 @@
+ android:layout_height="wrap_content"/>
+
+
++
++
++
++
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b567946..5daf6f8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -19,6 +19,10 @@ android {
versionName = "2.0"
}
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -55,10 +59,20 @@ android {
}
dependencies {
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.robolectric:robolectric:4.11.1")
+ testImplementation("androidx.test:core:1.5.0")
+ testImplementation("androidx.test.ext:junit:1.1.5")
+ testImplementation("org.mockito:mockito-core:5.3.1")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.mockito:mockito-core:5.8.0")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9c5cedd..d4eea55 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,7 +44,7 @@
{
scheduleWork(context)
- appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.FULL) }
+ if (appWidgetIds.isNotEmpty()) updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.FULL)
}
ACTION_BATTERY_UPDATE -> {
- appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.TICK) }
+ if (appWidgetIds.isNotEmpty()) updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.TICK)
}
StepCounterService.ACTION_STEP_UPDATE -> {
// Step event updates match Tick mode conceptually
- appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.TICK) }
+ if (appWidgetIds.isNotEmpty()) updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.TICK)
}
android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED -> {
- appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.ALARM_ONLY) }
+ if (appWidgetIds.isNotEmpty()) updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.ALARM_ONLY)
}
Intent.ACTION_PROVIDER_CHANGED -> {
val host = intent.data?.host
val mode = if (host == "com.android.calendar") UpdateMode.CALENDAR_ONLY else UpdateMode.TASKS_ONLY
- appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, mode) }
+ if (appWidgetIds.isNotEmpty()) updateAppWidget(context, appWidgetManager, appWidgetIds, mode)
}
"nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER" -> {
val weatherJson = intent.getStringExtra("WeatherJson")
if (!weatherJson.isNullOrEmpty()) {
com.leanbitlab.lwidget.weather.BreezyWeatherFetcher.saveLatestWeatherData(context, weatherJson)
- appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.FULL) }
+ if (appWidgetIds.isNotEmpty()) updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.FULL)
}
}
}
@@ -165,9 +163,11 @@ class AwidgetProvider : AppWidgetProvider() {
companion object {
const val ACTION_BATTERY_UPDATE = "com.leanbitlab.lwidget.ACTION_BATTERY_UPDATE"
+ const val PERMISSION_READ_TASKS_ORG = "org.tasks.permission.READ_TASKS"
+ const val PERMISSION_READ_TASKS_ASTRID = "com.todoroo.astrid.READ"
// Suspended function called from Coroutine
- fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, mode: UpdateMode = UpdateMode.FULL) {
+ fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, mode: UpdateMode = UpdateMode.FULL) {
val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
// --- Load Preferences ---
@@ -236,7 +236,7 @@ class AwidgetProvider : AppWidgetProvider() {
val sizeStorage = prefs.getFloat("size_storage", 14f)
var showTasks = prefs.getBoolean("show_tasks", false)
- if (showTasks && androidx.core.content.ContextCompat.checkSelfPermission(context, "org.tasks.permission.READ_TASKS") != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ if (showTasks && androidx.core.content.ContextCompat.checkSelfPermission(context, PERMISSION_READ_TASKS_ORG) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
showTasks = false
}
val sizeTasks = prefs.getFloat("size_tasks", 14f)
@@ -361,49 +361,43 @@ class AwidgetProvider : AppWidgetProvider() {
// Resolve Colors
fun resolveColor(idx: Int, isPrimary: Boolean, isLight: Boolean): Int {
- // When dynamic colors is on, always use dynamic palette regardless of saved color index
- if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
- return if (isPrimary) {
- // High contrast accent for time & battery
- context.getColor(if (isLight) android.R.color.system_accent1_800 else android.R.color.system_accent1_50)
- } else {
- // Muted neutral for secondary items (temp, data, storage, steps)
- context.getColor(if (isLight) android.R.color.system_neutral2_600 else android.R.color.system_neutral2_300)
- }
- }
- return when (idx) {
- 0 -> { // Default
- if (isPrimary) {
- if (isLight) context.getColor(R.color.widget_text_light) else android.graphics.Color.WHITE
- } else {
- if (isLight) context.getColor(R.color.widget_text_secondary_light) else android.graphics.Color.parseColor("#CCFFFFFF")
- }
- }
- 1 -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
- context.getColor(android.R.color.system_accent1_500)
- } else {
- android.graphics.Color.CYAN
- }
- 2 -> {
- val prefix = if (isPrimary) "text_color_primary" else "text_color_secondary"
- val r = prefs.getInt("${prefix}_r", 255)
- val g = prefs.getInt("${prefix}_g", 255)
- val b = prefs.getInt("${prefix}_b", 255)
- android.graphics.Color.rgb(r, g, b)
- }
- else -> if (isPrimary) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#CCFFFFFF")
- }
+ return ColorResolver.resolveColor(
+ context = context,
+ prefs = prefs,
+ useDynamicColors = useDynamicColors,
+ idx = idx,
+ isPrimary = isPrimary,
+ isLight = isLight
+ )
}
val primaryColor = resolveColor(textColorPrimaryIdx, true, useLightTheme)
val secondaryColor = resolveColor(textColorSecondaryIdx, false, useLightTheme)
+ val dateColorIdx = prefs.getInt("date_color_idx", 0)
+
// Slightly distinct colors for date and next alarm
val dateColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
// Warm accent for date
context.getColor(if (useLightTheme) android.R.color.system_accent2_700 else android.R.color.system_accent2_100)
} else {
- if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
+ when (dateColorIdx) {
+ 2 -> {
+ android.graphics.Color.rgb(
+ prefs.getInt("date_color_r", 255),
+ prefs.getInt("date_color_g", 255),
+ prefs.getInt("date_color_b", 255)
+ )
+ }
+ 1 -> {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ context.getColor(android.R.color.system_accent2_500)
+ } else {
+ android.graphics.Color.YELLOW
+ }
+ }
+ else -> if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
+ }
}
val alarmColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
// Cool tertiary accent for alarm
@@ -434,7 +428,7 @@ class AwidgetProvider : AppWidgetProvider() {
batterySpannable.setSpan(android.text.style.RelativeSizeSpan(0.5f), batterySpannable.length - 1, batterySpannable.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val tempInt = batteryStatus?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
val tempVal = tempInt / 10f
- if (showSteps) loadStepCount(context, tickViews)
+ if (showSteps) loadStepCount(context, tickViews, prefs)
if (showBattery) tickViews.setTextViewText(R.id.text_battery, batterySpannable)
if (showTemp) {
val tempStr = String.format("%.1f", tempVal)
@@ -449,22 +443,22 @@ class AwidgetProvider : AppWidgetProvider() {
}
if (showData) updateDataUsage(context, tickViews)
if (showStorage) updateStorageStats(context, tickViews)
- appWidgetManager.partiallyUpdateAppWidget(appWidgetId, tickViews)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetIds, tickViews)
return
} else if (mode == UpdateMode.CALENDAR_ONLY) {
val calViews = RemoteViews(context.packageName, layoutId)
if (showEvents) loadCalendarEvents(context, calViews, sizeEvents, primaryColor, secondaryColor)
- appWidgetManager.partiallyUpdateAppWidget(appWidgetId, calViews)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetIds, calViews)
return
} else if (mode == UpdateMode.TASKS_ONLY) {
val taskViews = RemoteViews(context.packageName, layoutId)
if (showTasks) loadTasks(context, taskViews, sizeTasks, primaryColor)
- appWidgetManager.partiallyUpdateAppWidget(appWidgetId, taskViews)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetIds, taskViews)
return
} else if (mode == UpdateMode.ALARM_ONLY) {
val alarmViews = RemoteViews(context.packageName, layoutId)
if (showNextAlarm) loadNextAlarm(context, alarmViews, sizeNextAlarm, secondaryColor)
- appWidgetManager.partiallyUpdateAppWidget(appWidgetId, alarmViews)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetIds, alarmViews)
return
}
@@ -667,7 +661,7 @@ class AwidgetProvider : AppWidgetProvider() {
if (showSteps) {
views.setTextViewTextSize(R.id.text_steps, android.util.TypedValue.COMPLEX_UNIT_SP, sizeSteps)
views.setTextColor(R.id.text_steps, secondaryColor)
- loadStepCount(context, views)
+ loadStepCount(context, views, prefs)
}
// --- Screen Time ---
@@ -677,7 +671,7 @@ class AwidgetProvider : AppWidgetProvider() {
if (showScreenTime) {
views.setTextViewTextSize(R.id.text_screen_time, android.util.TypedValue.COMPLEX_UNIT_SP, sizeScreenTime)
views.setTextColor(R.id.text_screen_time, secondaryColor)
- updateScreenTime(context, views)
+ updateScreenTime(context, views, prefs)
}
// --- Dynamic Spacing Logic for Both Sides ---
@@ -821,29 +815,17 @@ class AwidgetProvider : AppWidgetProvider() {
val settingsPendingIntent = PendingIntent.getActivity(context, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
views.setOnClickPendingIntent(R.id.widget_root, settingsPendingIntent)
- appWidgetManager.updateAppWidget(appWidgetId, views)
+ appWidgetManager.updateAppWidget(appWidgetIds, views)
}
- private fun loadCalendarEvents(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int, secondaryColor: Int) {
- if (androidx.core.content.ContextCompat.checkSelfPermission(
- context, android.Manifest.permission.READ_CALENDAR
- ) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
- return
- }
+ data class EventInfo(val id: Long, val title: String, val begin: Long, val isLocal: Boolean)
- val eventViews = listOf(
- R.id.text_event_1, R.id.text_event_2, R.id.text_event_3,
- R.id.text_event_4, R.id.text_event_5, R.id.text_event_6,
- R.id.text_event_7, R.id.text_event_8, R.id.text_event_9,
- R.id.text_event_10
- )
+ private fun fetchCalendarEvents(context: Context): List {
+ val syncedCalendarIds = mutableSetOf()
+ val visibleCalendarIds = mutableSetOf()
- try {
- val syncedCalendarIds = mutableSetOf()
- val visibleCalendarIds = mutableSetOf()
-
- val calSelection = "${android.provider.CalendarContract.Calendars.VISIBLE} = 1"
+ val calSelection = "${android.provider.CalendarContract.Calendars.VISIBLE} = 1"
context.contentResolver.query(
android.provider.CalendarContract.Calendars.CONTENT_URI,
@@ -856,12 +838,10 @@ class AwidgetProvider : AppWidgetProvider() {
calSelection, null, null
)?.use { cursor ->
val idIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars._ID)
- // val typeIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.ACCOUNT_TYPE)
val nameIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.ACCOUNT_NAME)
val displayIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)
while (cursor.moveToNext()) {
val calId = cursor.getLong(idIdx)
- // val accountType = cursor.getString(typeIdx) ?: ""
val accountName = cursor.getString(nameIdx) ?: ""
val displayName = cursor.getString(displayIdx) ?: ""
@@ -874,7 +854,7 @@ class AwidgetProvider : AppWidgetProvider() {
}
}
- if (visibleCalendarIds.isEmpty()) return
+ if (visibleCalendarIds.isEmpty()) return emptyList()
val projection = arrayOf(
android.provider.CalendarContract.Instances.EVENT_ID,
@@ -891,14 +871,11 @@ class AwidgetProvider : AppWidgetProvider() {
.appendPath(endQuery.toString())
.build()
- val idList = visibleCalendarIds.joinToString(",")
- // Removed Instances.VISIBLE = 1 because some devices/ROMs crash if Instances table lacks this column.
- // We are already filtering by CALENDAR_ID IN ($idList) which only contains visibly enabled calendars.
- val selection = "${android.provider.CalendarContract.Instances.END} >= ? AND ${android.provider.CalendarContract.Instances.CALENDAR_ID} IN ($idList)"
- val selectionArgs = arrayOf(now.toString())
+ val idList = visibleCalendarIds.joinToString(",")
+ val selection = "${android.provider.CalendarContract.Instances.END} >= ? AND ${android.provider.CalendarContract.Instances.CALENDAR_ID} IN ($idList)"
+ val selectionArgs = arrayOf(now.toString())
val sortOrder = "${android.provider.CalendarContract.Instances.BEGIN} ASC"
- data class EventInfo(val id: Long, val title: String, val begin: Long, val isLocal: Boolean)
val events = mutableListOf()
context.contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)?.use { cursor ->
@@ -916,8 +893,10 @@ class AwidgetProvider : AppWidgetProvider() {
events.add(EventInfo(eventId, title, begin, isLocal))
}
}
+ return events
+ }
- // java.time formatters
+ private fun bindCalendarEvents(context: Context, views: RemoteViews, events: List, textSizeSp: Float, primaryColor: Int, secondaryColor: Int, eventViews: List) {
val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault())
val dayFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault())
val dateFormatter = DateTimeFormatter.ofPattern("d MMM h:mma", Locale.getDefault())
@@ -969,12 +948,31 @@ class AwidgetProvider : AppWidgetProvider() {
data = android.content.ContentUris.withAppendedId(android.provider.CalendarContract.Events.CONTENT_URI, event.id)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
- val eventPendingIntent = PendingIntent.getActivity(context, event.id.toInt(), eventIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
- views.setOnClickPendingIntent(eventViews[i], eventPendingIntent)
- } else {
- views.setViewVisibility(eventViews[i], android.view.View.GONE)
- }
+ val eventPendingIntent = PendingIntent.getActivity(context, event.id.toInt(), eventIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(eventViews[i], eventPendingIntent)
+ } else {
+ views.setViewVisibility(eventViews[i], android.view.View.GONE)
}
+ }
+ }
+
+ private fun loadCalendarEvents(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int, secondaryColor: Int) {
+ if (androidx.core.content.ContextCompat.checkSelfPermission(
+ context, android.Manifest.permission.READ_CALENDAR
+ ) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+
+ val eventViews = listOf(
+ R.id.text_event_1, R.id.text_event_2, R.id.text_event_3,
+ R.id.text_event_4, R.id.text_event_5, R.id.text_event_6,
+ R.id.text_event_7, R.id.text_event_8, R.id.text_event_9,
+ R.id.text_event_10
+ )
+
+ try {
+ val events = fetchCalendarEvents(context)
+ bindCalendarEvents(context, views, events, textSizeSp, primaryColor, secondaryColor, eventViews)
} catch (e: Exception) {
// Log and gracefully handle crash
android.util.Log.e("LWidget", "Error loading calendar events", e)
@@ -984,7 +982,7 @@ class AwidgetProvider : AppWidgetProvider() {
}
}
- private fun updateScreenTime(context: Context, views: RemoteViews) {
+ private fun updateScreenTime(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
val mode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName)
@@ -1019,7 +1017,6 @@ class AwidgetProvider : AppWidgetProvider() {
}
}
- val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
val isBold = prefs.getBoolean("bold_screen_time", false)
if (totalForegroundTime > 0) {
@@ -1040,6 +1037,7 @@ class AwidgetProvider : AppWidgetProvider() {
}
private fun updateDataUsage(context: Context, views: RemoteViews) {
+ val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
val networkStatsManager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
// Use java.time
val startOfDay = LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
@@ -1069,7 +1067,6 @@ class AwidgetProvider : AppWidgetProvider() {
span
}
- val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
if (prefs.getBoolean("bold_data_usage", false) && text is android.text.SpannableString) {
text.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, text.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
@@ -1086,6 +1083,61 @@ class AwidgetProvider : AppWidgetProvider() {
}
}
+ private data class TaskData(val title: String, val dueMillis: Long)
+
+ private fun fetchActiveTasks(context: Context, limit: Int): List {
+ val tasks = mutableListOf()
+ val taskUri = android.net.Uri.parse("content://org.tasks/tasks")
+ val selection = "completed=0 AND deleted=0"
+ try {
+ context.contentResolver.query(taskUri, null, selection, null, "dueDate ASC")?.use { cursor ->
+ val titleIdx = cursor.getColumnIndex("title")
+ val compIdx = cursor.getColumnIndex("completed")
+ val delIdx = cursor.getColumnIndex("deleted")
+ val dueIdx = cursor.getColumnIndex("dueDate")
+
+ if (titleIdx == -1) return emptyList()
+
+ while (cursor.moveToNext() && tasks.size < limit) {
+ val completed = if (compIdx >= 0) cursor.getString(compIdx) else null
+ val deleted = if (delIdx >= 0) cursor.getString(delIdx) else null
+ val dueMillis = if (dueIdx >= 0) cursor.getLong(dueIdx) else 0L
+
+ val isCompleted = completed != null && completed != "0"
+ val isDeleted = deleted != null && deleted != "0"
+
+ if (isCompleted || isDeleted) {
+ continue
+ }
+
+ val title = cursor.getString(titleIdx) ?: "No Title"
+ tasks.add(TaskData(title, dueMillis))
+ }
+ }
+ } catch (e: Exception) {
+ // Return empty list on failure
+ }
+ return tasks
+ }
+
+ private fun formatDueSuffix(dueMillis: Long): String {
+ if (dueMillis <= 0) return ""
+ val dueDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(dueMillis), ZoneId.systemDefault()).toLocalDate()
+ val today = LocalDate.now()
+ val tomorrow = today.plusDays(1)
+
+ return if (dueDate.isBefore(today)) {
+ " (Overdue)"
+ } else if (dueDate.isEqual(today)) {
+ " (Today)"
+ } else if (dueDate.isEqual(tomorrow)) {
+ " (Tomorrow)"
+ } else {
+ val df = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
+ " (${dueDate.format(df)})"
+ }
+ }
+
private fun loadTasks(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int) {
val eventViews = listOf(
R.id.text_event_1, R.id.text_event_2, R.id.text_event_3,
@@ -1095,107 +1147,50 @@ class AwidgetProvider : AppWidgetProvider() {
)
// Debugging: Check permission again contextually
- val hasPerm = context.checkSelfPermission("org.tasks.permission.READ_TASKS") == android.content.pm.PackageManager.PERMISSION_GRANTED ||
- context.checkSelfPermission("com.todoroo.astrid.READ") == android.content.pm.PackageManager.PERMISSION_GRANTED
+ val hasPerm = context.checkSelfPermission(PERMISSION_READ_TASKS_ORG) == android.content.pm.PackageManager.PERMISSION_GRANTED ||
+ context.checkSelfPermission(PERMISSION_READ_TASKS_ASTRID) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!hasPerm) {
views.setTextViewText(eventViews[0], "Missing Permission")
views.setViewVisibility(eventViews[0], android.view.View.VISIBLE)
+ for (j in 1 until eventViews.size) {
+ views.setViewVisibility(eventViews[j], android.view.View.GONE)
+ }
return
}
+ val tasks = fetchActiveTasks(context, eventViews.size)
- val taskUri = android.net.Uri.parse("content://org.tasks/tasks")
- // Selection appears to be ignored by provider, so we select all and filter manually
- val selection = "completed=0 AND deleted=0"
-
- try {
- context.contentResolver.query(taskUri, null, selection, null, "dueDate ASC")?.use { cursor ->
- val titleIdx = cursor.getColumnIndex("title")
-
- if (cursor.count == 0) {
- // views.setTextViewText(eventViews[0], "No active tasks found")
- // views.setViewVisibility(eventViews[0], android.view.View.VISIBLE)
- // views.setTextColor(eventViews[0], secondaryColor)
- // Just hide all
- for (viewId in eventViews) {
- views.setViewVisibility(viewId, android.view.View.GONE)
- }
- return
- }
-
- var i = 0
- while (cursor.moveToNext() && i < eventViews.size) {
- // Manual Filtering: Provider might ignore selection
- val compIdx = cursor.getColumnIndex("completed")
- val delIdx = cursor.getColumnIndex("deleted")
- val dueIdx = cursor.getColumnIndex("dueDate")
- val completed = if (compIdx >= 0) cursor.getString(compIdx) else null
- val deleted = if (delIdx >= 0) cursor.getString(delIdx) else null
- val dueMillis = if (dueIdx >= 0) cursor.getLong(dueIdx) else 0L
-
- val isCompleted = completed != null && completed != "0"
- val isDeleted = deleted != null && deleted != "0"
-
- if (isCompleted || isDeleted) {
- continue
- }
-
- if (titleIdx != -1) {
- val title = cursor.getString(titleIdx) ?: "No Title"
-
- var dueSuffix = ""
- if (dueMillis > 0) {
- val dueDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(dueMillis), ZoneId.systemDefault()).toLocalDate()
- val today = LocalDate.now()
- val tomorrow = today.plusDays(1)
-
- if (dueDate.isBefore(today)) {
- dueSuffix = " (Overdue)"
- } else if (dueDate.isEqual(today)) {
- dueSuffix = " (Today)"
- } else if (dueDate.isEqual(tomorrow)) {
- dueSuffix = " (Tomorrow)"
- } else {
- val df = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
- dueSuffix = " (${dueDate.format(df)})"
- }
- }
-
- val fullText = "• $title$dueSuffix"
- val spannable = SpannableString(fullText)
- val accentColor = context.getColor(R.color.widget_outline)
- spannable.setSpan(ForegroundColorSpan(accentColor), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
-
- views.setTextViewText(eventViews[i], spannable)
- views.setTextColor(eventViews[i], primaryColor)
- views.setTextViewTextSize(eventViews[i], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
- views.setViewVisibility(eventViews[i], android.view.View.VISIBLE)
-
- val taskIntent = context.packageManager.getLaunchIntentForPackage("org.tasks")
- if (taskIntent != null) {
- val taskPendingIntent = PendingIntent.getActivity(context, 1000 + i, taskIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
- views.setOnClickPendingIntent(eventViews[i], taskPendingIntent)
- }
- i++
- }
- }
-
- for (j in i until eventViews.size) {
- views.setViewVisibility(eventViews[j], android.view.View.GONE)
- }
- return
- }
- } catch (e: Exception) {
- // Fail silently or log
+ if (tasks.isEmpty()) {
for (viewId in eventViews) {
- views.setViewVisibility(viewId, android.view.View.GONE)
+ views.setViewVisibility(viewId, android.view.View.GONE)
}
+ return
+ }
+
+ for (i in tasks.indices) {
+ val task = tasks[i]
+ val dueSuffix = formatDueSuffix(task.dueMillis)
+ val fullText = "• ${task.title}$dueSuffix"
+ val spannable = SpannableString(fullText)
+ val accentColor = context.getColor(R.color.widget_outline)
+ spannable.setSpan(ForegroundColorSpan(accentColor), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ views.setTextViewText(eventViews[i], spannable)
+ views.setTextColor(eventViews[i], primaryColor)
+ views.setTextViewTextSize(eventViews[i], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
+ views.setViewVisibility(eventViews[i], android.view.View.VISIBLE)
+
+ val taskIntent = context.packageManager.getLaunchIntentForPackage("org.tasks")
+ if (taskIntent != null) {
+ val taskPendingIntent = PendingIntent.getActivity(context, 1000 + i, taskIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(eventViews[i], taskPendingIntent)
+ }
+ }
+
+ for (j in tasks.size until eventViews.size) {
+ views.setViewVisibility(eventViews[j], android.view.View.GONE)
}
-
- // If we reached here (query null?), show generic message
- // views.setTextViewText(eventViews[0], "Query Failed")
- // views.setViewVisibility(eventViews[0], android.view.View.VISIBLE)
}
private fun loadWorldClock(views: RemoteViews, textSizeSp: Float, textColor: Int, zoneIdStr: String, is12Hour: Boolean) {
@@ -1239,6 +1234,7 @@ class AwidgetProvider : AppWidgetProvider() {
}
private fun updateStorageStats(context: Context, views: RemoteViews) {
+ val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
try {
val path = android.os.Environment.getDataDirectory()
val stat = android.os.StatFs(path.path)
@@ -1250,7 +1246,6 @@ class AwidgetProvider : AppWidgetProvider() {
val span = android.text.SpannableString("$gbStr GB")
span.setSpan(android.text.style.RelativeSizeSpan(0.5f), gbStr.length, gbStr.length + 3, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // GB
- val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
if (prefs.getBoolean("bold_storage", false)) {
span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
@@ -1261,9 +1256,8 @@ class AwidgetProvider : AppWidgetProvider() {
}
}
- private fun loadStepCount(context: Context, views: RemoteViews) {
+ private fun loadStepCount(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) {
try {
- val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
val totalSteps = prefs.getFloat("last_total_steps", 0f)
val baselineSteps = prefs.getFloat("step_baseline", 0f)
@@ -1285,12 +1279,9 @@ class AwidgetProvider : AppWidgetProvider() {
private fun getBestIntent(context: Context, packages: List, fallback: Intent): Intent {
val pm = context.packageManager
for (pkg in packages) {
- try {
- val intent = pm.getLaunchIntentForPackage(pkg)
- if (intent != null) {
- return intent
- }
- } catch (e: Exception) {
+ val intent = pm.getLaunchIntentForPackage(pkg)
+ if (intent != null) {
+ return intent
}
}
return fallback
diff --git a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig
new file mode 100644
index 0000000..ae333f9
--- /dev/null
+++ b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig
@@ -0,0 +1,1272 @@
+/*
+ * Copyright (C) 2026 LeanBitLab
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.leanbitlab.lwidget
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.os.Build
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.app.usage.NetworkStatsManager
+import android.os.BatteryManager
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.style.ForegroundColorSpan
+import android.widget.RemoteViews
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+enum class UpdateMode {
+ FULL, TICK, CALENDAR_ONLY, TASKS_ONLY, ALARM_ONLY
+}
+
+class AwidgetProvider : AppWidgetProvider() {
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ val pendingResult = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ for (appWidgetId in appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId, UpdateMode.FULL)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ scheduleWork(context)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ cancelWork(context)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val thisAppWidget = ComponentName(context, AwidgetProvider::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(thisAppWidget)
+
+ if (intent.action in listOf(
+ Intent.ACTION_BOOT_COMPLETED,
+ ACTION_BATTERY_UPDATE,
+ StepCounterService.ACTION_STEP_UPDATE,
+ Intent.ACTION_PROVIDER_CHANGED,
+ android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED,
+ "nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER"
+ )) {
+ val pendingResult = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED -> {
+ scheduleWork(context)
+ appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.FULL) }
+ }
+ ACTION_BATTERY_UPDATE -> {
+ appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.TICK) }
+ }
+ StepCounterService.ACTION_STEP_UPDATE -> {
+ // Step event updates match Tick mode conceptually
+ appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.TICK) }
+ }
+ android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED -> {
+ appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.ALARM_ONLY) }
+ }
+ Intent.ACTION_PROVIDER_CHANGED -> {
+ val host = intent.data?.host
+ val mode = if (host == "com.android.calendar") UpdateMode.CALENDAR_ONLY else UpdateMode.TASKS_ONLY
+ appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, mode) }
+ }
+ "nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER" -> {
+ val weatherJson = intent.getStringExtra("WeatherJson")
+ if (!weatherJson.isNullOrEmpty()) {
+ com.leanbitlab.lwidget.weather.BreezyWeatherFetcher.saveLatestWeatherData(context, weatherJson)
+ appWidgetIds.forEach { updateAppWidget(context, appWidgetManager, it, UpdateMode.FULL) }
+ }
+ }
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+ }
+
+ private fun scheduleWork(context: Context) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager
+ val intent = Intent(context, AwidgetProvider::class.java).apply {
+ action = ACTION_BATTERY_UPDATE
+ }
+ val pendingIntent = android.app.PendingIntent.getBroadcast(
+ context,
+ 500,
+ intent,
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
+ )
+
+ // 1 minute
+ val intervalMillis = 1L * 60L * 1000L
+
+ alarmManager.setInexactRepeating(
+ android.app.AlarmManager.RTC,
+ System.currentTimeMillis() + intervalMillis,
+ intervalMillis,
+ pendingIntent
+ )
+ }
+
+ private fun cancelWork(context: Context) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager
+ val intent = Intent(context, AwidgetProvider::class.java).apply {
+ action = ACTION_BATTERY_UPDATE
+ }
+ val pendingIntent = android.app.PendingIntent.getBroadcast(
+ context,
+ 500,
+ intent,
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
+ )
+ alarmManager.cancel(pendingIntent)
+ }
+
+ companion object {
+ const val ACTION_BATTERY_UPDATE = "com.leanbitlab.lwidget.ACTION_BATTERY_UPDATE"
+ const val PERMISSION_READ_TASKS_ORG = "org.tasks.permission.READ_TASKS"
+ const val PERMISSION_READ_TASKS_ASTRID = "com.todoroo.astrid.READ"
+
+ // Suspended function called from Coroutine
+ fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, mode: UpdateMode = UpdateMode.FULL) {
+ val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
+
+ // --- Load Preferences ---
+ val showTime = prefs.getBoolean("show_time", true)
+ val sizeTime = prefs.getFloat("size_time", 64f)
+
+ val showDate = prefs.getBoolean("show_date", true)
+ val sizeDate = prefs.getFloat("size_date", 14f)
+
+ val showBattery = prefs.getBoolean("show_battery", true)
+ val sizeBattery = prefs.getFloat("size_battery", 24f)
+ val boldBattery = prefs.getBoolean("bold_battery", false)
+
+ val showTemp = prefs.getBoolean("show_temp", true)
+ val sizeTemp = prefs.getFloat("size_temp", 18f)
+ val boldTemp = prefs.getBoolean("bold_temp", false)
+
+ val showWeatherCondition = prefs.getBoolean("show_weather_condition", false)
+ val sizeWeather = prefs.getFloat("size_weather", 18f)
+ val boldWeather = prefs.getBoolean("bold_weather", false)
+
+ var showEvents = prefs.getBoolean("show_events", true)
+ if (showEvents && androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.READ_CALENDAR) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ showEvents = false
+ }
+ val sizeEvents = prefs.getFloat("size_events", 14f)
+
+ // Fetch Breezy Weather Data
+ val bweather = com.leanbitlab.lwidget.weather.BreezyWeatherFetcher.fetchLocalWeather(context)
+ val showWeatherIconOnly = prefs.getBoolean("show_weather_icon_only", false)
+
+ android.util.Log.d("WidgetLife", "UpdateMode FULL | Condition: $showWeatherCondition | IconOnly: $showWeatherIconOnly | WeatherData: ${bweather?.currentCondition}")
+
+ val useSystemTheme = prefs.getBoolean("use_system_theme", false)
+ val useDynamicColors = prefs.getBoolean("use_dynamic_colors", true)
+
+ // Determine if light theme based on system or manual override
+ val useLightTheme = if (useSystemTheme) {
+ val nightMode = context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK
+ nightMode != android.content.res.Configuration.UI_MODE_NIGHT_YES
+ } else {
+ false // Default to dark when system theme is off
+ }
+
+ val timeFormatIdx = prefs.getInt("time_format_idx", 0)
+ val dateFormatIdx = prefs.getInt("date_format_idx", 0)
+
+ var showData = prefs.getBoolean("show_data_usage", false)
+ if (showData) {
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
+ val opMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ appOps.unsafeCheckOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName)
+ } else {
+ @Suppress("DEPRECATION")
+ appOps.checkOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName)
+ }
+ if (opMode != android.app.AppOpsManager.MODE_ALLOWED) showData = false
+ }
+ val sizeData = prefs.getFloat("size_data", 14f)
+
+ val showWorldClock = prefs.getBoolean("show_world_clock", false)
+ val sizeWorldClock = prefs.getFloat("size_world_clock", 18f)
+ val worldClockZoneStr = prefs.getString("world_clock_zone_str", "UTC") ?: "UTC"
+
+ val showStorage = prefs.getBoolean("show_storage", true)
+ val sizeStorage = prefs.getFloat("size_storage", 14f)
+
+ var showTasks = prefs.getBoolean("show_tasks", false)
+ if (showTasks && androidx.core.content.ContextCompat.checkSelfPermission(context, PERMISSION_READ_TASKS_ORG) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ showTasks = false
+ }
+ val sizeTasks = prefs.getFloat("size_tasks", 14f)
+
+ val showNextAlarm = prefs.getBoolean("show_next_alarm", true)
+ val sizeNextAlarm = prefs.getFloat("size_next_alarm", 14f)
+
+ var showSteps = prefs.getBoolean("show_steps", false)
+ if (showSteps && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACTIVITY_RECOGNITION) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ showSteps = false
+ }
+ }
+ val sizeSteps = prefs.getFloat("size_steps", 14f)
+
+ // Make sure the background Step Service is running if steps or keep-alive is enabled
+ val keepAlive = prefs.getBoolean("keep_alive", false)
+ val serviceIntent = Intent(context, StepCounterService::class.java)
+ // FOREGROUND_SERVICE_TYPE_HEALTH requires ACTIVITY_RECOGNITION at runtime
+ val hasActivityPerm = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACTIVITY_RECOGNITION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else true
+ if ((showSteps || keepAlive) && hasActivityPerm) {
+ context.startForegroundService(serviceIntent)
+ } else {
+ context.stopService(serviceIntent)
+ }
+
+
+ val fontStyle = prefs.getInt("font_style", 0)
+
+ val bgOpacity = prefs.getFloat("bg_opacity", 100f)
+ val textColorPrimaryIdx = prefs.getInt("text_color_primary_idx", 0)
+ val textColorSecondaryIdx = prefs.getInt("text_color_secondary_idx", 0)
+ val bgColorIdx = prefs.getInt("bg_color_idx", 0)
+
+ // --- Theme & Font Setup ---
+ fun getLayout(fontIdx: Int): Int {
+ return when (fontIdx) {
+ 1 -> R.layout.widget_layout_serif
+ 2 -> R.layout.widget_layout_mono
+ 3 -> R.layout.widget_layout_cursive
+ 4 -> R.layout.widget_layout_condensed
+ 5 -> R.layout.widget_layout_condensed_light
+ 6 -> R.layout.widget_layout_light
+ 7 -> R.layout.widget_layout_medium
+ 8 -> R.layout.widget_layout_black
+ 9 -> R.layout.widget_layout_thin
+ 10 -> R.layout.widget_layout_smallcaps
+ else -> R.layout.widget_layout
+ }
+ }
+
+ val layoutId = getLayout(fontStyle)
+
+ val views = RemoteViews(context.packageName, layoutId)
+
+ // --- Background & Outline Application ---
+ val outlineColorIdx = prefs.getInt("outline_color_idx", 0)
+
+ // Background
+ views.setImageViewResource(R.id.widget_background, R.drawable.widget_bg_fill)
+
+ // Resolve background color (0=Default, 1=System Accent, 2=Custom)
+ fun resolveBgColor(idx: Int, isLight: Boolean): Int {
+ return when (idx) {
+ 0 -> if (isLight) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#212121")
+ 1 -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ context.getColor(android.R.color.system_accent1_500)
+ } else {
+ android.graphics.Color.CYAN
+ }
+ 2 -> {
+ val r = prefs.getInt("bg_color_r", 255)
+ val g = prefs.getInt("bg_color_g", 255)
+ val b = prefs.getInt("bg_color_b", 255)
+ android.graphics.Color.rgb(r, g, b)
+ }
+ else -> if (isLight) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#212121")
+ }
+ }
+
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ views.setColorStateList(R.id.widget_background, "setImageTintList", android.content.res.ColorStateList.valueOf(resolveBgColor(bgColorIdx, useLightTheme)))
+ } else {
+ views.setInt(R.id.widget_background, "setColorFilter", resolveBgColor(bgColorIdx, useLightTheme))
+ }
+
+ val alpha255 = (bgOpacity * 255 / 100).toInt().coerceIn(0, 255)
+ views.setInt(R.id.widget_background, "setImageAlpha", alpha255)
+
+ // Outline
+ // Resolve outline using same logic (0=Default, 1=System, 2=Custom)
+ fun resolveOutlineColor(idx: Int): Int {
+ return when (idx) {
+ 0 -> context.getColor(R.color.widget_outline) // Default
+ 1 -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ context.getColor(android.R.color.system_accent1_500)
+ } else {
+ android.graphics.Color.CYAN
+ }
+ 2 -> {
+ val r = prefs.getInt("outline_color_r", 255)
+ val g = prefs.getInt("outline_color_g", 255)
+ val b = prefs.getInt("outline_color_b", 255)
+ android.graphics.Color.rgb(r, g, b)
+ }
+ else -> context.getColor(R.color.widget_outline)
+ }
+ }
+
+ val showOutline = prefs.getBoolean("show_outline", true)
+ val outlineColor = resolveOutlineColor(outlineColorIdx)
+ views.setImageViewResource(R.id.widget_outline, R.drawable.widget_bg_outline)
+ views.setViewVisibility(R.id.widget_outline, if (showOutline) android.view.View.VISIBLE else android.view.View.GONE)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ views.setColorStateList(R.id.widget_outline, "setImageTintList", android.content.res.ColorStateList.valueOf(outlineColor))
+ } else {
+ views.setInt(R.id.widget_outline, "setColorFilter", outlineColor)
+ }
+ views.setInt(R.id.widget_outline, "setImageAlpha", 255)
+
+ // Resolve Colors
+ fun resolveColor(idx: Int, isPrimary: Boolean, isLight: Boolean): Int {
+ return ColorResolver.resolveColor(
+ context = context,
+ prefs = prefs,
+ useDynamicColors = useDynamicColors,
+ idx = idx,
+ isPrimary = isPrimary,
+ isLight = isLight
+ )
+ }
+
+ val primaryColor = resolveColor(textColorPrimaryIdx, true, useLightTheme)
+ val secondaryColor = resolveColor(textColorSecondaryIdx, false, useLightTheme)
+
+ // Slightly distinct colors for date and next alarm
+ val dateColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ // Warm accent for date
+ context.getColor(if (useLightTheme) android.R.color.system_accent2_700 else android.R.color.system_accent2_100)
+ } else {
+ if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
+ }
+ val alarmColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ // Cool tertiary accent for alarm
+ context.getColor(if (useLightTheme) android.R.color.system_accent3_700 else android.R.color.system_accent3_100)
+ } else {
+ if (useLightTheme) android.graphics.Color.parseColor("#AA445566") else android.graphics.Color.parseColor("#BBAACCDD")
+ }
+
+ // Background & outline dynamic color
+ if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ // Warm neutral surface for background (overrides custom bg color when dynamic is on)
+ views.setColorStateList(R.id.widget_background, "setImageTintList", android.content.res.ColorStateList.valueOf(context.getColor(if (useLightTheme) android.R.color.system_neutral2_50 else android.R.color.system_neutral1_800)))
+ // Accent-tinted outline
+ if (showOutline) {
+ views.setColorStateList(R.id.widget_outline, "setImageTintList", android.content.res.ColorStateList.valueOf(context.getColor(if (useLightTheme) android.R.color.system_accent1_300 else android.R.color.system_accent1_400)))
+ }
+ }
+
+ if (mode == UpdateMode.TICK) {
+ val tickViews = RemoteViews(context.packageName, layoutId)
+ val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { ifilter ->
+ context.registerReceiver(null, ifilter)
+ }
+ val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: 0
+ val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: 100
+ val batteryPct = (level * 100 / scale.toFloat()).toInt()
+ val batterySpannable = android.text.SpannableString("${batteryPct}%")
+ batterySpannable.setSpan(android.text.style.RelativeSizeSpan(0.5f), batterySpannable.length - 1, batterySpannable.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ val tempInt = batteryStatus?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
+ val tempVal = tempInt / 10f
+ if (showSteps) loadStepCount(context, tickViews, prefs)
+ if (showBattery) tickViews.setTextViewText(R.id.text_battery, batterySpannable)
+ if (showTemp) {
+ val tempStr = String.format("%.1f", tempVal)
+ val tempText = "$tempStr°C"
+ val tempSpan = android.text.SpannableString(tempText)
+ val cIdx = tempText.indexOf("°C")
+ if (cIdx != -1) {
+ tempSpan.setSpan(android.text.style.RelativeSizeSpan(0.5f), cIdx, cIdx + 2, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ if (boldTemp) tempSpan.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, tempSpan.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ tickViews.setTextViewText(R.id.text_temp, tempSpan)
+ }
+ if (showData) updateDataUsage(context, tickViews, prefs)
+ if (showStorage) updateStorageStats(context, tickViews, prefs)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetId, tickViews)
+ return
+ } else if (mode == UpdateMode.CALENDAR_ONLY) {
+ val calViews = RemoteViews(context.packageName, layoutId)
+ if (showEvents) loadCalendarEvents(context, calViews, sizeEvents, primaryColor, secondaryColor)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetId, calViews)
+ return
+ } else if (mode == UpdateMode.TASKS_ONLY) {
+ val taskViews = RemoteViews(context.packageName, layoutId)
+ if (showTasks) loadTasks(context, taskViews, sizeTasks, primaryColor)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetId, taskViews)
+ return
+ } else if (mode == UpdateMode.ALARM_ONLY) {
+ val alarmViews = RemoteViews(context.packageName, layoutId)
+ if (showNextAlarm) loadNextAlarm(context, alarmViews, sizeNextAlarm, secondaryColor)
+ appWidgetManager.partiallyUpdateAppWidget(appWidgetId, alarmViews)
+ return
+ }
+
+ // --- Apply Time ---
+ val timeVisible = showTime || showWorldClock
+ views.setViewVisibility(R.id.time_container, if (timeVisible) android.view.View.VISIBLE else android.view.View.GONE)
+
+ views.setViewVisibility(R.id.clock_time, if (showTime) android.view.View.VISIBLE else android.view.View.GONE)
+ views.setTextViewTextSize(R.id.clock_time, android.util.TypedValue.COMPLEX_UNIT_SP, sizeTime)
+ views.setTextColor(R.id.clock_time, primaryColor)
+
+ val (timeFormat12, timeFormat24) = when(timeFormatIdx) {
+ 0 -> "h:mm" to "H:mm"
+ 1 -> "H:mm" to "H:mm"
+ else -> "h:mm" to "H:mm"
+ }
+ views.setCharSequence(R.id.clock_time, "setFormat12Hour", timeFormat12)
+ views.setCharSequence(R.id.clock_time, "setFormat24Hour", timeFormat24)
+
+ // --- World Clock ---
+ views.setViewVisibility(R.id.text_world_clock, if (showWorldClock) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showWorldClock) {
+ loadWorldClock(views, sizeWorldClock, secondaryColor, worldClockZoneStr, timeFormat12.contains("a"))
+ }
+
+ // --- Apply Date ---
+ val dateVisible = showDate || showNextAlarm
+ views.setViewVisibility(R.id.date_container, if (dateVisible) android.view.View.VISIBLE else android.view.View.GONE)
+
+ views.setViewVisibility(R.id.clock_date, if (showDate) android.view.View.VISIBLE else android.view.View.GONE)
+ views.setTextViewTextSize(R.id.clock_date, android.util.TypedValue.COMPLEX_UNIT_SP, sizeDate)
+ views.setTextColor(R.id.clock_date, dateColor)
+
+ val (dateFormat12, dateFormat24) = when(dateFormatIdx) {
+ 0 -> "EEEE, MMMM dd" to "EEEE, MMMM dd"
+ 1 -> "EEE, MMM dd" to "EEE, MMM dd"
+ 2 -> "dd/MM/yyyy" to "dd/MM/yyyy"
+ else -> "EEEE, MMMM dd" to "EEEE, MMMM dd"
+ }
+ views.setCharSequence(R.id.clock_date, "setFormat12Hour", dateFormat12)
+ views.setCharSequence(R.id.clock_date, "setFormat24Hour", dateFormat24)
+
+ // --- Apply Battery & Temp ---
+ views.setViewVisibility(R.id.text_battery, if (showBattery) android.view.View.VISIBLE else android.view.View.GONE)
+ views.setTextViewTextSize(R.id.text_battery, android.util.TypedValue.COMPLEX_UNIT_SP, sizeBattery)
+
+ views.setViewVisibility(R.id.text_temp, if (showTemp) android.view.View.VISIBLE else android.view.View.GONE)
+ views.setTextViewTextSize(R.id.text_temp, android.util.TypedValue.COMPLEX_UNIT_SP, sizeTemp)
+ views.setTextColor(R.id.text_battery, secondaryColor)
+ views.setTextColor(R.id.text_temp, secondaryColor)
+
+ val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { ifilter ->
+ context.registerReceiver(null, ifilter)
+ }
+
+ val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: 0
+ val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: 100
+ val batteryPct = (level * 100 / scale.toFloat()).toInt()
+
+ val tempInt = batteryStatus?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
+ val tempVal = tempInt / 10f
+
+ if (showBattery) {
+ val batterySpannable = android.text.SpannableString("${batteryPct}%")
+ batterySpannable.setSpan(android.text.style.RelativeSizeSpan(0.5f), batterySpannable.length - 1, batterySpannable.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ if (boldBattery) batterySpannable.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, batterySpannable.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ views.setTextViewText(R.id.text_battery, batterySpannable)
+ }
+ if (showTemp) {
+ val tempStr = String.format("%.1f", tempVal)
+ val tempText = "$tempStr°C"
+ val tempSpan = android.text.SpannableString(tempText)
+ val cIdx = tempText.indexOf("°C")
+ if (cIdx != -1) {
+ tempSpan.setSpan(android.text.style.RelativeSizeSpan(0.5f), cIdx, cIdx + 2, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ if (boldTemp) tempSpan.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, tempSpan.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ views.setTextViewText(R.id.text_temp, tempSpan)
+ }
+
+ // --- Weather Condition ---
+ val showWeather = showWeatherCondition && bweather != null
+ views.setViewVisibility(R.id.text_weather_condition, if (showWeather) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showWeather && bweather != null) {
+ var weatherCode = bweather.currentConditionCode
+ var weatherText = bweather.currentCondition
+ var hasWarning = false
+
+ // Check week forecasts for warnings
+ val forecasts = bweather.forecasts
+ if (forecasts != null && forecasts.isNotEmpty()) {
+ for ((index, forecast) in forecasts.take(7).withIndex()) {
+ val fCode = forecast.conditionCode
+ if (fCode != null && (
+ fCode in listOf(500, 501, 502, 503, 504, 511, 520, 521, 522, 531) || // Rain
+ fCode in listOf(600, 601, 602, 611, 612, 615, 616, 620, 621, 622) || // Snow
+ fCode in listOf(210, 211, 212, 221, 230, 231, 232) // Storm
+ )) {
+ hasWarning = true
+ weatherCode = fCode
+
+ // Determine string representation of the day
+ val dayText = when (index) {
+ 0 -> "today"
+ 1 -> "tomorrow"
+ else -> {
+ val localDate = java.time.LocalDate.now().plusDays(index.toLong())
+ localDate.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.getDefault())
+ }
+ }
+
+ // Extract probability and create warning string
+ val precipString = if (forecast.precipProbability != null && forecast.precipProbability > 0) "${forecast.precipProbability}% " else ""
+ val conditionWarning = when (fCode) {
+ in listOf(500, 501, 502, 503, 504, 511, 520, 521, 522, 531) -> if (index <= 1) "Rain $dayText" else "Rain on $dayText"
+ in listOf(600, 601, 602, 611, 612, 615, 616, 620, 621, 622) -> if (index <= 1) "Snow $dayText" else "Snow on $dayText"
+ in listOf(210, 211, 212, 221, 230, 231, 232) -> if (index <= 1) "Storm $dayText" else "Storm on $dayText"
+ else -> "Warning"
+ }
+ weatherText = "$precipString$conditionWarning"
+ break
+ }
+ }
+ }
+
+ var conditionText = weatherText
+ if (conditionText.isNullOrEmpty()) {
+ conditionText = "Unknown"
+ }
+
+ // Get weather icon string representation
+ val weatherIcon = when (weatherCode) {
+ 800 -> "☀️" // Clear
+ 801, 802 -> "⛅" // Partly Cloudy
+ 803, 804 -> "☁️" // Cloudy
+ 500, 501, 502, 503, 504, 511, 520, 521, 522, 531 -> "🌧️" // Rain
+ 600, 601, 602, 611, 612, 615, 616, 620, 621, 622 -> "❄️" // Snow
+ 771 -> "🌬️" // Wind
+ 741 -> "🌫️" // Fog
+ 751 -> "🌁" // Haze
+ 210, 211, 212, 221, 230, 231, 232 -> "⛈️" // Thunderstorm
+ else -> ""
+ }
+
+ val displayString = if (showWeatherIconOnly && !hasWarning) weatherIcon else "$conditionText $weatherIcon"
+
+ // If it has warning format like "Rain on Sun 🌧️", make " on Sun 🌧️" smaller
+ if (hasWarning) {
+ val fullMatch = weatherText ?: "Unknown"
+ val span = android.text.SpannableString(displayString.trim())
+
+ // The day portion is the last word for today/tomorrow, or the last two words for "on Sun"
+ val lastSpaceIdx = fullMatch.lastIndexOf(' ')
+ val onSpaceIdx = fullMatch.lastIndexOf(" on ")
+
+ val shrinkStartIndex = if (onSpaceIdx != -1) onSpaceIdx else lastSpaceIdx
+ if (shrinkStartIndex != -1 && shrinkStartIndex < span.length) {
+ span.setSpan(android.text.style.RelativeSizeSpan(0.75f), shrinkStartIndex, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ } else if (weatherIcon.isNotEmpty()) {
+ span.setSpan(android.text.style.RelativeSizeSpan(0.75f), span.length - weatherIcon.length, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ if (boldWeather) span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ views.setTextViewText(R.id.text_weather_condition, span)
+ } else {
+ val weatherSpan = android.text.SpannableString(displayString.trim())
+ if (boldWeather) weatherSpan.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, weatherSpan.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ views.setTextViewText(R.id.text_weather_condition, weatherSpan)
+ }
+ views.setTextViewTextSize(R.id.text_weather_condition, android.util.TypedValue.COMPLEX_UNIT_SP, sizeWeather)
+ views.setTextColor(R.id.text_weather_condition, secondaryColor)
+
+ val launchIntent = context.packageManager.getLaunchIntentForPackage("org.breezyweather")
+ if (launchIntent != null) {
+ val pendingIntent = android.app.PendingIntent.getActivity(
+ context, 0, launchIntent,
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.text_weather_condition, pendingIntent)
+ }
+ }
+
+ // --- Data Usage ---
+ views.setViewVisibility(R.id.text_data_usage, if (showData) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showData) {
+ views.setTextViewTextSize(R.id.text_data_usage, android.util.TypedValue.COMPLEX_UNIT_SP, sizeData)
+ views.setTextColor(R.id.text_data_usage, secondaryColor)
+ updateDataUsage(context, views, prefs)
+ }
+
+ // --- Storage ---
+ views.setViewVisibility(R.id.text_storage, if (showStorage) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showStorage) {
+ views.setTextViewTextSize(R.id.text_storage, android.util.TypedValue.COMPLEX_UNIT_SP, sizeStorage)
+ views.setTextColor(R.id.text_storage, secondaryColor)
+ updateStorageStats(context, views, prefs)
+ }
+
+ // --- Step Counter ---
+ views.setViewVisibility(R.id.text_steps, if (showSteps) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showSteps) {
+ views.setTextViewTextSize(R.id.text_steps, android.util.TypedValue.COMPLEX_UNIT_SP, sizeSteps)
+ views.setTextColor(R.id.text_steps, secondaryColor)
+ loadStepCount(context, views, prefs)
+ }
+
+ // --- Screen Time ---
+ val showScreenTime = prefs.getBoolean("show_screen_time", false)
+ val sizeScreenTime = prefs.getFloat("size_screen_time", 14f)
+ views.setViewVisibility(R.id.text_screen_time, if (showScreenTime) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showScreenTime) {
+ views.setTextViewTextSize(R.id.text_screen_time, android.util.TypedValue.COMPLEX_UNIT_SP, sizeScreenTime)
+ views.setTextColor(R.id.text_screen_time, secondaryColor)
+ updateScreenTime(context, views, prefs)
+ }
+
+ // --- Dynamic Spacing Logic for Both Sides ---
+ fun dpToPx(dp: Float): Int {
+ return (dp * context.resources.displayMetrics.density).toInt()
+ }
+
+ // To precisely manage font intrinsic top padding, we drop the container's top padding to 0
+ // and apply a custom top padding precisely computed based on the top item sizes.
+ val basePadding = dpToPx(16f)
+ views.setViewPadding(R.id.inner_container, basePadding, 0, basePadding, basePadding)
+
+ // Left Side: Time or Date or Events
+ if (showTime || showWorldClock) {
+ val size = if (showTime) sizeTime else sizeWorldClock
+ val intrinsicGap = size * 0.18f
+ views.setViewPadding(R.id.time_container, 0, maxOf(0, dpToPx(16f - intrinsicGap)), 0, 0)
+ views.setViewPadding(R.id.date_container, 0, 0, 0, 0)
+ } else if (showDate || showNextAlarm) {
+ views.setViewPadding(R.id.time_container, 0, 0, 0, 0)
+ val size = if (showDate) sizeDate else sizeNextAlarm
+ val intrinsicGap = size * 0.18f
+ views.setViewPadding(R.id.date_container, 0, maxOf(0, dpToPx(16f - intrinsicGap)), 0, 0)
+ } else {
+ views.setViewPadding(R.id.time_container, 0, 0, 0, 0)
+ views.setViewPadding(R.id.date_container, 0, 0, 0, 0)
+ }
+
+ // Events container: add spacing gap below date/time if they exist
+ val eventsVisible = showEvents || showTasks
+ val leftHasContent = (showTime || showWorldClock || showDate || showNextAlarm)
+ if (eventsVisible) {
+ val topMargin = if (leftHasContent) dpToPx(8f) else {
+ val size = if (showEvents) sizeEvents else sizeTasks
+ val intrinsicGap = size * 0.18f
+ maxOf(0, dpToPx(16f - intrinsicGap))
+ }
+ views.setViewPadding(R.id.events_container, 0, topMargin, 0, 0)
+ }
+
+ // Right Side Stack: ordered by user preference
+ data class StackEntry(val viewId: Int, val isVisible: Boolean, val size: Float, val key: String)
+
+ val allRightItems = listOf(
+ StackEntry(R.id.text_battery, showBattery, sizeBattery, "show_battery"),
+ StackEntry(R.id.text_temp, showTemp, sizeTemp, "show_temp"),
+ StackEntry(R.id.text_weather_condition, showWeather, sizeWeather, "show_weather_condition"),
+ StackEntry(R.id.text_data_usage, showData, sizeData, "show_data_usage"),
+ StackEntry(R.id.text_storage, showStorage, sizeStorage, "show_storage"),
+ StackEntry(R.id.text_steps, showSteps, sizeSteps, "show_steps"),
+ StackEntry(R.id.text_screen_time, showScreenTime, sizeScreenTime, "show_screen_time")
+ )
+
+ val savedOrder = prefs.getString("widget_right_column_order", "")
+ val rightStack = if (savedOrder.isNullOrEmpty()) {
+ allRightItems
+ } else {
+ val orderKeys = savedOrder.split(",")
+ val ordered = orderKeys.mapNotNull { k -> allRightItems.find { it.key == k } }
+ val remaining = allRightItems.filter { item -> item.key !in orderKeys }
+ ordered + remaining
+ }
+
+ // Position items using explicit padding instead of layout_below
+ // Calculate cumulative Y positions for each visible item
+ val rightDp = context.resources.displayMetrics.density
+ var cumulativeTopDp = 16f // Starting top margin from top of widget
+ for (entry in rightStack) {
+ if (entry.isVisible) {
+ val topPaddingPx = (cumulativeTopDp * rightDp).toInt()
+ views.setViewPadding(entry.viewId, 0, topPaddingPx, 0, 0)
+ // Advance by this item's height + small gap
+ val itemHeightDp = entry.size * 1.2f // approximate line height
+ cumulativeTopDp += itemHeightDp + 2f
+ }
+ }
+
+ // --- Click Actions ---
+ val clockPackages = listOf("com.android.deskclock", "com.google.android.deskclock", "com.simplemobiletools.clock", "org.fossify.clock")
+ val alarmIntent = getBestIntent(context, clockPackages, Intent(android.provider.AlarmClock.ACTION_SHOW_ALARMS))
+ val alarmPendingIntent = PendingIntent.getActivity(context, 0, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.clock_time, alarmPendingIntent)
+
+ val calendarPackages = listOf("org.fossify.calendar", "com.simplemobiletools.calendar", "com.google.android.calendar", "com.android.calendar")
+ val baseCalIntent = Intent(Intent.ACTION_VIEW).apply {
+ data = android.net.Uri.parse("content://com.android.calendar/time")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ val calendarIntent = getBestIntent(context, calendarPackages, baseCalIntent)
+ val calendarPendingIntent = PendingIntent.getActivity(context, 1, calendarIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.clock_date, calendarPendingIntent)
+
+ val batteryIntent = Intent(Intent.ACTION_POWER_USAGE_SUMMARY)
+ val batteryPendingIntent = PendingIntent.getActivity(context, 2, batteryIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.text_battery, batteryPendingIntent)
+ views.setOnClickPendingIntent(R.id.text_temp, batteryPendingIntent)
+
+ val storageIntent = Intent(android.provider.Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
+ val storagePendingIntent = PendingIntent.getActivity(context, 3, storageIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.text_storage, storagePendingIntent)
+
+ val dataIntent = Intent(android.provider.Settings.ACTION_DATA_USAGE_SETTINGS)
+ val dataPendingIntent = PendingIntent.getActivity(context, 4, dataIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.text_data_usage, dataPendingIntent)
+
+ // --- Calendar Events OR Tasks ---
+ views.setViewVisibility(R.id.events_container, if (showEvents || showTasks) android.view.View.VISIBLE else android.view.View.GONE)
+
+ if (showEvents) {
+ loadCalendarEvents(context, views, sizeEvents, primaryColor, secondaryColor)
+ } else if (showTasks) {
+ loadTasks(context, views, sizeTasks, primaryColor)
+ }
+
+ // --- Next Alarm ---
+ views.setViewVisibility(R.id.text_next_alarm, if (showNextAlarm) android.view.View.VISIBLE else android.view.View.GONE)
+ if (showNextAlarm) {
+ loadNextAlarm(context, views, sizeNextAlarm, alarmColor)
+ }
+ // Click action for Next Alarm (same as Clock)
+ views.setOnClickPendingIntent(R.id.text_next_alarm, alarmPendingIntent)
+
+ val refreshIntent = Intent(context, AwidgetProvider::class.java).apply {
+ action = ACTION_BATTERY_UPDATE
+ }
+ val refreshPendingIntent = PendingIntent.getBroadcast(context, 10, refreshIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+
+ if (showTasks) {
+ val tasksIntent = context.packageManager.getLaunchIntentForPackage("org.tasks")
+ if (tasksIntent != null) {
+ val tasksPendingIntent = PendingIntent.getActivity(context, 11, tasksIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.events_container, tasksPendingIntent)
+ } else {
+ views.setOnClickPendingIntent(R.id.events_container, refreshPendingIntent)
+ }
+ } else {
+ views.setOnClickPendingIntent(R.id.events_container, refreshPendingIntent)
+ }
+
+ val settingsIntent = Intent(context, MainActivity::class.java)
+ val settingsPendingIntent = PendingIntent.getActivity(context, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.widget_root, settingsPendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+
+
+ data class EventInfo(val id: Long, val title: String, val begin: Long, val isLocal: Boolean)
+
+ private fun fetchCalendarEvents(context: Context): List {
+ val syncedCalendarIds = mutableSetOf()
+ val visibleCalendarIds = mutableSetOf()
+
+ val calSelection = "${android.provider.CalendarContract.Calendars.VISIBLE} = 1"
+
+ context.contentResolver.query(
+ android.provider.CalendarContract.Calendars.CONTENT_URI,
+ arrayOf(
+ android.provider.CalendarContract.Calendars._ID,
+ android.provider.CalendarContract.Calendars.ACCOUNT_TYPE,
+ android.provider.CalendarContract.Calendars.ACCOUNT_NAME,
+ android.provider.CalendarContract.Calendars.CALENDAR_DISPLAY_NAME
+ ),
+ calSelection, null, null
+ )?.use { cursor ->
+ val idIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars._ID)
+ val nameIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.ACCOUNT_NAME)
+ val displayIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)
+ while (cursor.moveToNext()) {
+ val calId = cursor.getLong(idIdx)
+ val accountName = cursor.getString(nameIdx) ?: ""
+ val displayName = cursor.getString(displayIdx) ?: ""
+
+ visibleCalendarIds.add(calId)
+
+ if (displayName.contains("holiday", ignoreCase = true) ||
+ accountName.contains("holiday", ignoreCase = true)) {
+ syncedCalendarIds.add(calId)
+ }
+ }
+ }
+
+ if (visibleCalendarIds.isEmpty()) return emptyList()
+
+ val projection = arrayOf(
+ android.provider.CalendarContract.Instances.EVENT_ID,
+ android.provider.CalendarContract.Events.TITLE,
+ android.provider.CalendarContract.Instances.BEGIN,
+ android.provider.CalendarContract.Instances.CALENDAR_ID
+ )
+
+ val now = System.currentTimeMillis()
+ val endQuery = now + android.text.format.DateUtils.DAY_IN_MILLIS * 30
+
+ val uri = android.provider.CalendarContract.Instances.CONTENT_URI.buildUpon()
+ .appendPath(now.toString())
+ .appendPath(endQuery.toString())
+ .build()
+
+ val idList = visibleCalendarIds.joinToString(",")
+ val selection = "${android.provider.CalendarContract.Instances.END} >= ? AND ${android.provider.CalendarContract.Instances.CALENDAR_ID} IN ($idList)"
+ val selectionArgs = arrayOf(now.toString())
+ val sortOrder = "${android.provider.CalendarContract.Instances.BEGIN} ASC"
+
+ val events = mutableListOf()
+
+ context.contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)?.use { cursor ->
+ val eventIdIdx = cursor.getColumnIndex(android.provider.CalendarContract.Instances.EVENT_ID)
+ val titleIdx = cursor.getColumnIndex(android.provider.CalendarContract.Events.TITLE)
+ val beginIdx = cursor.getColumnIndex(android.provider.CalendarContract.Instances.BEGIN)
+ val calIdIdx = cursor.getColumnIndex(android.provider.CalendarContract.Instances.CALENDAR_ID)
+
+ while (cursor.moveToNext() && events.size < 10) {
+ val eventId = cursor.getLong(eventIdIdx)
+ val title = cursor.getString(titleIdx) ?: "No Title"
+ val begin = cursor.getLong(beginIdx)
+ val calId = cursor.getLong(calIdIdx)
+ val isLocal = !syncedCalendarIds.contains(calId)
+ events.add(EventInfo(eventId, title, begin, isLocal))
+ }
+ }
+ return events
+ }
+
+ private fun bindCalendarEvents(context: Context, views: RemoteViews, events: List, textSizeSp: Float, primaryColor: Int, secondaryColor: Int, eventViews: List) {
+ val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault())
+ val dayFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault())
+ val dateFormatter = DateTimeFormatter.ofPattern("d MMM h:mma", Locale.getDefault())
+
+ if (events.isEmpty()) {
+ views.setTextViewText(eventViews[0], "No events today")
+ views.setTextColor(eventViews[0], secondaryColor)
+ views.setTextViewTextSize(eventViews[0], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
+ views.setViewVisibility(eventViews[0], android.view.View.VISIBLE)
+
+ val emptyIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(eventViews[0], emptyIntent)
+
+ for (i in 1 until eventViews.size) {
+ views.setViewVisibility(eventViews[i], android.view.View.GONE)
+ }
+ return
+ }
+
+ for (i in eventViews.indices) {
+ if (i < events.size) {
+ val event = events[i]
+ val eventTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(event.begin), ZoneId.systemDefault())
+ val today = LocalDate.now()
+ val tomorrow = today.plusDays(1)
+ val oneWeekLater = today.plusWeeks(1)
+
+ val timeText = if (eventTime.toLocalDate().isEqual(today)) {
+ "Today ${eventTime.format(timeFormatter)}"
+ } else if (eventTime.toLocalDate().isEqual(tomorrow)) {
+ "Tomorrow ${eventTime.format(timeFormatter)}"
+ } else if (eventTime.toLocalDate().isBefore(oneWeekLater)) {
+ "${eventTime.format(dayFormatter)} ${eventTime.format(timeFormatter)}"
+ } else {
+ eventTime.format(dateFormatter)
+ }
+
+ val fullText = "• $timeText ${event.title}"
+ val spannable = SpannableString(fullText)
+ val accentColor = context.getColor(R.color.widget_outline)
+ spannable.setSpan(ForegroundColorSpan(accentColor), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ views.setTextViewText(eventViews[i], spannable)
+ views.setTextColor(eventViews[i], if (event.isLocal) primaryColor else secondaryColor)
+ views.setTextViewTextSize(eventViews[i], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
+ views.setViewVisibility(eventViews[i], android.view.View.VISIBLE)
+
+ val eventIntent = Intent(Intent.ACTION_VIEW).apply {
+ data = android.content.ContentUris.withAppendedId(android.provider.CalendarContract.Events.CONTENT_URI, event.id)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val eventPendingIntent = PendingIntent.getActivity(context, event.id.toInt(), eventIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(eventViews[i], eventPendingIntent)
+ } else {
+ views.setViewVisibility(eventViews[i], android.view.View.GONE)
+ }
+ }
+ }
+
+ private fun loadCalendarEvents(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int, secondaryColor: Int) {
+ if (androidx.core.content.ContextCompat.checkSelfPermission(
+ context, android.Manifest.permission.READ_CALENDAR
+ ) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+
+ val eventViews = listOf(
+ R.id.text_event_1, R.id.text_event_2, R.id.text_event_3,
+ R.id.text_event_4, R.id.text_event_5, R.id.text_event_6,
+ R.id.text_event_7, R.id.text_event_8, R.id.text_event_9,
+ R.id.text_event_10
+ )
+
+ try {
+ val events = fetchCalendarEvents(context)
+ bindCalendarEvents(context, views, events, textSizeSp, primaryColor, secondaryColor, eventViews)
+ } catch (e: Exception) {
+ // Log and gracefully handle crash
+ android.util.Log.e("LWidget", "Error loading calendar events", e)
+ for (viewId in eventViews) {
+ views.setViewVisibility(viewId, android.view.View.GONE)
+ }
+ }
+ }
+
+ private fun updateScreenTime(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) {
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
+ val mode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ appOps.unsafeCheckOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName)
+ } else {
+ appOps.checkOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName)
+ }
+ if (mode != android.app.AppOpsManager.MODE_ALLOWED) {
+ views.setViewVisibility(R.id.text_screen_time, android.view.View.GONE)
+ return
+ }
+
+ val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as android.app.usage.UsageStatsManager
+
+ val calendar = java.util.Calendar.getInstance()
+ calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
+ calendar.set(java.util.Calendar.MINUTE, 0)
+ calendar.set(java.util.Calendar.SECOND, 0)
+ calendar.set(java.util.Calendar.MILLISECOND, 0)
+
+ val startTime = calendar.timeInMillis
+ val endTime = System.currentTimeMillis()
+
+ val stats = usageStatsManager.queryUsageStats(android.app.usage.UsageStatsManager.INTERVAL_DAILY, startTime, endTime)
+
+ var totalForegroundTime = 0L
+ if (stats != null) {
+ for (usage in stats) {
+ // Only count significant foreground usage correctly reported
+ if (usage.totalTimeInForeground > 0) {
+ totalForegroundTime += usage.totalTimeInForeground
+ }
+ }
+ }
+
+ val isBold = prefs.getBoolean("bold_screen_time", false)
+
+ if (totalForegroundTime > 0) {
+ val totalMinutes = totalForegroundTime / (1000 * 60)
+ val hours = totalMinutes / 60
+ val mins = totalMinutes % 60
+ val timeString = if (hours > 0) "${hours}h ${mins}m \u23F3" else "${mins}m \u23F3" // ⏳
+ val span = android.text.SpannableString(timeString)
+ span.setSpan(android.text.style.RelativeSizeSpan(0.75f), span.length - 2, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ if (isBold) span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ views.setTextViewText(R.id.text_screen_time, span)
+ } else {
+ val span = android.text.SpannableString("0m \u23F3")
+ span.setSpan(android.text.style.RelativeSizeSpan(0.75f), span.length - 2, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ if (isBold) span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ views.setTextViewText(R.id.text_screen_time, span)
+ }
+ }
+
+ private fun updateDataUsage(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) {
+ val networkStatsManager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
+ // Use java.time
+ val startOfDay = LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ val endTime = System.currentTimeMillis()
+
+ try {
+ val bucket = networkStatsManager.querySummaryForDevice(
+ NetworkCapabilities.TRANSPORT_CELLULAR,
+ null,
+ startOfDay,
+ endTime
+ )
+
+ val bytes = bucket.rxBytes + bucket.txBytes
+ val mb = bytes / (1024f * 1024f)
+ val gb = mb / 1024f
+
+ val text: CharSequence = if (gb >= 1.0f) {
+ val gbStr = String.format("%.2f", gb)
+ val span = android.text.SpannableString("$gbStr GB")
+ span.setSpan(android.text.style.RelativeSizeSpan(0.5f), gbStr.length, gbStr.length + 3, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // GB
+ span
+ } else {
+ val mbStr = String.format("%.1f", mb)
+ val span = android.text.SpannableString("$mbStr MB")
+ span.setSpan(android.text.style.RelativeSizeSpan(0.5f), mbStr.length, mbStr.length + 3, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // MB
+ span
+ }
+
+ if (prefs.getBoolean("bold_data_usage", false) && text is android.text.SpannableString) {
+ text.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, text.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+
+ views.setTextViewText(R.id.text_data_usage, text)
+
+ } catch (e: SecurityException) {
+ val res = context.resources
+ // Assuming R.string.no_perm exists (we created strings.xml)
+ views.setTextViewText(R.id.text_data_usage, res.getString(R.string.no_perm))
+ } catch (e: Exception) {
+ val res = context.resources
+ views.setTextViewText(R.id.text_data_usage, res.getString(R.string.error))
+ }
+ }
+
+ private data class TaskData(val title: String, val dueMillis: Long)
+
+ private fun fetchActiveTasks(context: Context, limit: Int): List {
+ val tasks = mutableListOf()
+ val taskUri = android.net.Uri.parse("content://org.tasks/tasks")
+ val selection = "completed=0 AND deleted=0"
+ try {
+ context.contentResolver.query(taskUri, null, selection, null, "dueDate ASC")?.use { cursor ->
+ val titleIdx = cursor.getColumnIndex("title")
+ val compIdx = cursor.getColumnIndex("completed")
+ val delIdx = cursor.getColumnIndex("deleted")
+ val dueIdx = cursor.getColumnIndex("dueDate")
+
+ if (titleIdx == -1) return emptyList()
+
+ while (cursor.moveToNext() && tasks.size < limit) {
+ val completed = if (compIdx >= 0) cursor.getString(compIdx) else null
+ val deleted = if (delIdx >= 0) cursor.getString(delIdx) else null
+ val dueMillis = if (dueIdx >= 0) cursor.getLong(dueIdx) else 0L
+
+ val isCompleted = completed != null && completed != "0"
+ val isDeleted = deleted != null && deleted != "0"
+
+ if (isCompleted || isDeleted) {
+ continue
+ }
+
+ val title = cursor.getString(titleIdx) ?: "No Title"
+ tasks.add(TaskData(title, dueMillis))
+ }
+ }
+ } catch (e: Exception) {
+ // Return empty list on failure
+ }
+ return tasks
+ }
+
+ private fun formatDueSuffix(dueMillis: Long): String {
+ if (dueMillis <= 0) return ""
+ val dueDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(dueMillis), ZoneId.systemDefault()).toLocalDate()
+ val today = LocalDate.now()
+ val tomorrow = today.plusDays(1)
+
+ return if (dueDate.isBefore(today)) {
+ " (Overdue)"
+ } else if (dueDate.isEqual(today)) {
+ " (Today)"
+ } else if (dueDate.isEqual(tomorrow)) {
+ " (Tomorrow)"
+ } else {
+ val df = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
+ " (${dueDate.format(df)})"
+ }
+ }
+
+ private fun loadTasks(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int) {
+ val eventViews = listOf(
+ R.id.text_event_1, R.id.text_event_2, R.id.text_event_3,
+ R.id.text_event_4, R.id.text_event_5, R.id.text_event_6,
+ R.id.text_event_7, R.id.text_event_8, R.id.text_event_9,
+ R.id.text_event_10
+ )
+
+ // Debugging: Check permission again contextually
+ val hasPerm = context.checkSelfPermission(PERMISSION_READ_TASKS_ORG) == android.content.pm.PackageManager.PERMISSION_GRANTED ||
+ context.checkSelfPermission(PERMISSION_READ_TASKS_ASTRID) == android.content.pm.PackageManager.PERMISSION_GRANTED
+
+ if (!hasPerm) {
+ views.setTextViewText(eventViews[0], "Missing Permission")
+ views.setViewVisibility(eventViews[0], android.view.View.VISIBLE)
+ for (j in 1 until eventViews.size) {
+ views.setViewVisibility(eventViews[j], android.view.View.GONE)
+ }
+ return
+ }
+
+ val tasks = fetchActiveTasks(context, eventViews.size)
+
+ if (tasks.isEmpty()) {
+ for (viewId in eventViews) {
+ views.setViewVisibility(viewId, android.view.View.GONE)
+ }
+ return
+ }
+
+ for (i in tasks.indices) {
+ val task = tasks[i]
+ val dueSuffix = formatDueSuffix(task.dueMillis)
+ val fullText = "• ${task.title}$dueSuffix"
+ val spannable = SpannableString(fullText)
+ val accentColor = context.getColor(R.color.widget_outline)
+ spannable.setSpan(ForegroundColorSpan(accentColor), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ views.setTextViewText(eventViews[i], spannable)
+ views.setTextColor(eventViews[i], primaryColor)
+ views.setTextViewTextSize(eventViews[i], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
+ views.setViewVisibility(eventViews[i], android.view.View.VISIBLE)
+
+ val taskIntent = context.packageManager.getLaunchIntentForPackage("org.tasks")
+ if (taskIntent != null) {
+ val taskPendingIntent = PendingIntent.getActivity(context, 1000 + i, taskIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(eventViews[i], taskPendingIntent)
+ }
+ }
+
+ for (j in tasks.size until eventViews.size) {
+ views.setViewVisibility(eventViews[j], android.view.View.GONE)
+ }
+ }
+
+ private fun loadWorldClock(views: RemoteViews, textSizeSp: Float, textColor: Int, zoneIdStr: String, is12Hour: Boolean) {
+ try {
+ val zoneId = ZoneId.of(zoneIdStr)
+ val zdt = java.time.ZonedDateTime.now(zoneId)
+ val pattern = if (is12Hour) "h:mm a" else "H:mm"
+ val formatter = DateTimeFormatter.ofPattern(pattern, Locale.getDefault())
+ val timeStr = zdt.format(formatter)
+
+ // Format: "🌍 10:30 AM"
+ val text = "\uD83C\uDF0D $timeStr"
+ views.setTextViewText(R.id.text_world_clock, text)
+ views.setTextViewTextSize(R.id.text_world_clock, android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
+ views.setTextColor(R.id.text_world_clock, textColor)
+ views.setViewVisibility(R.id.text_world_clock, android.view.View.VISIBLE)
+
+ } catch (e: Exception) {
+ views.setViewVisibility(R.id.text_world_clock, android.view.View.GONE)
+ }
+ }
+
+ private fun loadNextAlarm(context: Context, views: RemoteViews, textSizeSp: Float, textColor: Int) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val nextAlarm = alarmManager.nextAlarmClock
+
+ if (nextAlarm != null) {
+ val nextAlarmTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(nextAlarm.triggerTime), ZoneId.systemDefault())
+ val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault())
+ val timeText = nextAlarmTime.format(timeFormatter)
+
+ // Format: "| ⏰ 7:00 AM"
+ val fullText = "| ⏰ $timeText"
+ views.setTextViewText(R.id.text_next_alarm, fullText)
+ views.setTextViewTextSize(R.id.text_next_alarm, android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp)
+ views.setTextColor(R.id.text_next_alarm, textColor)
+ views.setViewVisibility(R.id.text_next_alarm, android.view.View.VISIBLE)
+ } else {
+ views.setViewVisibility(R.id.text_next_alarm, android.view.View.GONE)
+ }
+ }
+
+ private fun updateStorageStats(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) {
+ try {
+ val path = android.os.Environment.getDataDirectory()
+ val stat = android.os.StatFs(path.path)
+ val freeBytes = stat.availableBlocksLong * stat.blockSizeLong
+
+ val gb = freeBytes / (1024f * 1024f * 1024f)
+
+ val gbStr = String.format("%.0f", gb)
+ val span = android.text.SpannableString("$gbStr GB")
+ span.setSpan(android.text.style.RelativeSizeSpan(0.5f), gbStr.length, gbStr.length + 3, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // GB
+
+ if (prefs.getBoolean("bold_storage", false)) {
+ span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+
+ views.setTextViewText(R.id.text_storage, span)
+ } catch (e: Exception) {
+ views.setTextViewText(R.id.text_storage, "Err")
+ }
+ }
+
+ private fun loadStepCount(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) {
+ try {
+ val totalSteps = prefs.getFloat("last_total_steps", 0f)
+ val baselineSteps = prefs.getFloat("step_baseline", 0f)
+
+ val dailySteps = (totalSteps - baselineSteps).toInt().coerceAtLeast(0)
+ val span = android.text.SpannableString("$dailySteps \uD83D\uDC5F") // 👟 outline sneaker
+ span.setSpan(android.text.style.RelativeSizeSpan(0.75f), span.length - 2, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ if (prefs.getBoolean("bold_steps", false)) {
+ span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+
+ views.setTextViewText(R.id.text_steps, span)
+ } catch (e: Exception) {
+ // Fallback display if an exception occurs
+ views.setTextViewText(R.id.text_steps, "Err")
+ }
+ }
+
+ private fun getBestIntent(context: Context, packages: List, fallback: Intent): Intent {
+ val pm = context.packageManager
+ for (pkg in packages) {
+ val intent = pm.getLaunchIntentForPackage(pkg)
+ if (intent != null) {
+ return intent
+ }
+ }
+ return fallback
+ }
+ }
+}
diff --git a/app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt b/app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt
new file mode 100644
index 0000000..39a60a2
--- /dev/null
+++ b/app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt
@@ -0,0 +1,51 @@
+package com.leanbitlab.lwidget
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Color
+import android.os.Build
+
+object ColorResolver {
+ fun resolveColor(
+ context: Context,
+ prefs: SharedPreferences,
+ useDynamicColors: Boolean,
+ idx: Int,
+ isPrimary: Boolean,
+ isLight: Boolean,
+ sdkInt: Int = Build.VERSION.SDK_INT
+ ): Int {
+ // When dynamic colors is on, always use dynamic palette regardless of saved color index
+ if (useDynamicColors && sdkInt >= Build.VERSION_CODES.S) {
+ return if (isPrimary) {
+ // High contrast accent for time & battery
+ context.getColor(if (isLight) android.R.color.system_accent1_800 else android.R.color.system_accent1_50)
+ } else {
+ // Muted neutral for secondary items (temp, data, storage, steps)
+ context.getColor(if (isLight) android.R.color.system_neutral2_600 else android.R.color.system_neutral2_300)
+ }
+ }
+ return when (idx) {
+ 0 -> { // Default
+ if (isPrimary) {
+ if (isLight) context.getColor(R.color.widget_text_light) else Color.WHITE
+ } else {
+ if (isLight) context.getColor(R.color.widget_text_secondary_light) else Color.parseColor("#CCFFFFFF")
+ }
+ }
+ 1 -> if (sdkInt >= Build.VERSION_CODES.S) {
+ context.getColor(android.R.color.system_accent1_500)
+ } else {
+ Color.CYAN
+ }
+ 2 -> {
+ val prefix = if (isPrimary) "text_color_primary" else "text_color_secondary"
+ val r = prefs.getInt("${prefix}_r", 255)
+ val g = prefs.getInt("${prefix}_g", 255)
+ val b = prefs.getInt("${prefix}_b", 255)
+ Color.rgb(r, g, b)
+ }
+ else -> if (isPrimary) Color.WHITE else Color.parseColor("#CCFFFFFF")
+ }
+ }
+}
diff --git a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
index 097cf74..3c4b1a4 100644
--- a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
+++ b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
@@ -193,7 +193,7 @@ class MainActivity : AppCompatActivity() {
}
// Check Tasks
- if (prefs.getBoolean("show_tasks", false) && ContextCompat.checkSelfPermission(this, "org.tasks.permission.READ_TASKS") != PackageManager.PERMISSION_GRANTED) {
+ if (prefs.getBoolean("show_tasks", false) && ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) {
prefs.edit().putBoolean("show_tasks", false).apply()
findViewById(R.id.row_tasks_toggle).findViewById(R.id.row_switch).isChecked = false
findViewById(R.id.row_tasks_size).visibility = View.GONE
@@ -739,8 +739,25 @@ class MainActivity : AppCompatActivity() {
val timeFormatOptions = listOf(getString(R.string.format_12h), getString(R.string.format_24h))
val colorOptions = listOf(getString(R.string.color_default), getString(R.string.color_system_accent), getString(R.string.color_custom))
+ setupTimeSection(timeFormatOptions)
+ setupNextAlarmSection()
+ setupWorldClockSection(zoneIds)
+ setupDateSection(dateFormatOptions)
+ setupBatterySection()
+ setupTempSection()
+ setupWeatherSection()
+ setupDataUsageSection()
+ setupStorageSection()
+ setupStepsSection()
+ setupScreenTimeSection()
+ setupKeepAliveSection()
+ setupEventsAndTasksSections()
+ setupThemeSection(colorOptions)
+ }
+
+ private fun setupTimeSection(timeFormatOptions: List) {
// Time
- val timeSwitch = bindFoldedSection(
+ bindFoldedSection(
R.id.header_time, R.drawable.ic_time, getString(R.string.section_time),
R.id.content_time, R.id.row_time_toggle,
"show_time", true,
@@ -748,7 +765,8 @@ class MainActivity : AppCompatActivity() {
selectorRowId = R.id.row_time_format, selectorOptions = timeFormatOptions, prefSelectorKey = "time_format_idx", defSelectorIdx = 0,
isContent = true
)
-
+ }
+ private fun setupNextAlarmSection() {
// Next Alarm
bindFoldedSection(
R.id.header_next_alarm, R.drawable.ic_alarm, getString(R.string.section_next_alarm),
@@ -757,9 +775,10 @@ class MainActivity : AppCompatActivity() {
sizeRowId = R.id.row_next_alarm_size, prefSizeKey = "size_next_alarm", defSize = 14f, minSize = 10f, maxSize = 24f,
isContent = true
)
-
+ }
+ private fun setupWorldClockSection(zoneIds: List) {
// World Clock
- val worldClockSwitch = bindFoldedSection(
+ bindFoldedSection(
R.id.header_world_clock, R.drawable.ic_world, getString(R.string.section_world_clock),
R.id.content_world_clock, R.id.row_world_clock_toggle,
"show_world_clock", false,
@@ -767,7 +786,8 @@ class MainActivity : AppCompatActivity() {
isContent = true
)
bindTimezoneSearch(R.id.row_world_clock_zone, zoneIds, "world_clock_zone_str", "UTC")
-
+ }
+ private fun setupDateSection(dateFormatOptions: List) {
// Date
bindFoldedSection(
R.id.header_date, R.drawable.ic_date, getString(R.string.section_date),
@@ -777,9 +797,10 @@ class MainActivity : AppCompatActivity() {
selectorRowId = R.id.row_date_format, selectorOptions = dateFormatOptions, prefSelectorKey = "date_format_idx", defSelectorIdx = 0,
isContent = true
)
-
+ }
+ private fun setupBatterySection() {
// Battery
- val batterySwitch = bindFoldedSection(
+ bindFoldedSection(
R.id.header_battery, R.drawable.ic_battery, getString(R.string.section_battery),
R.id.content_battery, R.id.row_battery_toggle,
"show_battery", true,
@@ -787,7 +808,8 @@ class MainActivity : AppCompatActivity() {
isContent = true
).also { it.tag = "battery" }
bindToggle(R.id.row_battery_bold, "Bold Text", "bold_battery", false)
-
+ }
+ private fun setupTempSection() {
// Temp
bindFoldedSection(
R.id.header_temp, R.drawable.ic_temp, getString(R.string.section_temp),
@@ -797,7 +819,8 @@ class MainActivity : AppCompatActivity() {
isContent = true
).also { it.tag = "temp" }
bindToggle(R.id.row_temp_bold, "Bold Text", "bold_temp", false)
-
+ }
+ private fun setupWeatherSection() {
// Weather
val weatherSwitch = bindFoldedSection(
R.id.header_weather, R.drawable.ic_weather, getString(R.string.section_weather_condition),
@@ -853,7 +876,8 @@ class MainActivity : AppCompatActivity() {
.show()
}
}
-
+ }
+ private fun setupDataUsageSection() {
// Data Usage
val dataSwitch = bindFoldedSection(
R.id.header_data, R.drawable.ic_data, getString(R.string.section_data_usage),
@@ -889,7 +913,8 @@ class MainActivity : AppCompatActivity() {
updateToggleAvailability()
checkAllPermissions()
}
-
+ }
+ private fun setupStorageSection() {
// Storage
bindFoldedSection(
R.id.header_storage, R.drawable.ic_storage, getString(R.string.section_storage),
@@ -899,7 +924,8 @@ class MainActivity : AppCompatActivity() {
isContent = true
).also { it.tag = "storage" }
bindToggle(R.id.row_storage_bold, "Bold Text", "bold_storage", false)
-
+ }
+ private fun setupStepsSection() {
// Steps
val stepsSwitch = bindFoldedSection(
R.id.header_steps, R.drawable.ic_steps, getString(R.string.section_steps),
@@ -953,7 +979,8 @@ class MainActivity : AppCompatActivity() {
updateToggleAvailability()
checkAllPermissions()
}
-
+ }
+ private fun setupScreenTimeSection() {
// Screen Time
val screenTimeSwitch = bindFoldedSection(
R.id.header_screen_time, R.drawable.ic_time, getString(R.string.section_screen_time),
@@ -989,7 +1016,8 @@ class MainActivity : AppCompatActivity() {
updateToggleAvailability()
checkAllPermissions()
}
-
+ }
+ private fun setupKeepAliveSection() {
// Keep Alive
val keepAliveSwitch = bindFoldedSection(
R.id.header_keep_alive, R.drawable.ic_alarm, getString(R.string.section_keep_alive),
@@ -1022,7 +1050,8 @@ class MainActivity : AppCompatActivity() {
else { stopService(serviceIntent) }
updateWidget()
}
-
+ }
+ private fun setupEventsAndTasksSections() {
// Events
val eventsSwitch = bindFoldedSection(
R.id.header_events, R.drawable.ic_events, getString(R.string.section_events),
@@ -1065,7 +1094,6 @@ class MainActivity : AppCompatActivity() {
checkAllPermissions()
}
}
-
tasksSwitch.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
if (!isAppInstalled("org.tasks")) {
@@ -1083,8 +1111,8 @@ class MainActivity : AppCompatActivity() {
}.show()
return@setOnCheckedChangeListener
}
- if (ContextCompat.checkSelfPermission(this, "org.tasks.permission.READ_TASKS") != PackageManager.PERMISSION_GRANTED) {
- ActivityCompat.requestPermissions(this, arrayOf("org.tasks.permission.READ_TASKS"), 101)
+ if (ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(AwidgetProvider.PERMISSION_READ_TASKS_ORG), 101)
}
if (checkLimit()) {
eventsSwitch.isChecked = false
@@ -1102,7 +1130,8 @@ class MainActivity : AppCompatActivity() {
checkAllPermissions()
}
}
-
+ }
+ private fun setupThemeSection(colorOptions: List) {
// ===== THEME =====
// Use bindFoldedSection for the main card (top-level accordion), not bindNestedCard
accordionViews["section_appearance_expanded"] = findViewById(R.id.content_appearance)
@@ -1162,6 +1191,7 @@ class MainActivity : AppCompatActivity() {
prefs.edit()
.putInt("text_color_primary_idx", 0)
.putInt("text_color_secondary_idx", 0)
+ .putInt("date_color_idx", 0)
.putInt("outline_color_idx", 0)
.putInt("bg_color_idx", 0)
.apply()
@@ -1206,6 +1236,15 @@ class MainActivity : AppCompatActivity() {
bindColorSliders(R.id.row_text_color_secondary_custom, "text_color_secondary")
secondarySliderRow.visibility = if (prefs.getInt("text_color_secondary_idx", 0) == 2) View.VISIBLE else View.GONE
+ // Date Color
+ val dateSliderRow = findViewById(R.id.row_date_color_custom)
+ bindSelector(R.id.row_date_color, getString(R.string.section_date_color), "date_color_idx", colorOptions, 0) { idx ->
+ dateSliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
+ if (idx != 2) updateWidget()
+ }
+ bindColorSliders(R.id.row_date_color_custom, "date_color")
+ dateSliderRow.visibility = if (prefs.getInt("date_color_idx", 0) == 2) View.VISIBLE else View.GONE
+
// Outline Color
val outlineSliderRow = findViewById(R.id.row_outline_color_custom)
bindSelector(R.id.row_outline_color, getString(R.string.section_outline_color), "outline_color_idx", colorOptions, 0) { idx ->
@@ -1368,6 +1407,7 @@ class MainActivity : AppCompatActivity() {
R.id.row_bg_color, R.id.row_bg_color_custom,
R.id.row_text_color_primary, R.id.row_text_color_primary_custom,
R.id.row_text_color_secondary, R.id.row_text_color_secondary_custom,
+ R.id.row_date_color, R.id.row_date_color_custom,
R.id.row_outline_color, R.id.row_outline_color_custom
)
manualColorIds.forEach { id ->
diff --git a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig
new file mode 100644
index 0000000..98ed38a
--- /dev/null
+++ b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig
@@ -0,0 +1,1489 @@
+/*
+ * Copyright (C) 2026 LeanBitLab
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.leanbitlab.lwidget
+
+import android.Manifest
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.view.View
+import android.widget.AutoCompleteTextView
+import android.widget.ListView
+import android.widget.TextView
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+import com.google.android.material.slider.Slider
+import com.google.android.material.switchmaterial.SwitchMaterial
+
+// Data class for reorderable items
+data class ReorderItem(
+ val key: String, // e.g. "show_battery"
+ val label: String, // e.g. "Battery"
+ var enabled: Boolean
+)
+
+// Adapter for reorder RecyclerView
+class ReorderAdapter(
+ private val items: MutableList,
+ private val onOrderChanged: () -> Unit
+) : RecyclerView.Adapter() {
+
+ class ViewHolder(val view: android.view.View) : RecyclerView.ViewHolder(view) {
+ val handle: android.widget.ImageView = view.findViewById(R.id.reorder_handle)
+ val name: TextView = view.findViewById(R.id.reorder_item_name)
+ val enabled: SwitchMaterial = view.findViewById(R.id.reorder_item_enabled)
+ }
+
+ override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): ViewHolder {
+ val view = android.view.LayoutInflater.from(parent.context)
+ .inflate(R.layout.settings_reorder_item, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = items[position]
+ holder.name.text = item.label
+ holder.enabled.isChecked = item.enabled
+ }
+
+ override fun getItemCount() = items.size
+
+ fun moveItem(from: Int, to: Int) {
+ val moved = items.removeAt(from)
+ items.add(to, moved)
+ notifyItemMoved(from, to)
+ onOrderChanged()
+ }
+}
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var prefs: SharedPreferences
+ private val contentSwitches = mutableListOf()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ com.google.android.material.color.DynamicColors.applyToActivityIfAvailable(this)
+ setContentView(R.layout.activity_main)
+
+ prefs = getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
+
+ checkAllPermissions()
+ setupSections()
+
+ // Setup Changelog
+ val versionName = try {
+ packageManager.getPackageInfo(packageName, 0).versionName
+ } catch (e: Exception) {
+ "Unknown"
+ }
+ val tvVersion = findViewById(R.id.tv_changelog_version)
+ tvVersion.text = getString(R.string.changelog_version, versionName)
+
+ val cardChangelog = findViewById(R.id.card_changelog)
+ val changelogContent = findViewById(R.id.changelog_expandable_content)
+ val ivChangelogExpand = findViewById(R.id.iv_changelog_expand)
+ cardChangelog.setOnClickListener {
+ val isCurrentlyVisible = changelogContent.visibility == View.VISIBLE
+ changelogContent.visibility = if (isCurrentlyVisible) View.GONE else View.VISIBLE
+ ivChangelogExpand.animate().rotation(if (isCurrentlyVisible) 0f else 180f).setDuration(200).start()
+ }
+
+ // Prevent parent scroll when touching the inner changelog scroll area
+ findViewById(R.id.changelog_scroll).setOnTouchListener { v, event ->
+ when (event.action) {
+ android.view.MotionEvent.ACTION_DOWN, android.view.MotionEvent.ACTION_MOVE ->
+ v.parent.requestDisallowInterceptTouchEvent(true)
+ android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL ->
+ v.parent.requestDisallowInterceptTouchEvent(false)
+ }
+ false
+ }
+
+ findViewById(R.id.tv_github_link).setOnClickListener {
+ val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/LeanBitLab/Lwidget"))
+ startActivity(intent)
+ }
+
+ findViewById(R.id.tv_privacy_policy).setOnClickListener {
+ val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/LeanBitLab/Lwidget/wiki/Privacy-Policy"))
+ startActivity(intent)
+ }
+
+
+ val fab = findViewById(R.id.fab_update)
+ fab.setOnClickListener {
+ updateWidget()
+ }
+
+ // Apply navigation bar insets to FAB so it doesn't overlap gesture nav
+ ViewCompat.setOnApplyWindowInsetsListener(fab) { view, windowInsets ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.updateLayoutParams {
+ bottomMargin = insets.bottom + (24 * resources.displayMetrics.density).toInt()
+ rightMargin = insets.right + (24 * resources.displayMetrics.density).toInt()
+ }
+ windowInsets
+ }
+
+ // Handle Collapsing Toolbar Title Fade and Header Fade
+ val appBar = findViewById(R.id.app_bar)
+ val titleApp = findViewById(R.id.title_app)
+ val expandedHeader = findViewById(R.id.header_expanded)
+
+ appBar.addOnOffsetChangedListener(com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener { _, verticalOffset ->
+ val totalScrollRange = appBar.totalScrollRange
+ val percentage = kotlin.math.abs(verticalOffset).toFloat() / totalScrollRange.toFloat()
+
+ // Fade in toolbar title when nearing collapse (e.g. last 20% of scroll)
+ val alphaTitle = ((percentage - 0.8f) / 0.2f).coerceIn(0f, 1f)
+ titleApp.alpha = alphaTitle
+
+ // Fade out expanded header as we scroll up (first 50% of scroll)
+ // Starts dense (1f) and fades to 0f by the time we are halfway collapsed
+ val alphaHeader = (1f - (percentage / 0.5f)).coerceIn(0f, 1f)
+ expandedHeader.alpha = alphaHeader
+ // Optional: Scale down slightly for a nicer effect
+ val scale = (1f - (percentage * 0.1f)).coerceIn(0.9f, 1f)
+ expandedHeader.scaleX = scale
+ expandedHeader.scaleY = scale
+ })
+ }
+
+ private fun checkAllPermissions() {
+ val cardPermissionList = findViewById(R.id.card_permission_list)
+ var widgetNeedsUpdate = false
+
+ // Check Calendar
+ if (prefs.getBoolean("show_events", false) && ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
+ prefs.edit().putBoolean("show_events", false).apply()
+ findViewById(R.id.row_events_toggle).findViewById(R.id.row_switch).isChecked = false
+ findViewById(R.id.row_events_size).visibility = View.GONE
+ widgetNeedsUpdate = true
+ }
+
+ // Check Tasks
+ if (prefs.getBoolean("show_tasks", false) && ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) {
+ prefs.edit().putBoolean("show_tasks", false).apply()
+ findViewById(R.id.row_tasks_toggle).findViewById(R.id.row_switch).isChecked = false
+ findViewById(R.id.row_tasks_size).visibility = View.GONE
+ widgetNeedsUpdate = true
+ }
+
+ // Check Steps
+ var stepMissing = false
+ if (prefs.getBoolean("show_steps", false)) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
+ stepMissing = true
+ }
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ stepMissing = true
+ }
+ }
+ if (stepMissing) {
+ prefs.edit().putBoolean("show_steps", false).apply()
+ findViewById(R.id.row_steps_toggle).findViewById(R.id.row_switch).isChecked = false
+ findViewById(R.id.row_steps_size).visibility = View.GONE
+ widgetNeedsUpdate = true
+ }
+
+ // Check Screen Time
+ if (prefs.getBoolean("show_screen_time", false) && !hasUsageStatsPermission()) {
+ prefs.edit().putBoolean("show_screen_time", false).apply()
+ findViewById(R.id.row_screen_time_toggle).findViewById(R.id.row_switch).isChecked = false
+ findViewById(R.id.row_screen_time_size).visibility = View.GONE
+ widgetNeedsUpdate = true
+ }
+
+ // Check Data Usage
+ if (prefs.getBoolean("show_data_usage", false) && !hasUsageStatsPermission()) {
+ prefs.edit().putBoolean("show_data_usage", false).apply()
+ findViewById(R.id.row_data_toggle).findViewById(R.id.row_switch).isChecked = false
+ findViewById(R.id.row_data_size).visibility = View.GONE
+ widgetNeedsUpdate = true
+ }
+
+ // Check Breezy Weather
+ if (prefs.getBoolean("show_weather_condition", false)) {
+ if (!isAppInstalled("org.breezyweather") || ContextCompat.checkSelfPermission(this, "org.breezyweather.READ_PROVIDER") != PackageManager.PERMISSION_GRANTED) {
+ prefs.edit().putBoolean("show_weather_condition", false).apply()
+ findViewById(R.id.row_weather_toggle).findViewById(R.id.row_switch).isChecked = false
+ findViewById(R.id.row_weather_size).visibility = View.GONE
+ widgetNeedsUpdate = true
+ }
+ }
+
+ cardPermissionList.visibility = View.GONE
+
+ if (widgetNeedsUpdate) {
+ updateWidget()
+ updateToggleAvailability()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Re-check permissions when returning (especially for Data Usage settings)
+ checkAllPermissions()
+ // Force a full widget update every time the app is opened
+ updateWidget()
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ checkAllPermissions()
+ if (requestCode == 101) {
+ // Task permission result - trigger update
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ updateWidget()
+ }
+ } else if (requestCode == 103) {
+ // Breezy Weather permission result
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ prefs.edit().putBoolean("show_weather_condition", true).apply()
+ findViewById(R.id.row_weather_toggle).findViewById(R.id.row_switch).isChecked = true
+ findViewById(R.id.row_weather_size).visibility = View.VISIBLE
+ updateWidget()
+ updateToggleAvailability()
+
+ // Show Gadgetbridge module prompt
+ android.app.AlertDialog.Builder(this)
+ .setTitle("Important Step")
+ .setMessage("If the weather doesn't show up on your widget soon:\n\nOpen Breezy Weather → Settings → External Modules → Enable 'Send Gadgetbridge Data' & toggle on 'Lwidget'.")
+ .setPositiveButton("Got it", null)
+ .show()
+ }
+ }
+ }
+
+ // ===== FOLDED SECTION HELPERS =====
+
+ // Top-level accordion: feature cards (Time, Battery, Appearance, etc.)
+ private val accordionViews = mutableMapOf()
+ private val accordionHeaders = mutableMapOf()
+ // Nested sections: own accordion among themselves
+ private val nestedViews = mutableMapOf()
+ private val nestedHeaders = mutableMapOf()
+
+ private fun collapseAllExcept(exceptKey: String) {
+ accordionViews.forEach { (key, view) ->
+ if (key != exceptKey && view.visibility == View.VISIBLE) {
+ // Extract section name from key like "section_world_clock_expanded"
+ val sectionName = key.replace("section_", "").replace("_expanded", "")
+ collapseSectionNestedContent(sectionName)
+ view.visibility = View.GONE
+ prefs.edit().putBoolean(key, false).apply()
+ accordionHeaders[key]?.let { resetChevron(it) }
+ }
+ }
+ dismissKeyboard()
+ }
+
+ private fun collapseNestedExcept(exceptKey: String) {
+ nestedViews.forEach { (key, view) ->
+ if (key != exceptKey && view.visibility == View.VISIBLE) {
+ view.visibility = View.GONE
+ prefs.edit().putBoolean(key, false).apply()
+ nestedHeaders[key]?.let { resetChevron(it) }
+ }
+ }
+ }
+
+ private fun resetChevron(header: View) {
+ val chevron = header.findViewById(R.id.header_chevron)
+ ?: header.findViewById(R.id.header_chevron_appearance_outline)
+ ?: header.findViewById(R.id.header_chevron_appearance_colors)
+ ?: header.findViewById(R.id.header_chevron_appearance_theme)
+ ?: header.findViewById(R.id.header_chevron_appearance_font)
+ ?: header.findViewById(R.id.header_chevron_appearance_transparency)
+ chevron?.rotation = 0f
+ }
+
+ private fun bindFoldedSection(
+ headerId: Int, iconResId: Int?, title: String,
+ contentId: Int,
+ toggleRowId: Int,
+ prefShowKey: String, defShow: Boolean,
+ sizeRowId: Int? = null, prefSizeKey: String? = null,
+ defSize: Float = 14f, minSize: Float = 10f, maxSize: Float = 72f,
+ selectorRowId: Int? = null, selectorOptions: List? = null,
+ prefSelectorKey: String? = null, defSelectorIdx: Int = 0,
+ isContent: Boolean = false,
+ subSettingsContainerId: Int? = null,
+ validateToggle: ((Boolean) -> Boolean)? = null,
+ onChanged: ((Boolean) -> Unit)? = null
+ ): SwitchMaterial {
+ val header = findViewById(headerId)
+ val chevron = header.findViewById(R.id.header_chevron)
+ val headerIcon = header.findViewById(R.id.header_icon)
+ val headerTitle = header.findViewById(R.id.header_title)
+ val content = findViewById(contentId)
+
+ val sectionKey = prefShowKey.replace("show_", "")
+ val expandedPrefKey = "section_${sectionKey}_expanded"
+ accordionViews[expandedPrefKey] = content
+ accordionHeaders[expandedPrefKey] = header
+
+ headerTitle.text = title
+ if (iconResId != null) {
+ headerIcon.setImageResource(iconResId)
+ headerIcon.visibility = View.VISIBLE
+ } else {
+ headerIcon.visibility = View.GONE
+ }
+
+ // Expand/collapse - read from prefs, apply visibility
+ val isExpandedFromPrefs = prefs.getBoolean(expandedPrefKey, false)
+ content.visibility = if (isExpandedFromPrefs) View.VISIBLE else View.GONE
+ chevron.rotation = if (isExpandedFromPrefs) 180f else 0f
+
+ // Header click: expand this one and collapse all others
+ header.setOnClickListener {
+ val nowExpanded = content.visibility != View.VISIBLE
+ if (nowExpanded) {
+ collapseAllExcept(expandedPrefKey)
+ content.visibility = View.VISIBLE
+ prefs.edit().putBoolean(expandedPrefKey, true).apply()
+ } else {
+ // Collapsing - dismiss keyboard and close nested subsections
+ collapseSectionNestedContent(sectionKey)
+ content.visibility = View.GONE
+ prefs.edit().putBoolean(expandedPrefKey, false).apply()
+ }
+ android.animation.ObjectAnimator.ofFloat(chevron, "rotation", if (nowExpanded) 180f else 0f).apply {
+ duration = 300
+ start()
+ }
+ }
+
+ // Toggle row
+ val toggleRow = findViewById(toggleRowId)
+ val toggleSwitch = toggleRow.findViewById(R.id.row_switch)
+ val toggleLabel = toggleRow.findViewById(R.id.row_label)
+ val toggleCard = toggleRow.findViewById(R.id.toggle_row_card)
+ toggleLabel.text = "Enable"
+
+ if (isContent) contentSwitches.add(toggleSwitch)
+
+ val isShown = prefs.getBoolean(prefShowKey, defShow)
+ toggleSwitch.isChecked = isShown
+
+ // Sub-settings alpha
+ val subSettings = subSettingsContainerId?.let { findViewById(it) }
+ subSettings?.alpha = if (isShown) 1.0f else 0.4f
+ updateToggleCardStyle(toggleCard, isShown)
+
+ // Size row visibility
+ val sizeRow = sizeRowId?.let { findViewById(it) }
+ sizeRow?.visibility = if (isShown) View.VISIBLE else View.GONE
+
+ // Selector row visibility
+ val selectorRow = selectorRowId?.let { findViewById(it) }
+ selectorRow?.visibility = if (isShown) View.VISIBLE else View.GONE
+
+ onChanged?.invoke(isShown)
+
+ // Internal listener - ALWAYS handles visibility
+ toggleSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked && !checkLimit()) {
+ toggleSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ if (validateToggle?.invoke(isChecked) == false) {
+ toggleSwitch.isChecked = !isChecked
+ return@setOnCheckedChangeListener
+ }
+ prefs.edit().putBoolean(prefShowKey, isChecked).apply()
+ subSettings?.alpha = if (isChecked) 1.0f else 0.4f
+ updateToggleCardStyle(toggleCard, isChecked)
+ sizeRow?.visibility = if (isChecked) View.VISIBLE else View.GONE
+ selectorRow?.visibility = if (isChecked) View.VISIBLE else View.GONE
+ onChanged?.invoke(isChecked)
+ updateWidget()
+ if (isContent) updateToggleAvailability()
+ }
+
+ // Size row setup
+ if (sizeRowId != null && prefSizeKey != null) {
+ val sizeRowInner = findViewById(sizeRowId)
+ val slider = sizeRowInner.findViewById(R.id.row_slider)
+ val valueLabel = sizeRowInner.findViewById(R.id.row_value)
+ val sizeLabel = sizeRowInner.findViewById(R.id.row_label)
+ sizeLabel.text = "Size"
+
+ val currentSize = prefs.getFloat(prefSizeKey, defSize)
+ slider.valueFrom = minSize
+ slider.valueTo = maxSize
+ slider.value = currentSize.coerceIn(minSize, maxSize)
+ valueLabel.text = "${currentSize.toInt()}"
+
+ slider.addOnChangeListener { _, value, fromUser ->
+ if (fromUser) {
+ valueLabel.text = "${value.toInt()}"
+ prefs.edit().putFloat(prefSizeKey, value).apply()
+ updateWidget()
+ }
+ }
+ }
+
+ // Selector row setup (skip world clock timezone - uses custom search layout)
+ if (selectorRowId != null && selectorOptions != null && prefSelectorKey != null && prefSelectorKey != "world_clock_zone_str") {
+ val selectorRowInner = findViewById(selectorRowId)
+ val autoCompleteTextView = selectorRowInner.findViewById(R.id.row_value)
+ val selectorLabel = selectorRowInner.findViewById(R.id.row_label)
+ selectorLabel.text = title
+
+ val adapter = android.widget.ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, selectorOptions)
+ autoCompleteTextView.setAdapter(adapter)
+
+ if (prefSelectorKey == "world_clock_zone_str") {
+ val currentVal = prefs.getString(prefSelectorKey, "UTC") ?: "UTC"
+ autoCompleteTextView.setText(currentVal, false)
+ autoCompleteTextView.setOnItemClickListener { _, _, position, _ ->
+ val selected = selectorOptions.getOrElse(position) { "UTC" }
+ prefs.edit().putString(prefSelectorKey, selected).apply()
+ updateWidget()
+ selectorRowInner.clearFocus()
+ autoCompleteTextView.clearFocus()
+ }
+ } else {
+ val currentIdx = prefs.getInt(prefSelectorKey, defSelectorIdx)
+ autoCompleteTextView.setText(selectorOptions.getOrElse(currentIdx) { selectorOptions[defSelectorIdx] }, false)
+ autoCompleteTextView.setOnItemClickListener { _, _, position, _ ->
+ prefs.edit().putInt(prefSelectorKey, position).apply()
+ updateWidget()
+ selectorRowInner.clearFocus()
+ autoCompleteTextView.clearFocus()
+ }
+ }
+
+ // Clear focus/dropdown shade when dismissed
+ autoCompleteTextView.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) {
+ autoCompleteTextView.clearFocus()
+ }
+ }
+ }
+
+ return toggleSwitch
+ }
+
+ private fun updateToggleCardStyle(card: com.google.android.material.card.MaterialCardView?, enabled: Boolean) {
+ if (card == null) return
+ if (enabled) {
+ card.setCardBackgroundColor(android.graphics.Color.TRANSPARENT)
+ card.strokeWidth = (1f * resources.displayMetrics.density).toInt()
+ card.setStrokeColor(android.content.res.ColorStateList.valueOf(
+ com.google.android.material.color.MaterialColors.getColor(card, com.google.android.material.R.attr.colorPrimary)
+ ))
+ } else {
+ card.setCardBackgroundColor(
+ com.google.android.material.color.MaterialColors.getColor(card, com.google.android.material.R.attr.colorSurfaceContainerLow)
+ )
+ card.setStrokeColor(android.content.res.ColorStateList.valueOf(
+ com.google.android.material.color.MaterialColors.getColor(card, com.google.android.material.R.attr.colorOutlineVariant)
+ ))
+ }
+ }
+
+ // Helper for callers who override the toggle listener to update row visibility
+ private fun updateFeatureRowVisibility(switch: SwitchMaterial, isChecked: Boolean, sizeRowId: Int? = null) {
+ val toggleRow = switch.parent as? View
+ val toggleCard = toggleRow?.findViewById(R.id.toggle_row_card)
+ updateToggleCardStyle(toggleCard, isChecked)
+ sizeRowId?.let { findViewById(it)?.visibility = if (isChecked) View.VISIBLE else View.GONE }
+ }
+
+ private fun bindTimezoneSearch(
+ rowId: Int, zoneIds: List, prefKey: String, defaultVal: String
+ ) {
+ val row = findViewById(rowId)
+ val searchEdit = row.findViewById(R.id.zone_search_edit)
+ val listView = row.findViewById(R.id.zone_search_list)
+
+ val currentVal = prefs.getString(prefKey, defaultVal) ?: defaultVal
+ searchEdit.setText(currentVal)
+
+ var filteredList: MutableList = mutableListOf()
+ val filteredAdapter = android.widget.ArrayAdapter(this, android.R.layout.simple_list_item_1, filteredList)
+
+ // Text watcher for filtering
+ searchEdit.addTextChangedListener(object : android.text.TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+ override fun afterTextChanged(s: android.text.Editable?) {
+ val query = s?.toString()?.lowercase() ?: ""
+ if (query.isEmpty()) {
+ filteredList.clear()
+ listView.visibility = View.GONE
+ return
+ }
+ filteredList.clear()
+ filteredList.addAll(zoneIds.filter { it.lowercase().contains(query) })
+ filteredAdapter.notifyDataSetChanged()
+ listView.visibility = if (filteredList.isEmpty()) View.GONE else View.VISIBLE
+ }
+ })
+
+ listView.adapter = filteredAdapter
+ listView.setOnItemClickListener { _, _, position, _ ->
+ val selected = filteredList[position]
+ searchEdit.setText(selected)
+ listView.visibility = View.GONE
+ searchEdit.clearFocus()
+ prefs.edit().putString(prefKey, selected).apply()
+ updateWidget()
+ }
+
+ // Intercept touch events so parent NestedScrollView doesn't steal them
+ listView.setOnTouchListener { v, event ->
+ v.parent.requestDisallowInterceptTouchEvent(true)
+ false
+ }
+
+ // Show list on focus
+ searchEdit.setOnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ val query = searchEdit.text?.toString()?.lowercase() ?: ""
+ if (query.isEmpty()) {
+ filteredList.clear()
+ filteredList.addAll(zoneIds)
+ filteredAdapter.notifyDataSetChanged()
+ listView.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+
+ // Dismiss keyboard and collapse nested subsections when a parent section collapses
+ private fun collapseSectionNestedContent(sectionKey: String) {
+ // World clock timezone search
+ if (sectionKey == "world_clock") {
+ val worldClockZoneRow = findViewById(R.id.row_world_clock_zone)
+ val searchEdit = worldClockZoneRow?.findViewById(R.id.zone_search_edit)
+ val listView = worldClockZoneRow?.findViewById(R.id.zone_search_list)
+ searchEdit?.clearFocus()
+ listView?.visibility = View.GONE
+ }
+ // Appearance reorder section
+ if (sectionKey == "appearance") {
+ // collapse reorder too
+ }
+ // Appearance subsections
+ if (sectionKey == "appearance") {
+ val outlineContent = findViewById(R.id.content_appearance_outline)
+ val colorsContent = findViewById(R.id.content_appearance_colors)
+ val themeContent = findViewById(R.id.content_appearance_theme)
+ val fontContent = findViewById(R.id.content_appearance_font)
+ val transparencyContent = findViewById(R.id.content_appearance_transparency)
+ outlineContent?.visibility = View.GONE
+ colorsContent?.visibility = View.GONE
+ themeContent?.visibility = View.GONE
+ fontContent?.visibility = View.GONE
+ transparencyContent?.visibility = View.GONE
+ prefs.edit()
+ .putBoolean("section_appearance_outline_expanded", false)
+ .putBoolean("section_appearance_colors_expanded", false)
+ .putBoolean("section_appearance_theme_expanded", false)
+ .putBoolean("section_appearance_font_expanded", false)
+ .putBoolean("section_appearance_transparency_expanded", false)
+ .apply()
+ // Reset nested chevrons
+ listOf(
+ R.id.header_chevron_appearance_outline,
+ R.id.header_chevron_appearance_colors,
+ R.id.header_chevron_appearance_theme,
+ R.id.header_chevron_appearance_font,
+ R.id.header_chevron_appearance_transparency
+ ).forEach { id ->
+ findViewById(id)?.rotation = 0f
+ }
+ }
+ dismissKeyboard()
+ }
+
+ private fun dismissKeyboard() {
+ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
+ currentFocus?.let { imm?.hideSoftInputFromWindow(it.windowToken, 0) }
+ }
+
+ private fun bindReorderSection() {
+ val defaultOrder = listOf(
+ ReorderItem("show_battery", getString(R.string.section_battery), prefs.getBoolean("show_battery", true)),
+ ReorderItem("show_temp", getString(R.string.section_temp), prefs.getBoolean("show_temp", true)),
+ ReorderItem("show_weather_condition", getString(R.string.section_weather_condition), prefs.getBoolean("show_weather_condition", false)),
+ ReorderItem("show_data_usage", getString(R.string.section_data_usage), prefs.getBoolean("show_data_usage", false)),
+ ReorderItem("show_storage", getString(R.string.section_storage), prefs.getBoolean("show_storage", true)),
+ ReorderItem("show_steps", getString(R.string.section_steps), prefs.getBoolean("show_steps", false)),
+ ReorderItem("show_screen_time", getString(R.string.section_screen_time), prefs.getBoolean("show_screen_time", false))
+ )
+
+ val savedOrder = prefs.getString("widget_right_column_order", "")
+ val items = if (savedOrder.isNullOrEmpty()) {
+ defaultOrder.toMutableList()
+ } else {
+ val keys = savedOrder.split(",")
+ val list = mutableListOf()
+ keys.forEach { key ->
+ val item = defaultOrder.find { it.key == key }
+ if (item != null) list.add(item)
+ else list.add(ReorderItem(key, key.replace("show_", "").replace("_", " ").capitalize(), prefs.getBoolean(key, false)))
+ }
+ // Add any new items not in saved order
+ defaultOrder.forEach { default ->
+ if (!list.any { it.key == default.key }) list.add(default)
+ }
+ list
+ }
+
+ val recyclerView = findViewById(R.id.reorder_recycler)
+ val adapter = ReorderAdapter(items) {
+ // Save order on every move
+ val orderStr = items.joinToString(",") { it.key }
+ prefs.edit().putString("widget_right_column_order", orderStr).apply()
+ updateWidget()
+ }
+ recyclerView.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this)
+ recyclerView.adapter = adapter
+
+ // Intercept touch events so parent NestedScrollView doesn't steal them
+ recyclerView.setOnTouchListener { v, event ->
+ v.parent.requestDisallowInterceptTouchEvent(true)
+ false
+ }
+
+
+ val callback = object : androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback(
+ androidx.recyclerview.widget.ItemTouchHelper.UP or androidx.recyclerview.widget.ItemTouchHelper.DOWN, 0
+ ) {
+ override fun onMove(rv: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
+ adapter.moveItem(viewHolder.adapterPosition, target.adapterPosition)
+ return true
+ }
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
+ }
+ ItemTouchHelper(callback).attachToRecyclerView(recyclerView)
+ }
+
+ private fun bindNestedCard(
+ headerId: Int, title: String, contentId: Int, sectionKey: String,
+ chevronViewId: Int? = null
+ ) {
+ val header = findViewById(headerId)
+ val content = findViewById(contentId)
+ val chevronView = header.findViewById(
+ chevronViewId ?: R.id.header_chevron
+ )
+
+ // Standalone toggle - no accordion, each section independent
+ nestedViews[sectionKey] = content
+ nestedHeaders[sectionKey] = header
+
+ val isExpanded = prefs.getBoolean(sectionKey, false)
+ content.visibility = if (isExpanded) View.VISIBLE else View.GONE
+ chevronView.rotation = if (isExpanded) 180f else 0f
+
+ header.setOnClickListener {
+ val nowExpanded = content.visibility != View.VISIBLE
+ if (nowExpanded) {
+ collapseNestedExcept(sectionKey)
+ content.visibility = View.VISIBLE
+ prefs.edit().putBoolean(sectionKey, true).apply()
+ } else {
+ content.visibility = View.GONE
+ prefs.edit().putBoolean(sectionKey, false).apply()
+ }
+ android.animation.ObjectAnimator.ofFloat(chevronView, "rotation", if (nowExpanded) 180f else 0f).apply {
+ duration = 300
+ start()
+ }
+ }
+
+ }
+
+ private fun setupSections() {
+ contentSwitches.clear()
+
+ val zoneIds = java.time.ZoneId.getAvailableZoneIds().sorted()
+ val dateFormatOptions = listOf(getString(R.string.date_format_full), getString(R.string.date_format_short), getString(R.string.date_format_numeric))
+ val timeFormatOptions = listOf(getString(R.string.format_12h), getString(R.string.format_24h))
+ val colorOptions = listOf(getString(R.string.color_default), getString(R.string.color_system_accent), getString(R.string.color_custom))
+
+ setupTimeSection(timeFormatOptions)
+ setupNextAlarmSection()
+ setupWorldClockSection(zoneIds)
+ setupDateSection(dateFormatOptions)
+ setupBatterySection()
+ setupTempSection()
+ setupWeatherSection()
+ setupDataUsageSection()
+ setupStorageSection()
+ setupStepsSection()
+ setupScreenTimeSection()
+ setupKeepAliveSection()
+ setupEventsAndTasksSections()
+ setupThemeSection(colorOptions)
+ }
+
+ private fun setupTimeSection(timeFormatOptions: List) {
+ // Time
+ bindFoldedSection(
+ R.id.header_time, R.drawable.ic_time, getString(R.string.section_time),
+ R.id.content_time, R.id.row_time_toggle,
+ "show_time", true,
+ sizeRowId = R.id.row_time_size, prefSizeKey = "size_time", defSize = 64f, minSize = 12f, maxSize = 120f,
+ selectorRowId = R.id.row_time_format, selectorOptions = timeFormatOptions, prefSelectorKey = "time_format_idx", defSelectorIdx = 0,
+ isContent = true
+ )
+ }
+ private fun setupNextAlarmSection() {
+ // Next Alarm
+ bindFoldedSection(
+ R.id.header_next_alarm, R.drawable.ic_alarm, getString(R.string.section_next_alarm),
+ R.id.content_next_alarm, R.id.row_next_alarm_toggle,
+ "show_next_alarm", true,
+ sizeRowId = R.id.row_next_alarm_size, prefSizeKey = "size_next_alarm", defSize = 14f, minSize = 10f, maxSize = 24f,
+ isContent = true
+ )
+ }
+ private fun setupWorldClockSection(zoneIds: List) {
+ // World Clock
+ bindFoldedSection(
+ R.id.header_world_clock, R.drawable.ic_world, getString(R.string.section_world_clock),
+ R.id.content_world_clock, R.id.row_world_clock_toggle,
+ "show_world_clock", false,
+ sizeRowId = R.id.row_world_clock_size, prefSizeKey = "size_world_clock", defSize = 18f, minSize = 10f, maxSize = 32f,
+ isContent = true
+ )
+ bindTimezoneSearch(R.id.row_world_clock_zone, zoneIds, "world_clock_zone_str", "UTC")
+ }
+ private fun setupDateSection(dateFormatOptions: List) {
+ // Date
+ bindFoldedSection(
+ R.id.header_date, R.drawable.ic_date, getString(R.string.section_date),
+ R.id.content_date, R.id.row_date_toggle,
+ "show_date", true,
+ sizeRowId = R.id.row_date_size, prefSizeKey = "size_date", defSize = 14f, minSize = 10f, maxSize = 24f,
+ selectorRowId = R.id.row_date_format, selectorOptions = dateFormatOptions, prefSelectorKey = "date_format_idx", defSelectorIdx = 0,
+ isContent = true
+ )
+ }
+ private fun setupBatterySection() {
+ // Battery
+ bindFoldedSection(
+ R.id.header_battery, R.drawable.ic_battery, getString(R.string.section_battery),
+ R.id.content_battery, R.id.row_battery_toggle,
+ "show_battery", true,
+ sizeRowId = R.id.row_battery_size, prefSizeKey = "size_battery", defSize = 24f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "battery" }
+ bindToggle(R.id.row_battery_bold, "Bold Text", "bold_battery", false)
+ }
+ private fun setupTempSection() {
+ // Temp
+ bindFoldedSection(
+ R.id.header_temp, R.drawable.ic_temp, getString(R.string.section_temp),
+ R.id.content_temp, R.id.row_temp_toggle,
+ "show_temp", true,
+ sizeRowId = R.id.row_temp_size, prefSizeKey = "size_temp", defSize = 18f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "temp" }
+ bindToggle(R.id.row_temp_bold, "Bold Text", "bold_temp", false)
+ }
+ private fun setupWeatherSection() {
+ // Weather
+ val weatherSwitch = bindFoldedSection(
+ R.id.header_weather, R.drawable.ic_weather, getString(R.string.section_weather_condition),
+ R.id.content_weather, R.id.row_weather_toggle,
+ "show_weather_condition", false,
+ sizeRowId = R.id.row_weather_size, prefSizeKey = "size_weather", defSize = 18f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "weather_condition" }
+ bindToggle(R.id.row_weather_bold, "Bold Text", "bold_weather", false)
+
+ // Override weather listener for Breezy Weather check
+ weatherSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (!isAppInstalled("org.breezyweather")) {
+ weatherSwitch.isChecked = false
+ com.google.android.material.snackbar.Snackbar.make(
+ findViewById(R.id.fab_update),
+ "Breezy Weather app (with DataBridge enabled) is required.",
+ com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+ ).setAction("Install") {
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/breezy-weather/breezy-weather/releases")))
+ } catch (e: Exception) {}
+ }.show()
+ return@setOnCheckedChangeListener
+ }
+ if (ContextCompat.checkSelfPermission(this, "org.breezyweather.READ_PROVIDER") != PackageManager.PERMISSION_GRANTED) {
+ weatherSwitch.isChecked = false
+ android.app.AlertDialog.Builder(this)
+ .setTitle("Permission Clarification")
+ .setMessage("To display the weather, Lwidget needs to read data from Breezy Weather.\n\nAndroid will now ask for 'Location' access. Please note: Lwidget DOES NOT access your location, nor does it have permission to access the internet. This is simply how Android categorizes Breezy Weather's data sharing permission.")
+ .setPositiveButton("Continue") { _, _ ->
+ ActivityCompat.requestPermissions(this, arrayOf("org.breezyweather.READ_PROVIDER"), 103)
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ return@setOnCheckedChangeListener
+ }
+ if (!checkLimit()) {
+ weatherSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ }
+ prefs.edit().putBoolean("show_weather_condition", isChecked).apply()
+ updateFeatureRowVisibility(weatherSwitch, isChecked, R.id.row_weather_size)
+ updateWidget()
+ updateToggleAvailability()
+ if (isChecked) {
+ android.app.AlertDialog.Builder(this)
+ .setTitle("Important Step")
+ .setMessage("If the weather doesn't show up on your widget soon:\n\nOpen Breezy Weather → Settings → External Modules → Enable 'Send Gadgetbridge Data' & toggle on 'Lwidget'.")
+ .setPositiveButton("Got it", null)
+ .show()
+ }
+ }
+ }
+ private fun setupDataUsageSection() {
+ // Data Usage
+ val dataSwitch = bindFoldedSection(
+ R.id.header_data, R.drawable.ic_data, getString(R.string.section_data_usage),
+ R.id.content_data, R.id.row_data_toggle,
+ "show_data_usage", false,
+ sizeRowId = R.id.row_data_size, prefSizeKey = "size_data", defSize = 14f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "data" }
+ bindToggle(R.id.row_data_bold, "Bold Text", "bold_data_usage", false)
+
+ dataSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (!hasUsageStatsPermission()) {
+ dataSwitch.isChecked = false
+ try {
+ startActivity(Intent(android.provider.Settings.ACTION_USAGE_ACCESS_SETTINGS))
+ com.google.android.material.snackbar.Snackbar.make(
+ findViewById(R.id.fab_update),
+ getString(R.string.perm_usage_access_title),
+ com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+ ).show()
+ } catch (e: Exception) {}
+ return@setOnCheckedChangeListener
+ }
+ if (!checkLimit()) {
+ dataSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ }
+ prefs.edit().putBoolean("show_data_usage", isChecked).apply()
+ updateFeatureRowVisibility(dataSwitch, isChecked, R.id.row_data_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ }
+ }
+ private fun setupStorageSection() {
+ // Storage
+ bindFoldedSection(
+ R.id.header_storage, R.drawable.ic_storage, getString(R.string.section_storage),
+ R.id.content_storage, R.id.row_storage_toggle,
+ "show_storage", true,
+ sizeRowId = R.id.row_storage_size, prefSizeKey = "size_storage", defSize = 14f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "storage" }
+ bindToggle(R.id.row_storage_bold, "Bold Text", "bold_storage", false)
+ }
+ private fun setupStepsSection() {
+ // Steps
+ val stepsSwitch = bindFoldedSection(
+ R.id.header_steps, R.drawable.ic_steps, getString(R.string.section_steps),
+ R.id.content_steps, R.id.row_steps_toggle,
+ "show_steps", false,
+ sizeRowId = R.id.row_steps_size, prefSizeKey = "size_steps", defSize = 14f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "steps" }
+ bindToggle(R.id.row_steps_bold, "Bold Text", "bold_steps", false)
+
+ stepsSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ val neededPermissions = mutableListOf()
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
+ neededPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION)
+ }
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ if (neededPermissions.isNotEmpty()) {
+ ActivityCompat.requestPermissions(this, neededPermissions.toTypedArray(), 102)
+ stepsSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ }
+ if (!checkLimit()) {
+ stepsSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ }
+ prefs.edit().putBoolean("show_steps", isChecked).apply()
+ val keepAlive = prefs.getBoolean("keep_alive", false)
+ val serviceIntent = Intent(this, StepCounterService::class.java)
+ if (isChecked) {
+ val hasPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED
+ } else { true }
+ if (hasPermission) { startForegroundService(serviceIntent) }
+ else {
+ prefs.edit().putBoolean("show_steps", false).apply()
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ return@setOnCheckedChangeListener
+ }
+ } else if (!keepAlive) { stopService(serviceIntent) }
+ updateFeatureRowVisibility(stepsSwitch, isChecked, R.id.row_steps_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ }
+ }
+ private fun setupScreenTimeSection() {
+ // Screen Time
+ val screenTimeSwitch = bindFoldedSection(
+ R.id.header_screen_time, R.drawable.ic_time, getString(R.string.section_screen_time),
+ R.id.content_screen_time, R.id.row_screen_time_toggle,
+ "show_screen_time", false,
+ sizeRowId = R.id.row_screen_time_size, prefSizeKey = "size_screen_time", defSize = 14f, minSize = 10f, maxSize = 74f,
+ isContent = true
+ ).also { it.tag = "screen_time" }
+ bindToggle(R.id.row_screen_time_bold, "Bold Text", "bold_screen_time", false)
+
+ screenTimeSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (!hasUsageStatsPermission()) {
+ screenTimeSwitch.isChecked = false
+ try {
+ startActivity(Intent(android.provider.Settings.ACTION_USAGE_ACCESS_SETTINGS))
+ com.google.android.material.snackbar.Snackbar.make(
+ findViewById(R.id.fab_update),
+ getString(R.string.perm_usage_access_title),
+ com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+ ).show()
+ } catch (e: Exception) {}
+ return@setOnCheckedChangeListener
+ }
+ if (!checkLimit()) {
+ screenTimeSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ }
+ prefs.edit().putBoolean("show_screen_time", isChecked).apply()
+ updateFeatureRowVisibility(screenTimeSwitch, isChecked, R.id.row_screen_time_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ }
+ }
+ private fun setupKeepAliveSection() {
+ // Keep Alive
+ val keepAliveSwitch = bindFoldedSection(
+ R.id.header_keep_alive, R.drawable.ic_alarm, getString(R.string.section_keep_alive),
+ R.id.content_keep_alive, R.id.row_keep_alive_toggle,
+ "keep_alive", false
+ )
+
+ keepAliveSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ val neededPermissions = mutableListOf()
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
+ neededPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION)
+ }
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ if (neededPermissions.isNotEmpty()) {
+ ActivityCompat.requestPermissions(this, neededPermissions.toTypedArray(), 104)
+ keepAliveSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ }
+ prefs.edit().putBoolean("keep_alive", isChecked).apply()
+ updateFeatureRowVisibility(keepAliveSwitch, isChecked)
+ val showSteps = prefs.getBoolean("show_steps", false)
+ val serviceIntent = Intent(this, StepCounterService::class.java)
+ if (isChecked || showSteps) { startForegroundService(serviceIntent) }
+ else { stopService(serviceIntent) }
+ updateWidget()
+ }
+ }
+ private fun setupEventsAndTasksSections() {
+ // Events
+ val eventsSwitch = bindFoldedSection(
+ R.id.header_events, R.drawable.ic_events, getString(R.string.section_events),
+ R.id.content_events, R.id.row_events_toggle,
+ "show_events", false,
+ sizeRowId = R.id.row_events_size, prefSizeKey = "size_events", defSize = 14f, minSize = 10f, maxSize = 18f,
+ isContent = true
+ )
+
+ // Tasks
+ val tasksSwitch = bindFoldedSection(
+ R.id.header_tasks, R.drawable.ic_tasks, getString(R.string.section_tasks),
+ R.id.content_tasks, R.id.row_tasks_toggle,
+ "show_tasks", false,
+ sizeRowId = R.id.row_tasks_size, prefSizeKey = "size_tasks", defSize = 14f, minSize = 10f, maxSize = 18f,
+ isContent = true
+ )
+
+ // Mutual Exclusion: Events vs Tasks
+ eventsSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CALENDAR), 100)
+ eventsSwitch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ if (checkLimit()) {
+ tasksSwitch.isChecked = false
+ prefs.edit().putBoolean("show_events", true).putBoolean("show_tasks", false).apply()
+ updateFeatureRowVisibility(eventsSwitch, true, R.id.row_events_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ } else { eventsSwitch.isChecked = false }
+ } else {
+ prefs.edit().putBoolean("show_events", false).apply()
+ updateFeatureRowVisibility(eventsSwitch, false, R.id.row_events_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ }
+ }
+ tasksSwitch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (!isAppInstalled("org.tasks")) {
+ tasksSwitch.isChecked = false
+ com.google.android.material.snackbar.Snackbar.make(
+ findViewById(R.id.fab_update),
+ "Tasks.org app is required for this feature.",
+ com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+ ).setAction("Install") {
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("market://details?id=org.tasks")))
+ } catch (e: Exception) {
+ startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://play.google.com/store/apps/details?id=org.tasks")))
+ }
+ }.show()
+ return@setOnCheckedChangeListener
+ }
+ if (ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(AwidgetProvider.PERMISSION_READ_TASKS_ORG), 101)
+ }
+ if (checkLimit()) {
+ eventsSwitch.isChecked = false
+ prefs.edit().putBoolean("show_tasks", true).putBoolean("show_events", false).apply()
+ updateFeatureRowVisibility(tasksSwitch, true, R.id.row_tasks_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ } else { tasksSwitch.isChecked = false }
+ } else {
+ prefs.edit().putBoolean("show_tasks", false).apply()
+ updateFeatureRowVisibility(tasksSwitch, false, R.id.row_tasks_size)
+ updateWidget()
+ updateToggleAvailability()
+ checkAllPermissions()
+ }
+ }
+ }
+ private fun setupThemeSection(colorOptions: List) {
+ // ===== THEME =====
+ // Use bindFoldedSection for the main card (top-level accordion), not bindNestedCard
+ accordionViews["section_appearance_expanded"] = findViewById(R.id.content_appearance)
+ accordionHeaders["section_appearance_expanded"] = findViewById(R.id.header_appearance)
+ // Set title and icon
+ val appearanceHeader = findViewById(R.id.header_appearance)
+ appearanceHeader.findViewById(R.id.header_title).text = "Theme"
+ val appearanceHeaderIcon = appearanceHeader.findViewById(R.id.header_icon)
+ appearanceHeaderIcon.setImageResource(R.drawable.ic_palette)
+ appearanceHeaderIcon.visibility = View.VISIBLE
+
+ val appearanceContent = findViewById(R.id.content_appearance)
+ val appearanceChevron = findViewById(R.id.header_appearance).findViewById(R.id.header_chevron)
+ val appearanceIsExpanded = prefs.getBoolean("section_appearance_expanded", false)
+ appearanceContent.visibility = if (appearanceIsExpanded) View.VISIBLE else View.GONE
+ appearanceChevron.rotation = if (appearanceIsExpanded) 180f else 0f
+
+ findViewById(R.id.header_appearance).setOnClickListener {
+ val nowExpanded = appearanceContent.visibility != View.VISIBLE
+ if (nowExpanded) {
+ collapseAllExcept("section_appearance_expanded")
+ appearanceContent.visibility = View.VISIBLE
+ prefs.edit().putBoolean("section_appearance_expanded", true).apply()
+ } else {
+ appearanceContent.visibility = View.GONE
+ prefs.edit().putBoolean("section_appearance_expanded", false).apply()
+ }
+ android.animation.ObjectAnimator.ofFloat(appearanceChevron, "rotation", if (nowExpanded) 180f else 0f).apply {
+ duration = 300
+ start()
+ }
+ }
+
+ // Appearance Subsections (nested cards)
+ bindNestedCard(R.id.header_appearance_outline, "OUTLINE", R.id.content_appearance_outline, "section_appearance_outline_expanded", R.id.header_chevron_appearance_outline)
+ bindNestedCard(R.id.header_appearance_colors, "COLORS", R.id.content_appearance_colors, "section_appearance_colors_expanded", R.id.header_chevron_appearance_colors)
+ bindNestedCard(R.id.header_appearance_theme, "THEME", R.id.content_appearance_theme, "section_appearance_theme_expanded", R.id.header_chevron_appearance_theme)
+ bindNestedCard(R.id.header_appearance_font, "FONT", R.id.content_appearance_font, "section_appearance_font_expanded", R.id.header_chevron_appearance_font)
+ bindNestedCard(R.id.header_appearance_transparency, "TRANSPARENCY", R.id.content_appearance_transparency, "section_appearance_transparency_expanded", R.id.header_chevron_appearance_transparency)
+
+ // Reorder section
+ bindNestedCard(R.id.header_appearance_reorder, "REORDER", R.id.content_appearance_reorder, "section_appearance_reorder_expanded", R.id.header_chevron_appearance_reorder)
+ bindReorderSection()
+
+ // Outline toggle
+ bindToggle(R.id.row_outline_toggle, "Show Outline", "show_outline", true) { isChecked ->
+ updateWidget()
+ }
+
+ // Dynamic Colors toggle
+ val rowDynamicColors = findViewById(R.id.row_dynamic_colors_toggle)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ rowDynamicColors.visibility = View.VISIBLE
+ bindToggle(R.id.row_dynamic_colors_toggle, "Dynamic Colors", "use_dynamic_colors", true) { isChecked ->
+ updateColorVisibility(isChecked)
+ if (isChecked) {
+ prefs.edit()
+ .putInt("text_color_primary_idx", 0)
+ .putInt("text_color_secondary_idx", 0)
+ .putInt("outline_color_idx", 0)
+ .putInt("bg_color_idx", 0)
+ .apply()
+ }
+ }
+ } else {
+ rowDynamicColors.visibility = View.GONE
+ }
+
+ // Theme toggle
+ bindToggle(R.id.row_theme_toggle, "Light Theme", "use_system_theme", false) { isChecked ->
+ applyTheme()
+ }
+
+ // BG Transparency
+ bindSlider(R.id.row_bg_transparency, "Background Opacity", "bg_opacity", 100f, 0f, 100f)
+
+ // Background Color
+ val bgSliderRow = findViewById(R.id.row_bg_color_custom)
+ bindSelector(R.id.row_bg_color, getString(R.string.section_bg_color), "bg_color_idx", colorOptions, 0) { idx ->
+ bgSliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
+ if (idx != 2) updateWidget()
+ }
+ bindColorSliders(R.id.row_bg_color_custom, "bg_color")
+ bgSliderRow.visibility = if (prefs.getInt("bg_color_idx", 0) == 2) View.VISIBLE else View.GONE
+
+ // Text Color Primary
+ val primarySliderRow = findViewById(R.id.row_text_color_primary_custom)
+ bindSelector(R.id.row_text_color_primary, getString(R.string.section_text_color_primary), "text_color_primary_idx", colorOptions, 0) { idx ->
+ primarySliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
+ if (idx != 2) updateWidget()
+ }
+ bindColorSliders(R.id.row_text_color_primary_custom, "text_color_primary")
+ primarySliderRow.visibility = if (prefs.getInt("text_color_primary_idx", 0) == 2) View.VISIBLE else View.GONE
+
+ // Text Color Secondary
+ val secondarySliderRow = findViewById(R.id.row_text_color_secondary_custom)
+ bindSelector(R.id.row_text_color_secondary, getString(R.string.section_text_color_secondary), "text_color_secondary_idx", colorOptions, 0) { idx ->
+ secondarySliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
+ if (idx != 2) updateWidget()
+ }
+ bindColorSliders(R.id.row_text_color_secondary_custom, "text_color_secondary")
+ secondarySliderRow.visibility = if (prefs.getInt("text_color_secondary_idx", 0) == 2) View.VISIBLE else View.GONE
+
+ // Outline Color
+ val outlineSliderRow = findViewById(R.id.row_outline_color_custom)
+ bindSelector(R.id.row_outline_color, getString(R.string.section_outline_color), "outline_color_idx", colorOptions, 0) { idx ->
+ outlineSliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
+ if (idx != 2) updateWidget()
+ }
+ bindColorSliders(R.id.row_outline_color_custom, "outline_color")
+ outlineSliderRow.visibility = if (prefs.getInt("outline_color_idx", 0) == 2) View.VISIBLE else View.GONE
+
+ // Apply initial dynamic colors visibility
+ updateColorVisibility(prefs.getBoolean("use_dynamic_colors", true))
+
+ // Font selector
+ bindSelector(R.id.row_font, getString(R.string.section_font), "font_style", listOf(
+ getString(R.string.font_default), getString(R.string.font_serif), getString(R.string.font_monospace), getString(R.string.font_cursive),
+ getString(R.string.font_condensed), getString(R.string.font_condensed_light), getString(R.string.font_light), getString(R.string.font_medium),
+ getString(R.string.font_black), getString(R.string.font_thin), getString(R.string.font_smallcaps)
+ ), 0)
+
+ updateToggleAvailability()
+ }
+
+ private fun bindToggle(
+ viewId: Int, title: String, prefShowKey: String, defShow: Boolean,
+ isContent: Boolean = false,
+ onChanged: ((Boolean) -> Unit)? = null
+ ) {
+ val row = findViewById(viewId)
+ val tvTitle = row.findViewById(R.id.row_label)
+ val switch = row.findViewById(R.id.row_switch)
+
+ tvTitle.text = title
+ if (isContent) contentSwitches.add(switch)
+
+ val isShown = prefs.getBoolean(prefShowKey, defShow)
+ switch.isChecked = isShown
+ onChanged?.invoke(isShown)
+
+ switch.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked && !checkLimit()) {
+ switch.isChecked = false
+ return@setOnCheckedChangeListener
+ }
+ prefs.edit().putBoolean(prefShowKey, isChecked).apply()
+ onChanged?.invoke(isChecked)
+ updateWidget()
+ if (isContent) updateToggleAvailability()
+ }
+ }
+
+
+ private fun bindSlider(
+ viewId: Int, title: String, prefKey: String, defValue: Float,
+ minValue: Float, maxValue: Float
+ ) {
+ val row = findViewById(viewId)
+ val tvTitle = row.findViewById(R.id.row_label)
+ val slider = row.findViewById(R.id.row_slider)
+ val tvValue = row.findViewById(R.id.row_value)
+
+ tvTitle.text = title
+
+ val currentValue = prefs.getFloat(prefKey, defValue)
+ slider.valueFrom = minValue
+ slider.valueTo = maxValue
+ slider.value = currentValue.coerceIn(minValue, maxValue)
+ tvValue.text = "${currentValue.toInt()}%"
+
+ slider.addOnChangeListener { _, value, fromUser ->
+ if (fromUser) {
+ tvValue.text = "${value.toInt()}%"
+ prefs.edit().putFloat(prefKey, value).apply()
+ updateWidget()
+ }
+ }
+ }
+
+ private fun bindSelector(
+ viewId: Int, title: String, prefKey: String, options: List,
+ defaultIdx: Int, onSelectionChanged: ((Int) -> Unit)? = null
+ ) {
+ val row = findViewById(viewId)
+ val tvTitle = row.findViewById(R.id.row_label)
+ val autoCompleteTextView = row.findViewById(R.id.row_value)
+
+ tvTitle.text = title
+
+ val adapter = android.widget.ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, options)
+ autoCompleteTextView.setAdapter(adapter)
+
+ if (prefKey == "world_clock_zone_str") {
+ val currentVal = prefs.getString(prefKey, "UTC") ?: "UTC"
+ autoCompleteTextView.setText(currentVal, false)
+ autoCompleteTextView.setOnItemClickListener { _, _, position, _ ->
+ val selected = options.getOrElse(position) { "UTC" }
+ prefs.edit().putString(prefKey, selected).apply()
+ updateWidget()
+ }
+ } else {
+ val currentIdx = prefs.getInt(prefKey, defaultIdx)
+ autoCompleteTextView.setText(options.getOrElse(currentIdx) { options[defaultIdx] }, false)
+ autoCompleteTextView.setOnItemClickListener { _, _, position, _ ->
+ prefs.edit().putInt(prefKey, position).apply()
+ updateWidget()
+ onSelectionChanged?.invoke(position)
+ row.requestFocus()
+ autoCompleteTextView.clearFocus()
+ }
+ }
+ }
+
+ private fun bindColorSliders(viewId: Int, prefPrefix: String): View {
+ val row = findViewById(viewId)
+ val sliderRed = row.findViewById(R.id.slider_red)
+ val sliderGreen = row.findViewById(R.id.slider_green)
+ val sliderBlue = row.findViewById(R.id.slider_blue)
+ val valRed = row.findViewById(R.id.val_red)
+ val valGreen = row.findViewById(R.id.val_green)
+ val valBlue = row.findViewById(R.id.val_blue)
+ val preview = row.findViewById(R.id.color_preview)
+
+ val r = prefs.getInt("${prefPrefix}_r", 255)
+ val g = prefs.getInt("${prefPrefix}_g", 255)
+ val b = prefs.getInt("${prefPrefix}_b", 255)
+
+ fun updatePreview() {
+ val color = android.graphics.Color.rgb(sliderRed.value.toInt(), sliderGreen.value.toInt(), sliderBlue.value.toInt())
+ preview.backgroundTintList = android.content.res.ColorStateList.valueOf(color)
+ valRed.text = sliderRed.value.toInt().toString()
+ valGreen.text = sliderGreen.value.toInt().toString()
+ valBlue.text = sliderBlue.value.toInt().toString()
+ }
+
+ sliderRed.value = r.toFloat()
+ sliderGreen.value = g.toFloat()
+ sliderBlue.value = b.toFloat()
+ updatePreview()
+
+ val listener = Slider.OnChangeListener { _, _, fromUser ->
+ if (fromUser) {
+ updatePreview()
+ prefs.edit()
+ .putInt("${prefPrefix}_r", sliderRed.value.toInt())
+ .putInt("${prefPrefix}_g", sliderGreen.value.toInt())
+ .putInt("${prefPrefix}_b", sliderBlue.value.toInt())
+ .apply()
+ updateWidget()
+ }
+ }
+
+ sliderRed.addOnChangeListener(listener)
+ sliderGreen.addOnChangeListener(listener)
+ sliderBlue.addOnChangeListener(listener)
+
+ return row
+ }
+
+ private fun updateColorVisibility(useDynamicColors: Boolean) {
+ val manualColorIds = listOf(
+ R.id.row_bg_color, R.id.row_bg_color_custom,
+ R.id.row_text_color_primary, R.id.row_text_color_primary_custom,
+ R.id.row_text_color_secondary, R.id.row_text_color_secondary_custom,
+ R.id.row_outline_color, R.id.row_outline_color_custom
+ )
+ manualColorIds.forEach { id ->
+ findViewById(id).visibility = if (useDynamicColors) View.GONE else View.VISIBLE
+ }
+ }
+
+ private fun applyTheme() {
+ val useSystemTheme = prefs.getBoolean("use_system_theme", false)
+ updateWidget()
+ }
+
+ private fun checkLimit(): Boolean {
+ // Global limit removed per user request
+
+ // Subset Limit: Battery, Weather, Temp, Data, Storage (Max 5 allowed now to fit stack)
+ val subsetCount = contentSwitches.count {
+ it.isChecked && (it.tag == "battery" || it.tag == "weather_condition" || it.tag == "temp" || it.tag == "data" || it.tag == "storage")
+ }
+
+ if (subsetCount > 5) {
+ com.google.android.material.snackbar.Snackbar.make(
+ findViewById(R.id.fab_update),
+ getString(R.string.error_max_subset_items),
+ com.google.android.material.snackbar.Snackbar.LENGTH_SHORT
+ ).show()
+ return false
+ }
+
+ return true
+ }
+
+ // Check usage stats permission
+ private fun hasUsageStatsPermission(): Boolean {
+ val appOps = getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager
+ val opMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ appOps.unsafeCheckOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS,
+ android.os.Process.myUid(), packageName)
+ } else {
+ @Suppress("DEPRECATION")
+ appOps.checkOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS,
+ android.os.Process.myUid(), packageName)
+ }
+ return opMode == android.app.AppOpsManager.MODE_ALLOWED
+ }
+
+ private fun updateToggleAvailability() {
+ // Limit removed
+ // Ensure all are enabled
+ for (switch in contentSwitches) {
+ switch.isEnabled = true
+ switch.alpha = 1.0f
+ }
+ }
+
+ private fun isAppInstalled(packageName: String): Boolean {
+ return try {
+ packageManager.getPackageInfo(packageName, 0)
+ true
+ } catch (e: PackageManager.NameNotFoundException) {
+ false
+ }
+ }
+
+ private fun updateWidget() {
+ // Animation: Subtle Outline Shine
+ val fab = findViewById(R.id.fab_update)
+
+ // Get dynamic colors
+ // val colorSurface = com.google.android.material.color.MaterialColors.getColor(fab, com.google.android.material.R.attr.colorSurface)
+ val colorPrimary = com.google.android.material.color.MaterialColors.getColor(fab, com.google.android.material.R.attr.colorPrimary)
+ val colorTransparent = android.graphics.Color.TRANSPARENT
+
+ val strokeAnimator = android.animation.ValueAnimator.ofArgb(colorTransparent, colorPrimary, colorTransparent)
+ strokeAnimator.duration = 1000
+ strokeAnimator.addUpdateListener { animator ->
+ fab.strokeColor = android.content.res.ColorStateList.valueOf(animator.animatedValue as Int)
+ }
+ strokeAnimator.start()
+
+ // Trigger widget update by sending broadcast
+ val intent = Intent(this, AwidgetProvider::class.java).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ // Get all IDs
+ val ids = AppWidgetManager.getInstance(application).getAppWidgetIds(ComponentName(application, AwidgetProvider::class.java))
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
+ }
+ sendBroadcast(intent)
+ }
+}
diff --git a/app/src/main/java/com/leanbitlab/lwidget/WidgetUpdateWorker.kt b/app/src/main/java/com/leanbitlab/lwidget/WidgetUpdateWorker.kt
index fa807ce..1484907 100644
--- a/app/src/main/java/com/leanbitlab/lwidget/WidgetUpdateWorker.kt
+++ b/app/src/main/java/com/leanbitlab/lwidget/WidgetUpdateWorker.kt
@@ -20,8 +20,8 @@ class WidgetUpdateWorker(
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisAppWidget)
// Perform the periodic TICK update (Battery, Temp, Data, Storage)
- for (appWidgetId in appWidgetIds) {
- AwidgetProvider.updateAppWidget(context, appWidgetManager, appWidgetId, UpdateMode.TICK)
+ if (appWidgetIds.isNotEmpty()) {
+ AwidgetProvider.updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.TICK)
}
Result.success()
} catch (e: Exception) {
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 368d744..9390c71 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1245,6 +1245,16 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
+
+
+
+
Transparency
Text Color
Text Color 2
+ Date Color
Outline Color
Font Style
Background Color
diff --git a/app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt b/app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt
new file mode 100644
index 0000000..f4a9a27
--- /dev/null
+++ b/app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt
@@ -0,0 +1,287 @@
+package com.leanbitlab.lwidget
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Color
+import android.os.Build
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+class ColorResolverTest {
+
+ @Test
+ fun testResolveColor_DynamicOn_Primary_Light_SdkS() {
+ val mockContext = mock {
+ on { getColor(android.R.color.system_accent1_800) } doReturn 0x112233
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = true,
+ idx = 0,
+ isPrimary = true,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x112233, result)
+ }
+
+ @Test
+ fun testResolveColor_DynamicOn_Primary_Dark_SdkS() {
+ val mockContext = mock {
+ on { getColor(android.R.color.system_accent1_50) } doReturn 0x223344
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = true,
+ idx = 0,
+ isPrimary = true,
+ isLight = false,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x223344, result)
+ }
+
+ @Test
+ fun testResolveColor_DynamicOn_Secondary_Light_SdkS() {
+ val mockContext = mock {
+ on { getColor(android.R.color.system_neutral2_600) } doReturn 0x334455
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = true,
+ idx = 0,
+ isPrimary = false,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x334455, result)
+ }
+
+ @Test
+ fun testResolveColor_DynamicOn_Secondary_Dark_SdkS() {
+ val mockContext = mock {
+ on { getColor(android.R.color.system_neutral2_300) } doReturn 0x445566
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = true,
+ idx = 0,
+ isPrimary = false,
+ isLight = false,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x445566, result)
+ }
+
+ @Test
+ fun testResolveColor_DynamicOn_ButOldSdk() {
+ // Fallback to idx=0 default
+ val mockContext = mock {
+ on { getColor(R.color.widget_text_light) } doReturn 0x556677
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = true, // but SDK is old
+ idx = 0,
+ isPrimary = true,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.R
+ )
+
+ assertEquals(0x556677, result)
+ }
+
+ @Test
+ fun testResolveColor_DefaultIdx0_Primary_Light() {
+ val mockContext = mock {
+ on { getColor(R.color.widget_text_light) } doReturn 0x667788
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 0,
+ isPrimary = true,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x667788, result)
+ }
+
+ @Test
+ fun testResolveColor_DefaultIdx0_Primary_Dark() {
+ val mockContext = mock()
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 0,
+ isPrimary = true,
+ isLight = false,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(Color.WHITE, result)
+ }
+
+ @Test
+ fun testResolveColor_DefaultIdx0_Secondary_Light() {
+ val mockContext = mock {
+ on { getColor(R.color.widget_text_secondary_light) } doReturn 0x778899
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 0,
+ isPrimary = false,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x778899, result)
+ }
+
+ @Test
+ fun testResolveColor_DefaultIdx0_Secondary_Dark() {
+ val mockContext = mock()
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 0,
+ isPrimary = false,
+ isLight = false,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(Color.parseColor("#CCFFFFFF"), result)
+ }
+
+ @Test
+ fun testResolveColor_Idx1_SdkS() {
+ val mockContext = mock {
+ on { getColor(android.R.color.system_accent1_500) } doReturn 0x889900
+ }
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 1,
+ isPrimary = true, // shouldn't matter for idx 1
+ isLight = true, // shouldn't matter for idx 1
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(0x889900, result)
+ }
+
+ @Test
+ fun testResolveColor_Idx1_OldSdk() {
+ val mockContext = mock()
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 1,
+ isPrimary = true,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.R
+ )
+
+ assertEquals(Color.CYAN, result)
+ }
+
+ @Test
+ fun testResolveColor_Idx2_CustomColor() {
+ val mockContext = mock()
+ val mockPrefs = mock {
+ on { getInt("text_color_primary_r", 255) } doReturn 100
+ on { getInt("text_color_primary_g", 255) } doReturn 150
+ on { getInt("text_color_primary_b", 255) } doReturn 200
+ }
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 2,
+ isPrimary = true,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(Color.rgb(100, 150, 200), result)
+ }
+
+ @Test
+ fun testResolveColor_ElseIdx_Primary() {
+ val mockContext = mock()
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 99,
+ isPrimary = true,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(Color.WHITE, result)
+ }
+
+ @Test
+ fun testResolveColor_ElseIdx_Secondary() {
+ val mockContext = mock()
+ val mockPrefs = mock()
+
+ val result = ColorResolver.resolveColor(
+ context = mockContext,
+ prefs = mockPrefs,
+ useDynamicColors = false,
+ idx = 99,
+ isPrimary = false,
+ isLight = true,
+ sdkInt = Build.VERSION_CODES.S
+ )
+
+ assertEquals(Color.parseColor("#CCFFFFFF"), result)
+ }
+}
diff --git a/app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt b/app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt
new file mode 100644
index 0000000..efdbf96
--- /dev/null
+++ b/app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt
@@ -0,0 +1,145 @@
+package com.leanbitlab.lwidget
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.hardware.SensorEvent
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.time.LocalDate
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class StepCounterServiceTest {
+
+ private lateinit var service: StepCounterService
+ private lateinit var prefs: SharedPreferences
+
+ @Before
+ fun setUp() {
+ val context = ApplicationProvider.getApplicationContext()
+ prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE)
+ prefs.edit().clear().apply()
+
+ service = Robolectric.buildService(StepCounterService::class.java).create().get()
+ }
+
+ private fun createMockSensorEvent(steps: Float): SensorEvent {
+ val constructor = SensorEvent::class.java.declaredConstructors.first { it.parameterCount == 1 }
+ constructor.isAccessible = true
+ val event = constructor.newInstance(1) as SensorEvent
+ event.values[0] = steps
+ return event
+ }
+
+ @Test
+ fun testOnSensorChanged_hardwareRebooted() {
+ // Setup initial state: previous total was 1000, baseline was 200
+ // Meaning the user took 800 steps (1000 - 200)
+ prefs.edit()
+ .putFloat("last_total_steps", 1000f)
+ .putFloat("step_baseline", 200f)
+ .putString("step_date", LocalDate.now().toString())
+ .apply()
+
+ // Hardware rebooted, now sensor says 50 steps
+ val event = createMockSensorEvent(50f)
+ service.onSensorChanged(event)
+
+ // New baseline should be: 50 - (1000 - 200) = 50 - 800 = -750
+ assertEquals(-750f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertEquals(50f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+ }
+
+ @Test
+ fun testOnSensorChanged_hardwareRebootedMultipleTimes() {
+ // Initial state
+ prefs.edit()
+ .putFloat("last_total_steps", 1000f)
+ .putFloat("step_baseline", 200f)
+ .putString("step_date", LocalDate.now().toString())
+ .apply()
+
+ // First reboot, sensor goes from 1000 -> 50
+ service.onSensorChanged(createMockSensorEvent(50f))
+
+ // Expected: baseline = 50 - (1000 - 200) = -750
+ assertEquals(-750f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertEquals(50f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+
+ // Steps increase from 50 to 150
+ service.onSensorChanged(createMockSensorEvent(150f))
+
+ // Expected: baseline remains -750, last_total_steps = 150
+ assertEquals(-750f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertEquals(150f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+
+ // Second reboot, sensor goes from 150 -> 20
+ service.onSensorChanged(createMockSensorEvent(20f))
+
+ // Expected: baseline = 20 - (150 - (-750)) = 20 - 900 = -880
+ assertEquals(-880f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertEquals(20f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+ }
+
+ @Test
+ fun testOnSensorChanged_dailyReset() {
+ // Setup state for yesterday
+ val yesterday = LocalDate.now().minusDays(1).toString()
+ prefs.edit()
+ .putString("step_date", yesterday)
+ .putFloat("last_total_steps", 500f)
+ .putFloat("step_baseline", 100f)
+ .apply()
+
+ val event = createMockSensorEvent(600f)
+ service.onSensorChanged(event)
+
+ // Expected: Should update date to today and set baseline to current total
+ assertEquals(LocalDate.now().toString(), prefs.getString("step_date", ""))
+ assertEquals(600f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertEquals(600f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+ }
+
+ @Test
+ fun testOnSensorChanged_normalStepIncrease() {
+ // Normal state today
+ prefs.edit()
+ .putString("step_date", LocalDate.now().toString())
+ .putFloat("last_total_steps", 1000f)
+ .putFloat("step_baseline", 200f)
+ .apply()
+
+ // Step increases to 1050
+ val event = createMockSensorEvent(1050f)
+ service.onSensorChanged(event)
+
+ // Baseline shouldn't change
+ assertEquals(200f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertEquals(1050f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+ }
+
+ @Test
+ fun testOnSensorChanged_nullEvent() {
+ // Setup initial state
+ prefs.edit()
+ .putString("step_date", LocalDate.now().toString())
+ .putFloat("last_total_steps", 1000f)
+ .putFloat("step_baseline", 200f)
+ .putBoolean("was_called", false) // marker to check if prefs was edited
+ .apply()
+
+ service.onSensorChanged(null)
+
+ // Ensure nothing was updated or changed
+ assertEquals(1000f, prefs.getFloat("last_total_steps", 0f), 0.001f)
+ assertEquals(200f, prefs.getFloat("step_baseline", 0f), 0.001f)
+ assertFalse(prefs.getBoolean("was_called", true))
+ }
+}
diff --git a/app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt b/app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt
new file mode 100644
index 0000000..14a7a31
--- /dev/null
+++ b/app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt
@@ -0,0 +1,76 @@
+package com.leanbitlab.lwidget.weather
+
+import android.content.Context
+import android.content.SharedPreferences
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+class BreezyWeatherFetcherTest {
+
+ private lateinit var mockContext: Context
+ private lateinit var mockPrefs: SharedPreferences
+
+ private val prefsName = "lwidget_breezy_weather_data"
+ private val keyWeatherJson = "weather_json"
+
+ @Before
+ fun setup() {
+ mockPrefs = mock()
+ mockContext = mock {
+ on { getSharedPreferences(prefsName, Context.MODE_PRIVATE) } doReturn mockPrefs
+ }
+ }
+
+ @Test
+ fun `fetchLocalWeather with valid json returns parsed data`() {
+ val validJson = """
+ {
+ "timestamp": 1678886400,
+ "location": "Berlin",
+ "currentTemp": 15
+ }
+ """.trimIndent()
+
+ whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn(validJson)
+
+ val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext)
+
+ assertEquals(1678886400, result?.timestamp)
+ assertEquals("Berlin", result?.location)
+ assertEquals(15, result?.currentTemp)
+ }
+
+ @Test
+ fun `fetchLocalWeather with null json returns null`() {
+ whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn(null)
+
+ val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `fetchLocalWeather with empty json returns null`() {
+ whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn("")
+
+ val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext)
+
+ assertNull(result)
+ }
+
+ @Test
+ fun `fetchLocalWeather with malformed json returns null`() {
+ val malformedJson = """{ "timestamp": 1678886400, "location": """
+
+ whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn(malformedJson)
+
+ val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext)
+
+ assertNull(result)
+ }
+}
diff --git a/awidget_patch.diff b/awidget_patch.diff
new file mode 100644
index 0000000..1b5e58b
--- /dev/null
+++ b/awidget_patch.diff
@@ -0,0 +1,21 @@
+--- app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt
++++ app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt
+@@ -377,10 +377,12 @@
+ val primaryColor = resolveColor(textColorPrimaryIdx, true, useLightTheme)
+ val secondaryColor = resolveColor(textColorSecondaryIdx, false, useLightTheme)
+
++ val dateColorIdx = prefs.getInt("date_color_idx", 0)
++
+ // Slightly distinct colors for date and next alarm
+ val dateColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ // Warm accent for date
+ context.getColor(if (useLightTheme) android.R.color.system_accent2_700 else android.R.color.system_accent2_100)
+ } else {
+- if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
++ when (dateColorIdx) {
++ 2 -> android.graphics.Color.rgb(prefs.getInt("date_color_r", 255), prefs.getInt("date_color_g", 255), prefs.getInt("date_color_b", 255))
++ 1 -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) context.getColor(android.R.color.system_accent2_500) else android.graphics.Color.YELLOW
++ else -> if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
++ }
+ }
+ val alarmColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
diff --git a/fix_activity_main.sh b/fix_activity_main.sh
new file mode 100644
index 0000000..2551867
--- /dev/null
+++ b/fix_activity_main.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+sed -i '/android:id="@+id\/row_date_color"/,+4d' app/src/main/res/layout/activity_main.xml
+sed -i '/android:id="@+id\/row_date_color_custom"/,+4d' app/src/main/res/layout/activity_main.xml
diff --git a/main_patch.diff b/main_patch.diff
new file mode 100644
index 0000000..00559f7
--- /dev/null
+++ b/main_patch.diff
@@ -0,0 +1,34 @@
+--- app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
++++ app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
+@@ -1187,6 +1187,7 @@
+ prefs.edit()
+ .putInt("text_color_primary_idx", 0)
+ .putInt("text_color_secondary_idx", 0)
++ .putInt("date_color_idx", 0)
+ .putInt("outline_color_idx", 0)
+ .putInt("bg_color_idx", 0)
+ .apply()
+@@ -1226,6 +1227,15 @@
+ bindColorSliders(R.id.row_text_color_secondary_custom, "text_color_secondary")
+ secondarySliderRow.visibility = if (prefs.getInt("text_color_secondary_idx", 0) == 2) View.VISIBLE else View.GONE
+
++ // Date Color
++ val dateSliderRow = findViewById(R.id.row_date_color_custom)
++ bindSelector(R.id.row_date_color, getString(R.string.section_date_color), "date_color_idx", colorOptions, 0) { idx ->
++ dateSliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
++ if (idx != 2) updateWidget()
++ }
++ bindColorSliders(R.id.row_date_color_custom, "date_color")
++ dateSliderRow.visibility = if (prefs.getInt("date_color_idx", 0) == 2) View.VISIBLE else View.GONE
++
+ // Outline Color
+ val outlineSliderRow = findViewById(R.id.row_outline_color_custom)
+ bindSelector(R.id.row_outline_color, getString(R.string.section_outline_color), "outline_color_idx", colorOptions, 0) { idx ->
+@@ -1400,6 +1410,7 @@
+ R.id.row_bg_color, R.id.row_bg_color_custom,
+ R.id.row_text_color_primary, R.id.row_text_color_primary_custom,
+ R.id.row_text_color_secondary, R.id.row_text_color_secondary_custom,
++ R.id.row_date_color, R.id.row_date_color_custom,
+ R.id.row_outline_color, R.id.row_outline_color_custom
+ )
+ manualColorIds.forEach { id ->
diff --git a/patch_activity_main.diff b/patch_activity_main.diff
new file mode 100644
index 0000000..109e504
--- /dev/null
+++ b/patch_activity_main.diff
@@ -0,0 +1,19 @@
+--- app/src/main/res/layout/activity_main.xml
++++ app/src/main/res/layout/activity_main.xml
+@@ -1245,6 +1245,16 @@
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"/>
+
++
++
++
++
+ = android.os.Build.VERSION_CODES.S) {
+ // Warm accent for date
+ context.getColor(if (useLightTheme) android.R.color.system_accent2_700 else android.R.color.system_accent2_100)
+ } else {
+- if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
++ when (dateColorIdx) {
++ 2 -> {
++ android.graphics.Color.rgb(
++ prefs.getInt("date_color_r", 255),
++ prefs.getInt("date_color_g", 255),
++ prefs.getInt("date_color_b", 255)
++ )
++ }
++ 1 -> {
++ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
++ context.getColor(android.R.color.system_accent2_500)
++ } else {
++ android.graphics.Color.YELLOW
++ }
++ }
++ else -> if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
++ }
+ }
+ val alarmColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
diff --git a/patch_strings.diff b/patch_strings.diff
new file mode 100644
index 0000000..6c0fb73
--- /dev/null
+++ b/patch_strings.diff
@@ -0,0 +1,10 @@
+--- app/src/main/res/values/strings.xml
++++ app/src/main/res/values/strings.xml
+@@ -26,6 +26,7 @@
+ Transparency
+ Text Color
+ Text Color 2
++ Date Color
+ Outline Color
+ Font Style
+ Background Color
diff --git a/strings_patch.diff b/strings_patch.diff
new file mode 100644
index 0000000..3421a05
--- /dev/null
+++ b/strings_patch.diff
@@ -0,0 +1,10 @@
+--- app/src/main/res/values/strings.xml
++++ app/src/main/res/values/strings.xml
+@@ -25,6 +25,7 @@
+ Transparency
+ Text Color
+ Text Color 2
++ Date Color
+ Outline Color
+ Font Style
+ Background Color
diff --git a/test_app.sh b/test_app.sh
new file mode 100644
index 0000000..9b70b7f
--- /dev/null
+++ b/test_app.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+./gradlew testDebugUnitTest --tests "com.leanbitlab.lwidget.ColorResolverTest"
diff --git a/update_date_color.sh b/update_date_color.sh
new file mode 100644
index 0000000..2ac2749
--- /dev/null
+++ b/update_date_color.sh
@@ -0,0 +1,106 @@
+#!/bin/bash
+
+# Update AwidgetProvider.kt
+cat << 'PATCH1' > awidget_patch.diff
+--- app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt
++++ app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt
+@@ -377,10 +377,12 @@
+ val primaryColor = resolveColor(textColorPrimaryIdx, true, useLightTheme)
+ val secondaryColor = resolveColor(textColorSecondaryIdx, false, useLightTheme)
+
++ val dateColorIdx = prefs.getInt("date_color_idx", 0)
++
+ // Slightly distinct colors for date and next alarm
+ val dateColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ // Warm accent for date
+ context.getColor(if (useLightTheme) android.R.color.system_accent2_700 else android.R.color.system_accent2_100)
+ } else {
+- if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
++ when (dateColorIdx) {
++ 2 -> android.graphics.Color.rgb(prefs.getInt("date_color_r", 255), prefs.getInt("date_color_g", 255), prefs.getInt("date_color_b", 255))
++ 1 -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) context.getColor(android.R.color.system_accent2_500) else android.graphics.Color.YELLOW
++ else -> if (useLightTheme) android.graphics.Color.parseColor("#AA555544") else android.graphics.Color.parseColor("#BBDDDDCC")
++ }
+ }
+ val alarmColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+PATCH1
+
+# Update MainActivity.kt
+cat << 'PATCH2' > main_patch.diff
+--- app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
++++ app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt
+@@ -1187,6 +1187,7 @@
+ prefs.edit()
+ .putInt("text_color_primary_idx", 0)
+ .putInt("text_color_secondary_idx", 0)
++ .putInt("date_color_idx", 0)
+ .putInt("outline_color_idx", 0)
+ .putInt("bg_color_idx", 0)
+ .apply()
+@@ -1226,6 +1227,15 @@
+ bindColorSliders(R.id.row_text_color_secondary_custom, "text_color_secondary")
+ secondarySliderRow.visibility = if (prefs.getInt("text_color_secondary_idx", 0) == 2) View.VISIBLE else View.GONE
+
++ // Date Color
++ val dateSliderRow = findViewById(R.id.row_date_color_custom)
++ bindSelector(R.id.row_date_color, getString(R.string.section_date_color), "date_color_idx", colorOptions, 0) { idx ->
++ dateSliderRow.visibility = if (idx == 2) View.VISIBLE else View.GONE
++ if (idx != 2) updateWidget()
++ }
++ bindColorSliders(R.id.row_date_color_custom, "date_color")
++ dateSliderRow.visibility = if (prefs.getInt("date_color_idx", 0) == 2) View.VISIBLE else View.GONE
++
+ // Outline Color
+ val outlineSliderRow = findViewById(R.id.row_outline_color_custom)
+ bindSelector(R.id.row_outline_color, getString(R.string.section_outline_color), "outline_color_idx", colorOptions, 0) { idx ->
+@@ -1400,6 +1410,7 @@
+ R.id.row_bg_color, R.id.row_bg_color_custom,
+ R.id.row_text_color_primary, R.id.row_text_color_primary_custom,
+ R.id.row_text_color_secondary, R.id.row_text_color_secondary_custom,
++ R.id.row_date_color, R.id.row_date_color_custom,
+ R.id.row_outline_color, R.id.row_outline_color_custom
+ )
+ manualColorIds.forEach { id ->
+PATCH2
+
+# Update strings.xml
+cat << 'PATCH3' > strings_patch.diff
+--- app/src/main/res/values/strings.xml
++++ app/src/main/res/values/strings.xml
+@@ -25,6 +25,7 @@
+ Transparency
+ Text Color
+ Text Color 2
++ Date Color
+ Outline Color
+ Font Style
+ Background Color
+PATCH3
+
+# Update activity_main.xml
+cat << 'PATCH4' > activity_main_patch.diff
+--- app/src/main/res/layout/activity_main.xml
++++ app/src/main/res/layout/activity_main.xml
+@@ -1246,6 +1246,16 @@
+ android:layout_height="wrap_content"/>
+
+
++
++
++
++
+PATCH4
+
+patch -p0 < awidget_patch.diff
+patch -p0 < main_patch.diff
+patch -p0 < strings_patch.diff
+patch -p0 < activity_main_patch.diff