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