diff --git a/.gitignore b/.gitignore index a05276e7..fb8e9138 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ out/ # Gradle files .gradle/ +.gradle_home/ build/ # Local configuration file (sdk path, etc) diff --git a/README.md b/README.md index 126fdaf2..1d73c9df 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,6 @@ Google and Google specifically disclaims all warranties as to its quality, merchantability, or fitness for a particular purpose. Google Play and the Google Play logo are trademarks of Google LLC. + + +I just added some home screen widgets so that i can control my wled light just from my home screen without even opening the app. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f257f5e..2063e3ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Preset.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Preset.kt new file mode 100644 index 00000000..fbba691a --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Preset.kt @@ -0,0 +1,12 @@ +package ca.cgagnier.wlednativeandroid.model.wledapi + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Preset( + @Json(name = "n") val name: String = "", + @Json(name = "on") val on: Boolean? = null, + @Json(name = "bri") val brightness: Int? = null, + @Json(name = "mainseg") val mainSegment: Int? = null +) diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/DeviceApi.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/DeviceApi.kt index a88dbc23..39be5369 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/DeviceApi.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/DeviceApi.kt @@ -3,6 +3,7 @@ package ca.cgagnier.wlednativeandroid.service.api import ca.cgagnier.wlednativeandroid.model.Device import ca.cgagnier.wlednativeandroid.model.wledapi.Info import ca.cgagnier.wlednativeandroid.model.wledapi.JsonPost +import ca.cgagnier.wlednativeandroid.model.wledapi.Preset import ca.cgagnier.wlednativeandroid.model.wledapi.State import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -21,8 +22,17 @@ interface DeviceApi { @GET("json/info") suspend fun getInfo(): Response + @GET("presets.json") + suspend fun getPresets(): Response> + + @GET("json/state") + suspend fun getState(): Response + @POST("json/state") - suspend fun postJson(@Body state: JsonPost): Response + suspend fun postState(@Body state: State): Response + + @POST("json/state") + suspend fun postJson(@Body jsonPost: JsonPost): Response @Multipart @POST("update") @@ -54,6 +64,24 @@ class DeviceApiFactory(private val client: OkHttpClient) { return createForDeviceAndClient(baseUrl, client) } + /** + * Create a new DeviceApi instance with a custom timeout. + * + * @param address The address of a device to create the API for. + * @param timeout The custom timeout in seconds. + */ + fun create(address: String, timeout: Long): DeviceApi { + val baseUrl = if (!address.startsWith("http://") && !address.startsWith("https://")) { + "http://$address/" + } else { + address + } + val customClient = client.newBuilder().connectTimeout(timeout, TimeUnit.SECONDS) + .readTimeout(timeout, TimeUnit.SECONDS).writeTimeout(timeout, TimeUnit.SECONDS).build() + + return createForDeviceAndClient(baseUrl, customClient) + } + /** * Create a new DeviceApi instance for a device. * diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetConfigureActivity.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetConfigureActivity.kt new file mode 100644 index 00000000..9d4c19d2 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetConfigureActivity.kt @@ -0,0 +1,169 @@ +package ca.cgagnier.wlednativeandroid.widget + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import ca.cgagnier.wlednativeandroid.R +import ca.cgagnier.wlednativeandroid.model.Device +import ca.cgagnier.wlednativeandroid.repository.DeviceRepository +import ca.cgagnier.wlednativeandroid.ui.theme.WLEDNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@AndroidEntryPoint +class PresetWidgetConfigureActivity : ComponentActivity() { + + private val viewModel: PresetWidgetConfigureViewModel by viewModels() + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setResult(RESULT_CANCELED) + + val intent = intent + val extras = intent.extras + if (extras != null) { + appWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) + } + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + setContent { + WLEDNativeTheme { + DeviceSelectionScreen(viewModel) { device -> + saveDevicePref(this, appWidgetId, device) + + val appWidgetManager = AppWidgetManager.getInstance(this) + val info = appWidgetManager.getAppWidgetInfo(appWidgetId) + val providerClass = info?.provider?.className + if (providerClass == SmallPresetWidgetProvider::class.java.name) { + PresetWidgetProvider.updateAppWidget( + this, + appWidgetManager, + appWidgetId, + limit = 3, + layoutId = R.layout.widget_preset_horizontal, + listId = R.id.preset_grid, + itemLayoutId = R.layout.widget_preset_button_item, + titleViewId = null, + providerClass = SmallPresetWidgetProvider::class.java, + ) + } else { + PresetWidgetProvider.updateAppWidget(this, appWidgetManager, appWidgetId) + } + + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + finish() + } + } + } + } + + companion object { + private const val PREFS_NAME = "ca.cgagnier.wlednativeandroid.widget.PresetWidget" + private const val PREF_PREFIX_KEY = "appwidget_" + + internal fun saveDevicePref(context: Context, appWidgetId: Int, device: Device) { + val prefs = context.getSharedPreferences(PREFS_NAME, 0).edit() + prefs.putString(PREF_PREFIX_KEY + appWidgetId, device.address) + prefs.putString(PREF_PREFIX_KEY + appWidgetId + "_name", device.name) + prefs.apply() + } + + internal fun loadDeviceAddress(context: Context, appWidgetId: Int): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, 0) + return prefs.getString(PREF_PREFIX_KEY + appWidgetId, null) + } + + internal fun loadDeviceName(context: Context, appWidgetId: Int): String { + val prefs = context.getSharedPreferences(PREFS_NAME, 0) + return prefs.getString(PREF_PREFIX_KEY + appWidgetId + "_name", "WLED Device") ?: "WLED Device" + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeviceSelectionScreen(viewModel: PresetWidgetConfigureViewModel, onDeviceSelected: (Device) -> Unit) { + val devices by viewModel.allDevices.collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar(title = { Text("Select WLED Device") }) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + items(devices) { device -> + DeviceItem(device, onDeviceSelected) + } + } + } +} + +@Composable +private fun DeviceItem(device: Device, onClick: (Device) -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clickable { onClick(device) }, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = device.name, style = MaterialTheme.typography.titleMedium) + Text(text = device.address, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@HiltViewModel +class PresetWidgetConfigureViewModel @Inject constructor(private val deviceRepository: DeviceRepository) : ViewModel() { + val allDevices: Flow> = deviceRepository.allDevices +} + +val Device.name: String + get() = if (customName.isNotEmpty()) { + customName + } else if (originalName.isNotEmpty()) { + originalName + } else { + address + } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetProvider.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetProvider.kt new file mode 100644 index 00000000..d4cd40a3 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetProvider.kt @@ -0,0 +1,194 @@ +package ca.cgagnier.wlednativeandroid.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.widget.RemoteViews +import ca.cgagnier.wlednativeandroid.R +import ca.cgagnier.wlednativeandroid.model.wledapi.State +import ca.cgagnier.wlednativeandroid.service.api.DeviceApiFactory +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +open class PresetWidgetProvider : AppWidgetProvider() { + + @Inject + lateinit var deviceApiFactory: DeviceApiFactory + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == ACTION_TRIGGER_PRESET) { + val appWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) + val presetId = intent.getIntExtra(EXTRA_PRESET_ID, -1) + val deviceAddress = intent.getStringExtra(EXTRA_DEVICE_ADDRESS) + val listId = intent.getIntExtra(EXTRA_LIST_ID, R.id.preset_list) + + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && presetId != -1 && deviceAddress != null) { + triggerPreset(context, deviceAddress, presetId, appWidgetId, listId) + } + } + + if (intent.action == ACTION_REFRESH) { + val appWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) + val listId = intent.getIntExtra(EXTRA_LIST_ID, R.id.preset_list) + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + PresetWidgetRemoteViewsFactory.forceRefresh(appWidgetId) + val appWidgetManager = AppWidgetManager.getInstance(context) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, listId) + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun triggerPreset(context: Context, deviceAddress: String, presetId: Int, appWidgetId: Int, listId: Int) { + Log.d(TAG, "triggerPreset called for widget $appWidgetId, preset $presetId, device $deviceAddress") + CoroutineScope(Dispatchers.IO).launch { + try { + val api = deviceApiFactory.create(deviceAddress) + val response = api.postState(State(selectedPresetId = presetId)) + + if (response.isSuccessful) { + Log.d(TAG, "Successfully triggered preset $presetId for widget $appWidgetId") + val prefs = context.getSharedPreferences( + "ca.cgagnier.wlednativeandroid.widget.PresetWidgetCache", + Context.MODE_PRIVATE, + ).edit() + prefs.putInt("cache_selected_id_" + appWidgetId, presetId) + prefs.apply() + + PresetWidgetRemoteViewsFactory.forceRefresh(appWidgetId) + + val appWidgetManager = AppWidgetManager.getInstance(context) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, listId) + Log.d(TAG, "Triggered widget view update for $appWidgetId, listId $listId") + } else { + Log.e(TAG, "Failed to trigger preset: ${response.code()}") + } + } catch (e: Exception) { + Log.e(TAG, "Error triggering preset", e) + } + } + } + + companion object { + const val ACTION_TRIGGER_PRESET = "ca.cgagnier.wlednativeandroid.widget.ACTION_TRIGGER_PRESET" + const val ACTION_REFRESH = "ca.cgagnier.wlednativeandroid.widget.ACTION_REFRESH" + const val EXTRA_PRESET_ID = "ca.cgagnier.wlednativeandroid.widget.EXTRA_PRESET_ID" + const val EXTRA_DEVICE_ADDRESS = "ca.cgagnier.wlednativeandroid.widget.EXTRA_DEVICE_ADDRESS" + const val EXTRA_LIST_LIMIT = "ca.cgagnier.wlednativeandroid.widget.EXTRA_LIST_LIMIT" + const val EXTRA_LAYOUT_ID = "ca.cgagnier.wlednativeandroid.widget.EXTRA_LAYOUT_ID" + const val EXTRA_LIST_ID = "ca.cgagnier.wlednativeandroid.widget.EXTRA_LIST_ID" + private const val TAG = "PresetWidgetProvider" + + @Suppress("LongParameterList") + fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + limit: Int = 0, + layoutId: Int = R.layout.widget_preset, + listId: Int = R.id.preset_list, + itemLayoutId: Int = R.layout.widget_preset_item, + titleViewId: Int? = R.id.appwidget_text, + providerClass: Class = PresetWidgetProvider::class.java, + ) { + val deviceName = PresetWidgetConfigureActivity.loadDeviceName(context, appWidgetId) + val deviceAddress = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + + // Construct the RemoteViews object + val views = RemoteViews(context.packageName, layoutId) + + val configureIntent = Intent(context, PresetWidgetConfigureActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + } + val configurePendingIntent = PendingIntent.getActivity( + context, + appWidgetId, + configureIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + if (deviceAddress == null) { + titleViewId?.let { + views.setTextViewText(it, context.getString(R.string.widget_please_configure)) + } + views.setEmptyView(listId, R.id.empty_view) + views.setOnClickPendingIntent(R.id.empty_view, configurePendingIntent) + titleViewId?.let { + views.setOnClickPendingIntent(it, configurePendingIntent) + } + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + titleViewId?.let { + views.setTextViewText(it, deviceName) + views.setOnClickPendingIntent(it, configurePendingIntent) + } + + // Set up the collection + val intent = Intent(context, PresetWidgetService::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + putExtra(EXTRA_LIST_LIMIT, limit) + putExtra(EXTRA_LAYOUT_ID, itemLayoutId) + putExtra(EXTRA_LIST_ID, listId) + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + } + views.setRemoteAdapter(listId, intent) + views.setEmptyView(listId, R.id.empty_view) + + val refreshIntent = Intent(context, providerClass).apply { + action = ACTION_REFRESH + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + putExtra(EXTRA_LIST_ID, listId) + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + } + val refreshPendingIntent = PendingIntent.getBroadcast( + context, + appWidgetId, + refreshIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + views.setOnClickPendingIntent(R.id.empty_view, refreshPendingIntent) + + // Set up pending intent template for items + val toastIntent = Intent(context, providerClass).apply { + action = ACTION_TRIGGER_PRESET + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + putExtra(EXTRA_LIST_ID, listId) + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + } + val toastPendingIntent = PendingIntent.getBroadcast( + context, + appWidgetId, + toastIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + views.setPendingIntentTemplate(listId, toastPendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, views) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, listId) + } + } +} diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetService.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetService.kt new file mode 100644 index 00000000..198329c1 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/PresetWidgetService.kt @@ -0,0 +1,263 @@ +package ca.cgagnier.wlednativeandroid.widget + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import ca.cgagnier.wlednativeandroid.R +import ca.cgagnier.wlednativeandroid.model.wledapi.Preset +import ca.cgagnier.wlednativeandroid.service.api.DeviceApiFactory +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +class PresetWidgetService : RemoteViewsService() { + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = + PresetWidgetRemoteViewsFactory(this.applicationContext, intent) +} + +@Suppress("TooGenericExceptionCaught", "MagicNumber") +class PresetWidgetRemoteViewsFactory(private val context: Context, intent: Intent) : + RemoteViewsService.RemoteViewsFactory { + + private val appWidgetId: Int = intent.getIntExtra( + android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, + android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID, + ) + private val limit: Int = intent.getIntExtra(PresetWidgetProvider.EXTRA_LIST_LIMIT, 0) + private val itemLayoutId: Int = intent.getIntExtra( + PresetWidgetProvider.EXTRA_LAYOUT_ID, + R.layout.widget_preset_item, + ) + private var presets: List> = emptyList() + private var deviceAddress: String? = null + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface PresetWidgetServiceEntryPoint { + fun deviceApiFactory(): DeviceApiFactory + fun moshi(): Moshi + } + + override fun onCreate() { + // Data loading should ideally happen in onDataSetChanged + } + + private var selectedPresetId: Int = -1 + + @Suppress("LongMethod", "CyclomaticComplexMethod") + override fun onDataSetChanged() { + Log.d(TAG, "onDataSetChanged called for widget $appWidgetId") + deviceAddress = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (deviceAddress == null) { + Log.e(TAG, "Device address is null for widget $appWidgetId") + return + } + + val entryPoint = EntryPointAccessors.fromApplication( + context, + PresetWidgetServiceEntryPoint::class.java, + ) + val deviceApiFactory = entryPoint.deviceApiFactory() + val moshi = entryPoint.moshi() + + // Always load from cache first to ensure immediate display of existing data + loadPresetsFromCache(moshi) + Log.d(TAG, "Loaded ${presets.size} presets from cache for widget $appWidgetId") + + // Throttle check + val currentTime = System.currentTimeMillis() + val lastFetchTime = lastFetchTimeMap[appWidgetId] ?: 0L + val isForce = forceRefreshMap[appWidgetId] ?: false + + if (!isForce && currentTime - lastFetchTime < 15000L) { + Log.d(TAG, "Skipping network fetch for $appWidgetId, throttled") + return + } + + // Reset force flag and update last fetch time + forceRefreshMap[appWidgetId] = false + lastFetchTimeMap[appWidgetId] = currentTime + + runBlocking { + val address = deviceAddress ?: PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (address == null) { + Log.e(TAG, "Device address is null in runBlocking (widget $appWidgetId)") + return@runBlocking + } + deviceAddress = address + + try { + Log.d(TAG, "Fetching presets for $deviceAddress (widget $appWidgetId)") + val api = deviceApiFactory.create(deviceAddress!!, 4L) + + val stateDeferred = async(Dispatchers.IO) { api.getState() } + val presetsDeferred = async(Dispatchers.IO) { api.getPresets() } + + val stateResponse = try { + stateDeferred.await() + } catch (e: Exception) { + Log.e(TAG, "Error fetching state concurrently (widget $appWidgetId)", e) + null + } + + if (stateResponse != null && stateResponse.isSuccessful) { + val state = stateResponse.body() + Log.d(TAG, "State fetched successfully for widget $appWidgetId") + if (state != null) { + selectedPresetId = state.selectedPresetId ?: -1 + if (selectedPresetId == -1 && state.selectedPlaylistId != null && + state.selectedPlaylistId > 0 + ) { + selectedPresetId = state.selectedPlaylistId + } + } + } else { + Log.e(TAG, "State response failed for widget $appWidgetId: ${stateResponse?.code()}") + } + + val presetsResponse = try { + presetsDeferred.await() + } catch (e: Exception) { + Log.e(TAG, "Error fetching presets concurrently (widget $appWidgetId)", e) + null + } + + if (presetsResponse != null && presetsResponse.isSuccessful) { + val presetsMap = presetsResponse.body() + Log.d(TAG, "Presets fetched: ${presetsMap?.size} for widget $appWidgetId") + if (presetsMap != null) { + savePresetsToCache(presetsMap, moshi) + processPresets(presetsMap) + } + } else { + Log.e(TAG, "Error fetching presets for widget $appWidgetId: ${presetsResponse?.code() ?: "null response"}") + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching presets for widget $appWidgetId", e) + } + } + } + + private fun processPresets(presetsMap: Map) { + var sortedPresets = presetsMap.entries + .filter { it.key != "0" } + .map { it.key to it.value } + .sortedBy { it.first.toIntOrNull() ?: Int.MAX_VALUE } + + if (limit > 0) { + sortedPresets = sortedPresets.take(limit) + } + presets = sortedPresets + } + + private fun savePresetsToCache(presetsMap: Map, moshi: Moshi) { + try { + val type = Types.newParameterizedType(Map::class.java, String::class.java, Preset::class.java) + val adapter = moshi.adapter>(type) + val json = adapter.toJson(presetsMap) + + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() + prefs.putString(PREF_CACHE_PRESETS + appWidgetId, json) + prefs.putInt(PREF_CACHE_SELECTED_ID + appWidgetId, selectedPresetId) + prefs.apply() + } catch (e: Exception) { + Log.e(TAG, "Failed to save presets to cache", e) + } + } + + private fun loadPresetsFromCache(moshi: Moshi) { + try { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(PREF_CACHE_PRESETS + appWidgetId, null) + selectedPresetId = prefs.getInt(PREF_CACHE_SELECTED_ID + appWidgetId, -1) + + if (json != null) { + val type = Types.newParameterizedType(Map::class.java, String::class.java, Preset::class.java) + val adapter = moshi.adapter>(type) + val presetsMap = adapter.fromJson(json) + if (presetsMap != null) { + Log.d(TAG, "Loaded presets from cache for $appWidgetId. Selected: $selectedPresetId") + processPresets(presetsMap) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load presets from cache", e) + } + } + + // ... (rest of the file) + + override fun onDestroy() { + presets = emptyList() + } + + override fun getCount(): Int = presets.size + + // ... (rest of class) + + override fun getViewAt(position: Int): RemoteViews { + if (position >= presets.size) return RemoteViews(context.packageName, itemLayoutId) + + val (id, preset) = presets[position] + val views = RemoteViews(context.packageName, itemLayoutId) + + var name = preset.name + if (name.isEmpty()) { + name = "Preset $id" + } + + views.setTextViewText(R.id.preset_name, name) + + if (id.toIntOrNull() == selectedPresetId) { + if (itemLayoutId == R.layout.widget_preset_button_item) { + views.setInt(R.id.widget_item, "setBackgroundResource", R.drawable.widget_button_selected) + } else { + views.setViewVisibility(R.id.preset_indicator, android.view.View.VISIBLE) + } + } else { + if (itemLayoutId == R.layout.widget_preset_button_item) { + views.setInt(R.id.widget_item, "setBackgroundResource", R.drawable.widget_background) + } else { + views.setViewVisibility(R.id.preset_indicator, android.view.View.GONE) + } + } + + val fillInIntent = Intent().apply { + putExtra(PresetWidgetProvider.EXTRA_PRESET_ID, id.toIntOrNull() ?: -1) + putExtra(PresetWidgetProvider.EXTRA_DEVICE_ADDRESS, deviceAddress) + } + views.setOnClickFillInIntent(R.id.widget_item, fillInIntent) + + return views + } + + override fun getLoadingView(): RemoteViews? = null + + override fun getViewTypeCount(): Int = 1 + + override fun getItemId(position: Int): Long = position.toLong() + + override fun hasStableIds(): Boolean = true + + companion object { + private const val TAG = "PresetWidgetService" + private const val PREFS_NAME = "ca.cgagnier.wlednativeandroid.widget.PresetWidgetCache" + private const val PREF_CACHE_PRESETS = "cache_presets_" + private const val PREF_CACHE_SELECTED_ID = "cache_selected_id_" + + private val lastFetchTimeMap = java.util.concurrent.ConcurrentHashMap() + private val forceRefreshMap = java.util.concurrent.ConcurrentHashMap() + + fun forceRefresh(appWidgetId: Int) { + forceRefreshMap[appWidgetId] = true + } + } +} diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/SmallPresetWidgetProvider.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/SmallPresetWidgetProvider.kt new file mode 100644 index 00000000..7f8d1f50 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/SmallPresetWidgetProvider.kt @@ -0,0 +1,26 @@ +package ca.cgagnier.wlednativeandroid.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import ca.cgagnier.wlednativeandroid.R +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SmallPresetWidgetProvider : PresetWidgetProvider() { + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + for (appWidgetId in appWidgetIds) { + updateAppWidget( + context, + appWidgetManager, + appWidgetId, + limit = 3, + layoutId = R.layout.widget_preset_horizontal, + listId = R.id.preset_grid, + itemLayoutId = R.layout.widget_preset_button_item, + titleViewId = null, + providerClass = SmallPresetWidgetProvider::class.java, + ) + } + } +} diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt index 6374a0ea..2b495586 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt @@ -114,7 +114,7 @@ class WledWidgetConfigureActivity : ComponentActivity() { // TODO: Style the widget selection screen better so it looks more like the rest of the WLED app. @Composable -fun ConfigurationScreen(devices: List, onDeviceSelected: (Device) -> Unit) { +private fun ConfigurationScreen(devices: List, onDeviceSelected: (Device) -> Unit) { Scaffold( topBar = { TopAppBar( @@ -138,7 +138,7 @@ fun ConfigurationScreen(devices: List, onDeviceSelected: (Device) -> Uni } @Composable -fun DeviceItem(device: Device, onClick: () -> Unit) { +private fun DeviceItem(device: Device, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt index aabbdec3..19243fea 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt @@ -1,5 +1,7 @@ package ca.cgagnier.wlednativeandroid.widget +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.util.Log import androidx.glance.GlanceId @@ -53,6 +55,9 @@ class WledWidgetManager @Inject constructor( Log.d(TAG, "Updating widgets from websocket for MAC ${deviceWithState.device.macAddress}") updateWidgetsForMacAddress(context, deviceWithState.device.macAddress, newData) + + // Also update standard preset widgets + updatePresetWidgetsForDevice(context, deviceWithState.device.address) } suspend fun updateAllWidgets(context: Context) { @@ -152,6 +157,39 @@ class WledWidgetManager @Inject constructor( saveStateAndPush(context, glanceId, newData) } } + + // Update name and configurations for any preset widgets matching the device + val appWidgetManager = AppWidgetManager.getInstance(context) + val presetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, PresetWidgetProvider::class.java), + ) + for (appWidgetId in presetIds) { + val cachedAddress = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (cachedAddress == device.address) { + PresetWidgetConfigureActivity.saveDevicePref(context, appWidgetId, device) + PresetWidgetProvider.updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + val smallPresetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, SmallPresetWidgetProvider::class.java), + ) + for (appWidgetId in smallPresetIds) { + val cachedAddress = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (cachedAddress == device.address) { + PresetWidgetConfigureActivity.saveDevicePref(context, appWidgetId, device) + PresetWidgetProvider.updateAppWidget( + context, + appWidgetManager, + appWidgetId, + limit = 3, + layoutId = R.layout.widget_preset_horizontal, + listId = R.id.preset_grid, + itemLayoutId = R.layout.widget_preset_button_item, + providerClass = SmallPresetWidgetProvider::class.java, + ) + } + } } /** @@ -178,6 +216,53 @@ class WledWidgetManager @Inject constructor( } } } + + // Also handle preset widgets for the deleted device + val device = deviceRepository.findDeviceByMacAddress(macAddress) + val address = device?.address + if (address != null) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val presetPrefsName = "ca.cgagnier.wlednativeandroid.widget.PresetWidget" + + // Standard Preset Widgets + val presetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, PresetWidgetProvider::class.java), + ) + for (appWidgetId in presetIds) { + val cachedAddress = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (cachedAddress == address) { + val prefs = context.getSharedPreferences(presetPrefsName, 0).edit() + prefs.remove("appwidget_$appWidgetId") + prefs.remove("appwidget_${appWidgetId}_name") + prefs.apply() + PresetWidgetProvider.updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + // Small Preset Widgets + val smallPresetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, SmallPresetWidgetProvider::class.java), + ) + for (appWidgetId in smallPresetIds) { + val cachedAddress = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (cachedAddress == address) { + val prefs = context.getSharedPreferences(presetPrefsName, 0).edit() + prefs.remove("appwidget_$appWidgetId") + prefs.remove("appwidget_${appWidgetId}_name") + prefs.apply() + PresetWidgetProvider.updateAppWidget( + context, + appWidgetManager, + appWidgetId, + limit = 3, + layoutId = R.layout.widget_preset_horizontal, + listId = R.id.preset_grid, + itemLayoutId = R.layout.widget_preset_button_item, + providerClass = SmallPresetWidgetProvider::class.java, + ) + } + } + } } private suspend fun getWidgetState(context: Context, glanceId: GlanceId): WidgetStateData? { @@ -200,4 +285,41 @@ class WledWidgetManager @Inject constructor( } WledWidget().update(context, glanceId) } + + private fun updatePresetWidgetsForDevice(context: Context, deviceAddress: String) { + val appWidgetManager = AppWidgetManager.getInstance(context) + + // Standard Preset Widgets + val presetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, PresetWidgetProvider::class.java), + ) + for (appWidgetId in presetIds) { + val address = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (address == deviceAddress) { + PresetWidgetRemoteViewsFactory.forceRefresh(appWidgetId) + PresetWidgetProvider.updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + // Small Preset Widgets + val smallPresetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, SmallPresetWidgetProvider::class.java), + ) + for (appWidgetId in smallPresetIds) { + val address = PresetWidgetConfigureActivity.loadDeviceAddress(context, appWidgetId) + if (address == deviceAddress) { + PresetWidgetRemoteViewsFactory.forceRefresh(appWidgetId) + PresetWidgetProvider.updateAppWidget( + context, + appWidgetManager, + appWidgetId, + limit = 3, + layoutId = R.layout.widget_preset_horizontal, + listId = R.id.preset_grid, + itemLayoutId = R.layout.widget_preset_button_item, + providerClass = SmallPresetWidgetProvider::class.java, + ) + } + } + } } diff --git a/app/src/main/res/drawable/ic_dot_indicator.xml b/app/src/main/res/drawable/ic_dot_indicator.xml new file mode 100644 index 00000000..136435fa --- /dev/null +++ b/app/src/main/res/drawable/ic_dot_indicator.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..8fce07ac --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_button_selected.xml b/app/src/main/res/drawable/widget_button_selected.xml new file mode 100644 index 00000000..2c223c3a --- /dev/null +++ b/app/src/main/res/drawable/widget_button_selected.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_preset_small_preview.xml b/app/src/main/res/drawable/widget_preset_small_preview.xml new file mode 100644 index 00000000..cfb04639 --- /dev/null +++ b/app/src/main/res/drawable/widget_preset_small_preview.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview.xml b/app/src/main/res/drawable/widget_preview.xml new file mode 100644 index 00000000..ed550e79 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_preset.xml b/app/src/main/res/layout/widget_preset.xml new file mode 100644 index 00000000..34f20e11 --- /dev/null +++ b/app/src/main/res/layout/widget_preset.xml @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preset_button_item.xml b/app/src/main/res/layout/widget_preset_button_item.xml new file mode 100644 index 00000000..8520bca2 --- /dev/null +++ b/app/src/main/res/layout/widget_preset_button_item.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/layout/widget_preset_horizontal.xml b/app/src/main/res/layout/widget_preset_horizontal.xml new file mode 100644 index 00000000..75247a7e --- /dev/null +++ b/app/src/main/res/layout/widget_preset_horizontal.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/layout/widget_preset_item.xml b/app/src/main/res/layout/widget_preset_item.xml new file mode 100644 index 00000000..6e253da8 --- /dev/null +++ b/app/src/main/res/layout/widget_preset_item.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..1aad025b --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 8dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 779e2c79..12ead593 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,8 @@ Open Dismiss Some of your devices are hidden + Presets + No presets found Single Device Control a single WLED device diff --git a/app/src/main/res/xml/widget_preset_info.xml b/app/src/main/res/xml/widget_preset_info.xml new file mode 100644 index 00000000..17fbfa41 --- /dev/null +++ b/app/src/main/res/xml/widget_preset_info.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/xml/widget_preset_small_info.xml b/app/src/main/res/xml/widget_preset_small_info.xml new file mode 100644 index 00000000..718522f4 --- /dev/null +++ b/app/src/main/res/xml/widget_preset_small_info.xml @@ -0,0 +1,12 @@ + +