diff --git a/.fleet/goals/example.md b/.fleet/goals/example.md deleted file mode 100644 index a473a00..0000000 --- a/.fleet/goals/example.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -milestone: "1" ---- - -# Example Fleet Goal - -Analyze the codebase for potential improvements and create -issues for the engineering team. - -## Tools -- Test Coverage: `npx vitest --coverage --json` - -## Assessment Hints -- Focus on missing error handling in API routes -- Look for hardcoded configuration values - -## Insight Hints -- Report on overall test coverage metrics -- Note any unusually complex functions (cyclomatic complexity) - -## Constraints -- Do NOT propose changes already covered by open issues -- Do NOT propose changes rejected in recently closed issues -- Keep tasks small and isolated — one logical change per issue diff --git a/.github/workflows/fleet-analyze.yml b/.github/workflows/fleet-analyze.yml index 73efc52..32ba997 100644 --- a/.github/workflows/fleet-analyze.yml +++ b/.github/workflows/fleet-analyze.yml @@ -4,8 +4,6 @@ name: Fleet Analyze on: - schedule: - - cron: '0 */6 * * *' workflow_dispatch: inputs: goal: diff --git a/.github/workflows/fleet-merge.yml b/.github/workflows/fleet-merge.yml index 87ac367..751c9c3 100644 --- a/.github/workflows/fleet-merge.yml +++ b/.github/workflows/fleet-merge.yml @@ -4,8 +4,6 @@ name: Fleet Merge on: - schedule: - - cron: '0 */3 * * *' workflow_dispatch: inputs: mode: diff --git a/activity_main_patch.diff b/activity_main_patch.diff deleted file mode 100644 index 92b385f..0000000 --- a/activity_main_patch.diff +++ /dev/null @@ -1,19 +0,0 @@ ---- 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 5daf6f8..9db95d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.leanbitlab.lwidget" minSdk = 26 targetSdk = 35 - versionCode = 15 - versionName = "2.0" + versionCode = 16 + versionName = "2.1" } testOptions { diff --git a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt index 9bc7daa..5d0ee2a 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt @@ -138,8 +138,9 @@ class AwidgetProvider : AppWidgetProvider() { android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE ) - // 1 minute - val intervalMillis = 1L * 60L * 1000L + val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) + val intervalMinutes = prefs.getFloat("update_interval", 15f) + val intervalMillis = (intervalMinutes * 60f * 1000f).toLong().coerceAtLeast(60000L) // min 1 min alarmManager.setInexactRepeating( android.app.AlarmManager.RTC, diff --git a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig b/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig deleted file mode 100644 index ae333f9..0000000 --- a/app/src/main/java/com/leanbitlab/lwidget/AwidgetProvider.kt.orig +++ /dev/null @@ -1,1272 +0,0 @@ -/* - * 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/MainActivity.kt b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt index 56ba133..940fc69 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt @@ -108,6 +108,9 @@ class MainActivity : AppCompatActivity() { setupSections() updateLivePreview() + // Advanced Section + bindSlider(R.id.row_update_interval, "Update Interval (m)", "update_interval", 15f, 1f, 60f, "m") + // Setup Changelog val versionName = try { packageManager.getPackageInfo(packageName, 0).versionName @@ -117,26 +120,6 @@ class MainActivity : AppCompatActivity() { 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) @@ -201,11 +184,11 @@ class MainActivity : AppCompatActivity() { } 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) { + val calMissing = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED + if (prefs.getBoolean("show_events", false) && calMissing) { 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 @@ -213,7 +196,8 @@ class MainActivity : AppCompatActivity() { } // Check Tasks - if (prefs.getBoolean("show_tasks", false) && ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) { + val tasksMissing = ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED + if (prefs.getBoolean("show_tasks", false) && tasksMissing) { 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 @@ -222,15 +206,13 @@ class MainActivity : AppCompatActivity() { // 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 (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 (stepMissing) { + 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 (prefs.getBoolean("show_steps", false) && 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 @@ -238,7 +220,8 @@ class MainActivity : AppCompatActivity() { } // Check Screen Time - if (prefs.getBoolean("show_screen_time", false) && !hasUsageStatsPermission()) { + val usageMissing = !hasUsageStatsPermission() + if (prefs.getBoolean("show_screen_time", false) && usageMissing) { 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 @@ -246,7 +229,7 @@ class MainActivity : AppCompatActivity() { } // Check Data Usage - if (prefs.getBoolean("show_data_usage", false) && !hasUsageStatsPermission()) { + if (prefs.getBoolean("show_data_usage", false) && usageMissing) { 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 @@ -254,21 +237,81 @@ class MainActivity : AppCompatActivity() { } // 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 - } + val weatherMissing = !isAppInstalled("org.breezyweather") || ContextCompat.checkSelfPermission(this, "org.breezyweather.READ_PROVIDER") != PackageManager.PERMISSION_GRANTED + if (prefs.getBoolean("show_weather_condition", false) && weatherMissing) { + 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() } + + // Update Permission Toggles + updatePermissionToggle(R.id.row_perm_calendar, "Calendar Events", !calMissing) { + if (calMissing) ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CALENDAR), 100) + else openAppSettings() + } + updatePermissionToggle(R.id.row_perm_tasks, "Tasks", !tasksMissing) { + if (tasksMissing) ActivityCompat.requestPermissions(this, arrayOf(AwidgetProvider.PERMISSION_READ_TASKS_ORG), 101) + else openAppSettings() + } + updatePermissionToggle(R.id.row_perm_steps, "Step Counter", !stepMissing) { + if (stepMissing) { + val neededPermissions = mutableListOf() + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + neededPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION) + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + ActivityCompat.requestPermissions(this, neededPermissions.toTypedArray(), 102) + } else openAppSettings() + } + updatePermissionToggle(R.id.row_perm_data_usage, "Data Usage", !usageMissing) { + try { startActivity(Intent(android.provider.Settings.ACTION_USAGE_ACCESS_SETTINGS)) } catch (e: Exception) {} + } + updatePermissionToggle(R.id.row_perm_screen_time, "Screen Time", !usageMissing) { + try { startActivity(Intent(android.provider.Settings.ACTION_USAGE_ACCESS_SETTINGS)) } catch (e: Exception) {} + } + updatePermissionToggle(R.id.row_perm_weather, "Weather", !weatherMissing) { + if (weatherMissing && !isAppInstalled("org.breezyweather")) { + try { + startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("market://details?id=org.breezyweather"))) + } catch (e: Exception) { + try { + startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://f-droid.org/packages/org.breezyweather/"))) + } catch (e2: Exception) {} + } + } else if (weatherMissing) { + ActivityCompat.requestPermissions(this, arrayOf("org.breezyweather.READ_PROVIDER"), 104) + } else openAppSettings() + } + } + + private fun updatePermissionToggle(viewId: Int, label: String, isGranted: Boolean, onClick: () -> Unit) { + val row = findViewById(viewId) + if (row != null) { + row.findViewById(R.id.row_label).text = label + val switchView = row.findViewById(R.id.row_switch) + switchView.setOnCheckedChangeListener(null) + switchView.isChecked = isGranted + + row.setOnClickListener { onClick() } + switchView.setOnClickListener { + switchView.isChecked = isGranted // Revert instantly + onClick() + } + } + } + + private fun openAppSettings() { + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = android.net.Uri.parse("package:" + packageName) + startActivity(intent) } override fun onResume() { @@ -756,6 +799,44 @@ class MainActivity : AppCompatActivity() { } + + private fun bindCategoryFoldable(headerId: Int, contentId: Int, title: String, iconResId: Int, prefKey: String) { + accordionViews[prefKey] = findViewById(contentId) + accordionHeaders[prefKey] = findViewById(headerId) + + val header = findViewById(headerId) + header.findViewById(R.id.header_title).text = title + val headerIcon = header.findViewById(R.id.header_icon) + if (iconResId != 0) { + headerIcon.setImageResource(iconResId) + headerIcon.visibility = View.VISIBLE + } else { + headerIcon.visibility = View.GONE + } + + val content = findViewById(contentId) + val chevron = header.findViewById(R.id.header_chevron) + val isExpanded = prefs.getBoolean(prefKey, false) + content.visibility = if (isExpanded) View.VISIBLE else View.GONE + chevron.rotation = if (isExpanded) 180f else 0f + + header.setOnClickListener { + val nowExpanded = content.visibility != View.VISIBLE + if (nowExpanded) { + collapseAllExcept(prefKey) + content.visibility = View.VISIBLE + prefs.edit().putBoolean(prefKey, true).apply() + } else { + content.visibility = View.GONE + prefs.edit().putBoolean(prefKey, false).apply() + } + android.animation.ObjectAnimator.ofFloat(chevron, "rotation", if (nowExpanded) 180f else 0f).apply { + duration = 300 + start() + } + } + } + private fun setupSections() { contentSwitches.clear() @@ -778,6 +859,11 @@ class MainActivity : AppCompatActivity() { setupKeepAliveSection() setupEventsAndTasksSections() setupThemeSection(colorOptions) + + // System sections + bindCategoryFoldable(R.id.header_advanced, R.id.content_advanced, "Advanced", 0, "section_advanced_expanded") + bindCategoryFoldable(R.id.header_permissions, R.id.content_permissions, "Permissions", 0, "section_permissions_expanded") + bindCategoryFoldable(R.id.header_about, R.id.content_about, "About", 0, "section_about_expanded") } private fun setupTimeSection(timeFormatOptions: List) { @@ -1043,29 +1129,39 @@ class MainActivity : AppCompatActivity() { } } 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 -> + // Keep Alive (now inside Advanced section, not folded) + bindToggle(R.id.row_keep_alive_toggle, "Enable Keep Alive", "keep_alive", false) { 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) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 104) - keepAliveSwitch.isChecked = false - return@setOnCheckedChangeListener + neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (neededPermissions.isNotEmpty()) { + val switch = findViewById(R.id.row_keep_alive_toggle).findViewById(R.id.row_switch) + switch.isChecked = false + ActivityCompat.requestPermissions(this, neededPermissions.toTypedArray(), 105) + return@bindToggle } } 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() + val hasActivityPerm = android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED + if ((isChecked || showSteps) && hasActivityPerm) { + try { + ContextCompat.startForegroundService(this, serviceIntent) + } catch (e: Exception) { + e.printStackTrace() + } + } else { + stopService(serviceIntent) + } } } private fun setupEventsAndTasksSections() { @@ -1482,7 +1578,7 @@ class MainActivity : AppCompatActivity() { private fun bindSlider( viewId: Int, title: String, prefKey: String, defValue: Float, - minValue: Float, maxValue: Float + minValue: Float, maxValue: Float, suffix: String = "%" ) { val row = findViewById(viewId) val tvTitle = row.findViewById(R.id.row_label) @@ -1495,11 +1591,11 @@ class MainActivity : AppCompatActivity() { slider.valueFrom = minValue slider.valueTo = maxValue slider.value = currentValue.coerceIn(minValue, maxValue) - tvValue.text = "${currentValue.toInt()}%" + tvValue.text = "${currentValue.toInt()}$suffix" slider.addOnChangeListener { _, value, fromUser -> if (fromUser) { - tvValue.text = "${value.toInt()}%" + tvValue.text = "${value.toInt()}$suffix" prefs.edit().putFloat(prefKey, value).apply() updateWidget() } diff --git a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig deleted file mode 100644 index ccf949a..0000000 --- a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt.orig +++ /dev/null @@ -1,1506 +0,0 @@ -/* - * 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) - - if (prefs.getBoolean("is_first_launch", true)) { - startActivity(Intent(this, SetupActivity::class.java)) - finish() - return - } - - 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("date_color_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 - - // 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 -> - 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_date_color, R.id.row_date_color_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/SetupActivity.kt b/app/src/main/java/com/leanbitlab/lwidget/SetupActivity.kt index b00824d..416e051 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/SetupActivity.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/SetupActivity.kt @@ -59,9 +59,19 @@ class SetupActivity : AppCompatActivity() { switchKeepAlive.isChecked = prefs.getBoolean("keep_alive", false) switchKeepAlive.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { + val neededPermissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) { + neededPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - requestPermissionLauncher.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) + neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (neededPermissions.isNotEmpty()) { + switchKeepAlive.isChecked = false + requestPermissionLauncher.launch(neededPermissions.toTypedArray()) + return@setOnCheckedChangeListener } } prefs.edit().putBoolean("keep_alive", isChecked).apply() @@ -206,7 +216,10 @@ class SetupActivity : AppCompatActivity() { val keepAlive = prefs.getBoolean("keep_alive", false) val showSteps = prefs.getBoolean("show_steps", false) - if (keepAlive || showSteps) { + val hasActivityPerm = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED + + if ((keepAlive || showSteps) && hasActivityPerm) { val serviceIntent = Intent(this, StepCounterService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent) diff --git a/app/src/main/java/com/leanbitlab/lwidget/StepCounterService.kt b/app/src/main/java/com/leanbitlab/lwidget/StepCounterService.kt index 113a6e6..f0e79d3 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/StepCounterService.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/StepCounterService.kt @@ -42,6 +42,18 @@ class StepCounterService : Service(), SensorEventListener { override fun onCreate() { super.onCreate() createNotificationChannel() + + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, createNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH) + } else { + startForeground(NOTIFICATION_ID, createNotification()) + } + } catch (e: SecurityException) { + android.util.Log.e("LWidget", "Cannot start foreground service: missing permission", e) + stopSelf() + return + } val prefs = getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) val showSteps = prefs.getBoolean("show_steps", false) @@ -56,18 +68,6 @@ class StepCounterService : Service(), SensorEventListener { } } - try { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_ID, createNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH) - } else { - startForeground(NOTIFICATION_ID, createNotification()) - } - } catch (e: SecurityException) { - android.util.Log.e("LWidget", "Cannot start foreground service: missing permission", e) - stopSelf() - return - } - sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager stepSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index dc98ffe..60067c7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -95,444 +95,516 @@ android:layout_marginBottom="16dp" android:clipChildren="false"/> - - - - - - - - - - - - -