From e207c886bd1ae1982ee09c21288ac95a66c2bef4 Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:08:52 +0000 Subject: [PATCH 1/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. --- .../com/leanbitlab/lwidget/AwidgetProvider.kt | 28 +++++++++---------- .../leanbitlab/lwidget/WidgetUpdateWorker.kt | 4 +-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt index 1839765..6393ab4 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt @@ -54,9 +54,7 @@ class AwidgetProvider : AppWidgetProvider() { val pendingResult = goAsync() CoroutineScope(Dispatchers.IO).launch { try { - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId, UpdateMode.FULL) - } + updateAppWidget(context, appWidgetManager, appWidgetIds, UpdateMode.FULL) } finally { pendingResult.finish() } @@ -94,28 +92,28 @@ class AwidgetProvider : AppWidgetProvider() { when (intent.action) { Intent.ACTION_BOOT_COMPLETED -> { 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) } } } @@ -167,7 +165,7 @@ class AwidgetProvider : AppWidgetProvider() { const val ACTION_BATTERY_UPDATE = "com.leanbitlab.lwidget.ACTION_BATTERY_UPDATE" // 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 --- @@ -449,22 +447,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 } @@ -821,7 +819,7 @@ 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) } 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) { From bf7ce126e6400ef38dedcabc7930ed8d93d8c9ea Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:41:06 +0000 Subject: [PATCH 2/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. From 42711583a019501f47c03e0a3dcd57600fb5dc9f Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:07:21 +0000 Subject: [PATCH 3/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. From fc87c7392f2284384a3d41f960dba9c6c5cfcab0 Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:20:58 +0000 Subject: [PATCH 4/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. From 6c5ceb61f937894633005102dfe63d5b70b0bcf6 Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:58:35 +0000 Subject: [PATCH 5/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. --- app/build.gradle.kts | 10 + app/src/main/AndroidManifest.xml | 2 +- .../com/leanbitlab/lwidget/AwidgetProvider.kt | 275 +++++++++--------- .../com/leanbitlab/lwidget/MainActivity.kt | 69 +++-- .../lwidget/StepCounterServiceTest.kt | 145 +++++++++ .../weather/BreezyWeatherFetcherTest.kt | 76 +++++ 6 files changed, 418 insertions(+), 159 deletions(-) create mode 100644 app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt create mode 100644 app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b567946..2814f6b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,10 +55,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 @@ { + 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, @@ -854,12 +844,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) ?: "" @@ -872,7 +860,7 @@ class AwidgetProvider : AppWidgetProvider() { } } - if (visibleCalendarIds.isEmpty()) return + if (visibleCalendarIds.isEmpty()) return emptyList() val projection = arrayOf( android.provider.CalendarContract.Instances.EVENT_ID, @@ -889,14 +877,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 -> @@ -914,8 +899,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()) @@ -967,12 +954,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) @@ -982,7 +988,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) @@ -1017,7 +1023,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) { @@ -1038,6 +1043,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() @@ -1067,7 +1073,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) } @@ -1084,6 +1089,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, @@ -1093,107 +1153,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) { @@ -1237,6 +1240,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) @@ -1248,7 +1252,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) } @@ -1259,9 +1262,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) @@ -1283,12 +1285,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/MainActivity.kt b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt index 097cf74..2509b5b 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) 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) + } +} From ccbed9325186dbc0809b8153a5f86a0610f1e8bc Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:18:10 +0000 Subject: [PATCH 6/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. From 635dc39995ad0d6fe5e9a2621aa2d92011c28240 Mon Sep 17 00:00:00 2001 From: LeanBitLab <245915690+LeanBitLab@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:09:13 +0000 Subject: [PATCH 7/7] perf: Batch widget updates to reduce redundant computation Updated AwidgetProvider and WidgetUpdateWorker to pass an IntArray of appWidgetIds directly to AppWidgetManager.updateAppWidget instead of looping. This prevents redundant operations (loading SharedPreferences, querying database/CPs, formatting string layouts) and IO operations per instance, dramatically reducing CPU consumption when the user adds multiple instances of the same widget to the home screen. --- activity_main_patch.diff | 19 + app/build.gradle.kts | 4 + .../com/leanbitlab/lwidget/AwidgetProvider.kt | 60 +- .../lwidget/AwidgetProvider.kt.orig | 1272 ++++++++++++++ .../com/leanbitlab/lwidget/ColorResolver.kt | 51 + .../com/leanbitlab/lwidget/MainActivity.kt | 11 + .../leanbitlab/lwidget/MainActivity.kt.orig | 1489 +++++++++++++++++ app/src/main/res/layout/activity_main.xml | 10 + app/src/main/res/values/strings.xml | 1 + .../leanbitlab/lwidget/ColorResolverTest.kt | 287 ++++ awidget_patch.diff | 21 + fix_activity_main.sh | 3 + main_patch.diff | 34 + patch_activity_main.diff | 19 + patch_awidget.diff | 33 + patch_strings.diff | 10 + strings_patch.diff | 10 + test_app.sh | 2 + update_date_color.sh | 106 ++ 19 files changed, 3409 insertions(+), 33 deletions(-) create mode 100644 activity_main_patch.diff create mode 100644 app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig create mode 100644 app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt create mode 100644 app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig create mode 100644 app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt create mode 100644 awidget_patch.diff create mode 100644 fix_activity_main.sh create mode 100644 main_patch.diff create mode 100644 patch_activity_main.diff create mode 100644 patch_awidget.diff create mode 100644 patch_strings.diff create mode 100644 strings_patch.diff create mode 100644 test_app.sh create mode 100644 update_date_color.sh 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 2814f6b..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") diff --git a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt index 3e07bec..e600dd4 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt @@ -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 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 2509b5b..3c4b1a4 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt @@ -1191,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() @@ -1235,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 -> @@ -1397,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/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/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